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

개발일지 2026-07-22

피아노 학원 플랫폼 개발일지 (2026-07-22)

개요

이 날은 학원 운영 관리 기능을 실제 동작하도록 완성하는 작업이 이어졌다. ① 환불 관리(더미 → 실데이터), ② 카카오 알림톡 템플릿 반려 대응(문구 수정 + DB 반영), ③ 악보(음원) 데이터 전체 초기화(백업 후 삭제)와 신규 데이터셋 임포트 준비. "화면만 있고 기능은 없던" 관리 페이지들을 하나씩 실제로 살려내는 흐름의 연장선이다.


수정/생성 파일 목록

백엔드 (piano-backend)

  • academy/refund/StudentRefund.java (신규) — 환불 엔티티(+studentName 조인)
  • academy/refund/StudentRefundMapper.java + mapper/StudentRefundMapper.xml (신규) — 목록/조회/insert/상태변경
  • academy/refund/StudentRefundService.java (신규) — 등록/승인/거절
  • academy/refund/StudentRefundController.java (신규) — /api/v1/academy/refunds
  • academy/refund/dto/StudentRefundRequest.java, StudentRefundProcessRequest.java (신규)
  • resources/migration-20260721e-student-refunds.sql + schema.sql (신규 테이블)

프론트 (piano-academy / academy 앱)

  • api/refunds.ts (신규) — 환불 API 클라이언트
  • pages/Refunds.tsx (수정) — 더미 제거, 실데이터 연동

DB 데이터 작업 (코드 아닌 운영)

  • academy_notification_templates — 반려된 알림톡 문구 2종 수정
  • 악보 관련 11개 테이블 — 백업 후 초기화

파일별 상세

1. 학원 환불 관리 (student_refunds)

academy/refunds 화면은 완전 더미(정하윤/이준서 하드코딩, 버튼은 목업 토스트)였다. 백엔드의 기존 admin/refund(registration_refunds)는 본사↔학원 등록비 환불이라 이 화면과 무관해서 재사용할 수 없었다. academy 도메인엔 환불 기능이 아예 없었다.

정책 확정:

  • 환불은 계좌이체로 수동 지급 → 시스템은 기록 관리만 (KSNET 카드취소 API 연동 안 함, 애초에 미구현이기도).
  • 흐름: 원장이 환불 등록 → PENDING → 승인(APPROVED)/거절(REJECTED) 2단계.
  • 수강권은 건드리지 않음 (환불 승인해도 student_ticket 상태 미변경, 원장이 별도 수동 처리).
  • 등록 폼: 학생 선택 → 그 학생 보유 수강권 자동 로딩(선택) + 금액 + 사유.

신규 테이블:

CREATE TABLE student_refunds (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    academy_id BIGINT NOT NULL,
    student_id BIGINT NOT NULL,
    student_ticket_id BIGINT NULL,
    ticket_name VARCHAR(100) NULL,
    refund_amount INT NOT NULL DEFAULT 0,
    method VARCHAR(20) NOT NULL DEFAULT 'TRANSFER',
    reason VARCHAR(500) NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    reject_reason VARCHAR(255) NULL,
    processed_by VARCHAR(100) NULL,
    processed_at TIMESTAMP NULL,
    created_by VARCHAR(100) NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_sr_academy (academy_id, status),
    INDEX idx_sr_student (student_id)
);

서비스는 원장 권한만 등록/처리, updateStatus는 PENDING일 때만 동작하도록 방어. 프론트는 학생 select → getStudentTickets로 수강권을 채우고, 승인/거절을 처리한다.

2. 카카오 알림톡 템플릿 반려 대응

레슨확정/체험확정 알림톡이 카카오 검수에서 반려됐다. 사유는 동일:

"수신자의 어떤 액션으로 발송되는지가 문구에 안 보인다."

알림톡은 수신자가 직접 한 행동(신청/예약)의 결과로 나가는 정보성 메시지만 허용한다. "확정되었습니다"만 있으면 일방적 발송인지 구분이 안 돼 반려된다. 해결은 문구에 액션 근거를 넣는 것:

템플릿반려 전통과용
레슨확정[*1*] 레슨이 확정되었습니다.[*1*] 님, **예약하신** 레슨이 확정되었습니다.
체험확정[*1*] 체험 레슨이 확정되었습니다.[*1*] 님, **신청하신** 체험 레슨이 확정되었습니다.

DB(academy_notification_templates)의 본사 기본(acad=0) + 기존 학원(acad=15,16) 전체를 **첫 줄만 정밀 치환(REPLACE)**해서 반영했다(나머지 본문은 유지). 총 5건 수정.

UPDATE academy_notification_templates
SET content = REPLACE(content, '[*1*] 레슨이 확정되었습니다.', '[*1*] 님, 예약하신 레슨이 확정되었습니다.')
WHERE type_code = 'LESSON_CONFIRM' AND content LIKE '[*1*] 레슨이 확정되었습니다.%';

배운 점: 알림톡 검수는 문구에 "신청하신/예약하신/결제하신" 같은 액션 트리거 단어가 반드시 있어야 통과된다. 변수([*1*])로 문장을 시작하는 것도 감점이라 님,을 붙여 문장형으로.

3. 알림 발송 정책 정리 (설계 논의)

