사이트픽 블로그
← 목록으로

개발일지 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 확인 → E2E ping=200/auth/me=401/stats/dashboard=401(미인증 보호 확인).
  • 프론트 admin build(dev-URL 스캔 통과) → git commit 860b314 → S3 admin/ 업로드 → 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

  • 첫 번째 댓글을 남겨보세요.