개발일지 2026-08-10
음악학원 플랫폼 개발일지 (2026-08-10)
개요
이날은 세 가지를 처리했다. (1) SMS/LMS 자동 판정을 EUC-KR 바이트 기준으로 고쳐 재발송 시 90byte 초과로 나던 500 에러를 잡고, (2) 발송 이력 목록을 서버 페이징으로 전환하고, (3) 관리자 대시보드를 실제 집계 stats API 로 연동한 뒤, 고객 요구에 맞춰 원생수 / 악보 출력(원장·수강생) / 만료 임박 학원 3종 위젯으로 재구성해 운영 배포까지 마쳤다.
수정/생성 파일 목록
백엔드 (piano-backend)
.../admin/sms/SmsByteUtil.java(신규) — EUC-KR 바이트 계산 유틸.../admin/messagelog/MessageLog.java/MessageLogController.java(수정),.../admin/sms/PpurioService.java/SmsSendService.java,.../admin/smstemplate/SmsTemplateService.java(수정) — 바이트 기준 SMS/LMS 자동 판정.../admin/messagelog/MessageLogPage.java(신규),MessageLogService.java/MessageLogController.java/MessageLogMapper.java(수정),mapper/MessageLogMapper.xml(수정) — 발송 이력 서버 페이징.../admin/stats/StatsController.java/StatsService.java/StatsMapper.java/DashboardStats.java(신규→확장),mapper/StatsMapper.xml(신규→확장) — 대시보드 집계 API
프론트 (admin)
admin/src/api/messageLogs.ts/admin/src/pages/MessageLogs.tsx(수정) — 서버 페이징 적용, 재발송 LMS 확인창 분기admin/src/api/stats.ts(신규),admin/src/pages/Dashboard.tsx(실데이터 연동 후 3요구 재구성)
파일별 상세
1. SMS/LMS 자동 판정 — EUC-KR 바이트 기준 (재발송 500 해결)
문자 발송사(뿌리오)는 90바이트를 넘으면 SMS 가 아니라 LMS 로 보내야 한다. 이때 "바이트"는 UTF-8 이 아니라 EUC-KR(한글 2바이트) 기준이다. 기존 로직이 이 기준을 안 맞춰, 특히 재발송 시 본문이 90byte 를 넘으면 SMS 타입으로 잘못 보내다 500 이 났다. SmsByteUtil 로 EUC-KR 바이트를 정확히 계산하고, 그 값으로 SMS/LMS 를 자동 판정하도록 통일했다.
// SmsByteUtil — EUC-KR 바이트 길이(한글 2, ASCII 1)
public static int euckrBytes(String text) {
if (text == null) return 0;
try {
return text.getBytes("EUC-KR").length;
} catch (UnsupportedEncodingException e) {
// fallback: 한글/전각 2바이트, 그 외 1바이트
return text.codePoints().map(c -> c > 0x7F ? 2 : 1).sum();
}
}
public static boolean isLms(String text) {
return euckrBytes(text) > 90;
}
프론트에서도 재발송 시 본문이 90byte 를 넘으면 "LMS 로 전송됩니다" 확인창을 띄우도록 분기(b29ba93)를 넣어, 사용자가 타입 변화를 인지하게 했다.
2. 발송 이력 서버 페이징
발송 이력이 쌓이면서 전량 조회가 부담이 됐다. 목록을 MessageLogPage(페이지/사이즈/전체건수) 응답으로 감싸고, 매퍼 XML 에 LIMIT/OFFSET + COUNT 를 추가해 서버 페이징으로 전환했다. admin 은 페이지네이션 상태를 서버로 넘겨 필요한 페이지만 받는다.
<!-- MessageLogMapper.xml -->
<select id="findPage" resultMap="MessageLogMap">
SELECT ... FROM message_log
<include refid="whereClause"/>
ORDER BY created_at DESC
LIMIT #{size} OFFSET #{offset}
</select>
<select id="countAll" resultType="long">
SELECT COUNT(*) FROM message_log <include refid="whereClause"/>
</select>
3. 관리자 대시보드 — 실 stats API 연동 후 3요구 재구성
먼저 대시보드를 더미에서 실제 집계 API(/admin/stats/dashboard)로 연동했다(3c8293e/024de4d). 이후 고객이 "다른 건 필요 없고 딱 3개만" 이라고 해서 대시보드를 아래 3종으로 재구성했다(ca5fe97/860b314).
- 전체 원생수:
students중status='ENROLLED'카운트. - 이번달 악보 출력(원장/수강생):
sheet_action_logs에서action='PRINT'를user_role별로 이번달 집계. - 만료 임박 학원(30일 이내):
academies.contract_end가 오늘~+30일 사이인 학원.
집계는 별도 마이그레이션 없이 SELECT 로만 처리했다.
<!-- StatsMapper.xml -->
<select id="findMonthlyPrintByRole" resultType="map">
SELECT
SUM(CASE WHEN user_role = 'OWNER' THEN 1 ELSE 0 END) AS ownerPrints,
SUM(CASE WHEN user_role = 'STUDENT' THEN 1 ELSE 0 END) AS studentPrints
FROM sheet_action_logs
WHERE action = 'PRINT'
AND DATE_FORMAT(created_at, '%Y-%m') = DATE_FORMAT(NOW(), '%Y-%m')
</select>
<select id="findExpiringAcademies" resultType="map">
SELECT a.name AS name, a.contract_end AS contractEnd,
DATEDIFF(a.contract_end, CURDATE()) AS dday
FROM academies a
WHERE a.contract_end IS NOT NULL
AND a.contract_end BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY)
ORDER BY a.contract_end ASC
</select>
응답 DTO(DashboardStats)는 기존 매출 필드를 지우지 않고 필드를 추가(additive) 해 하위호환을 지켰다. admin Dashboard.tsx 는 KPI 4장(원생수/원장출력/수강생출력/만료학원) + 이번달 출력 막대그래프 + 만료 임박 학원 테이블(D-day 칩)로 재구성했다.
참고로 "원장·수강생만 출력 가능"(강사 출력 차단)은 이미 백엔드
/student/sheet-songs/items/{itemId}/file에서 OWNER/STUDENT 만 허용하도록 구현돼 있어 추가 작업이 없었다.
검증 & 배포
- 백엔드 컴파일 성공 → git commit
ca5fe97→ docker arm64 빌드 → ECR push → EC2 #1 컨테이너 교체 →sec-on=200,Started PianoApiApplication, HikariPool 확인 → E2Eping=200/auth/me=401/stats/dashboard=401(미인증 보호 확인). - 프론트 admin build(dev-URL 스캔 통과) → git commit
860b314→ S3admin/업로드 → CloudFront/admin/*무효화 → 새 번들index-C4s86Ud1.js서빙 +/admin/dashboard=200확인.
트러블슈팅 메모
- ECR push connection reset: ~53MB app.jar 레이어 업로드 중 "connection reset by peer" 가 반복됐다.
docker push ... > /tmp/push.log 2>&1; rc=$?로 파이프가 아닌 push 자체의 exit code 를 보고 재시도하는 루프로 처리(재시도 시 이미 올라간 레이어는 이어받아 결국 성공).... | tail의 exit code 로 성공 판정하면 tail 이 0 을 리턴해 오탐이 난다. - 바이트 인코딩 함정: "몇 바이트냐"는 인코딩에 따라 다르다. 발송사 규격이 EUC-KR 이면 UTF-8 로 재면 안 된다. 규격 문서의 인코딩을 반드시 확인하자.
결론 / 배운 점
- 외부 연동(문자 발송)의 제약(90byte, EUC-KR)은 유틸로 한 곳에 캡슐화해 모든 경로(최초 발송·재발송·템플릿)가 같은 기준을 쓰게 해야 재발이 없다.
- 대시보드는 "많이 보여주기"보다 고객이 실제로 보는 3개만 남기는 게 낫다. 응답은 additive 로 확장해 하위호환을 지켰다.
- 목록은 데이터가 쌓이기 전에 서버 페이징으로 가는 게 미래 부담을 줄인다.
댓글 0
- 첫 번째 댓글을 남겨보세요.