알림 발송을 본사 표준으로 고정하되 학원에 자율성을 얼마나 줄지 논의가 있었다. 핵심은 "무엇이 스케줄러에 부하를 주는가"의 구분이었다:

  • 안 무거움: 전 학원 공통 배치 1개(현재 10분 주기 cron)로 "지금+55~65분 뒤 시작 레슨"을 한 방 쿼리로 긁어 발송. 학원이 1000개여도 쿼리 하나. (실제 AcademyNotiScheduler가 이 방식)
  • 무거움: 학원마다 배치 주기 자체가 제각각(15분? 45분?)인 경우 → 스케줄러가 학원별 리듬 관리.

결론적으로 "학원별 발송 요일 제외 / 만료 D-day 값 설정"은 공통 배치 위에 필터만 얹는 것이라 부하가 거의 없다. 반면 원래 폐지 대상이던 "주기 개별 설정"만 무거운 것이었다. → 부하 주는 주기는 고정, 부하 없는 요일·D-day는 학원 자율로 남기는 절충안이 합리적.

4. 악보(음원) 데이터 전체 초기화

새 데이터셋으로 갈아엎기 위해 기존 악보를 싹 정리했다. 먼저 11개 테이블 전체를 CSV로 백업한 뒤, 참조 하위→상위 순서로 삭제했다.

  • 삭제: sheet_action_logs, student_sheet_item_downloads, user_sheet_library, sheet_item_komca, sheet_items, sheet_song_categories, sheet_songs, sheets, sheet_requests
  • 유지: sheet_categories(50개 카테고리 마스터), sheet_preview_settings

주의 포인트가 하나 있었다. student_sheet_item_downloads는 이름은 "student"지만 실제로는 user_id로 기록되어 학생 다운로드 + 원장 출력이 섞인 통합 이력이었다 (printItemBytes가 원장 출력 시에도 같은 테이블에 insert). 어차피 새로 부으면 item_id가 전부 바뀌어 이전 이력은 의미 없어서 함께 초기화했다.

5. 악보 임포트 준비 (진행 중)

새 데이터셋(0722 엠포레 악보DB/)은 엑셀 1개 + PDF 99개 + 썸네일 30개 구조다.

  • 곡 MS1~MS33 (33곡), 곡마다 난이도/편성별 PDF 여러 개(MS1-0001 등).
  • 정합성 분석: 엑셀 102행 중 98개 적재 / 4개 스킵(MS3 중복 3, MS13 중복 1), MS16만 썸네일 없음.

엑셀 라벨 함정: '장르(소분류)' 컬럼에 실제로는 대분류값("가요")이, '장르(대분류)' 컬럼에 소분류값("K-POP")이 들어있었다. 라벨이 반대라 값 기준으로 DB 카테고리(가요=depth1, K-POP=depth2)에 매핑해야 했다.

S3 권한 이슈: 로컬 AWS 계정(mandoo1027)은 piano-sheets-dev 버킷에 PutObject 권한이 없었다(AccessDenied). 서버(EC2)는 IAM Role로 접근 가능하므로 임포트를 EC2에서 실행하는 방식으로 전환했다. (PDF는 private/sheet/..., 썸네일은 별도 경로로 S3 업로드 + uploaded_files/sheet_songs/items 적재)


트러블슈팅 메모 (삽질 기록)

  • MyBatis type-alias 충돌: 환불 DTO를 RefundProcessRequest로 만들었더니, 기존 admin.refund의 동명 클래스와 MyBatis alias(단순 클래스명)가 충돌해 런타임 SqlSessionFactory 초기화 실패로 백엔드가 크래시했다. 로컬 컴파일은 통과했기에(런타임 초기화에서만 터짐) 배포 후에야 발견. → 클래스명을 StudentRefundProcessRequest로 바꿔 즉시 재빌드·재배포하여 복구. 교훈: 도메인이 달라도 MyBatis가 스캔하는 DTO는 클래스명이 전역 유일해야 안전하다.
  • 로컬 S3 권한 부재: IAM 사용자별로 List/Put 권한이 갈린다. 로컬에서 안 되면 EC2 IAM Role로 우회.

검증 & 배포

  • 환불: 백엔드 무중단 배포(좀비 JVM 정리 절차) + academy 프론트 배포. /academy/refunds 200, 신규 엔드포인트 401(인증 필요=존재) 확인. alias 충돌로 1차 크래시 → 수정 후 재배포로 복구.
  • 알림톡/악보초기화: DB 데이터 변경이라 배포·재시작 불필요(조회 즉시 반영).
  • 커밋: 백엔드 40fdd8b, 프론트 1e551e9 push 완료.

결론 / 배운 점

  • **"진실의 원천"과 "결합도"**를 계속 신경 썼다. 환불은 수강권과 결합하지 않고 순수 기록으로, 악보 이력은 어차피 재적재 시 무의미하니 과감히 초기화.
  • 외부 검수(카카오)는 정책이 곧 스펙이다. 문구 한 단어("예약하신")가 통과/반려를 가른다.
  • 배포해봐야 드러나는 런타임 문제(alias 충돌)가 있다. 무중단 배포 절차 덕에 빠르게 복구할 수 있었다.
  • 권한(S3 IAM)은 코드가 아니라 실행 위치로 푸는 게 빠를 때가 있다(로컬 대신 EC2).

댓글 0

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