개발일지 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/refundsacademy/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/refunds200, 신규 엔드포인트 401(인증 필요=존재) 확인. alias 충돌로 1차 크래시 → 수정 후 재배포로 복구. - 알림톡/악보초기화: DB 데이터 변경이라 배포·재시작 불필요(조회 즉시 반영).
- 커밋: 백엔드
40fdd8b, 프론트1e551e9push 완료.
결론 / 배운 점
- **"진실의 원천"과 "결합도"**를 계속 신경 썼다. 환불은 수강권과 결합하지 않고 순수 기록으로, 악보 이력은 어차피 재적재 시 무의미하니 과감히 초기화.
- 외부 검수(카카오)는 정책이 곧 스펙이다. 문구 한 단어("예약하신")가 통과/반려를 가른다.
- 배포해봐야 드러나는 런타임 문제(alias 충돌)가 있다. 무중단 배포 절차 덕에 빠르게 복구할 수 있었다.
- 권한(S3 IAM)은 코드가 아니라 실행 위치로 푸는 게 빠를 때가 있다(로컬 대신 EC2).
댓글 0
- 첫 번째 댓글을 남겨보세요.