개발일지 2026-07-13
개발일지 (2026-07-13)
개요
피아노 학원 플랫폼에서 두 가지 작업을 했다.
- 악보 미리보기 URL 난수 토큰화 — 미리보기 URL이
.../items/MS16-0003/preview/1처럼 곡코드를 그대로 노출해, 코드를 1씩 올려가며 전체 악보를 긁는 순회(enumeration)가 가능했다. 예측 불가능한 난수 토큰으로 바꿔 이 순회를 차단했다. - 학생 앱 "내 수강권" 화면 — 학생이 자기 수강권의 남은 횟수·만료일을 보는 화면을 스크린샷 디자인에 맞춰 만들었다. 기존 화면은 enrollment(수강 코스) 기반이라, 잔여 횟수 개념인
student_tickets기반으로 재작성했다.
마지막으로 두 레포(back/front) 커밋·푸시 + dev(mforet.kr) 전체 배포까지 마쳤다.
수정/생성 파일 목록
백엔드 (piano-backend)
src/main/resources/migration-20260713g-sheet-item-preview-token.sql(신규) —sheet_items.preview_token컬럼 추가 + 백필 + 유니크 인덱스.../student/sheetsong/SheetItem.java(수정) —previewToken필드 추가.../student/sheetsong/StudentSheetSongMapper.java(수정) —findPdfFilenameByPreviewToken추가.../resources/mapper/StudentSheetSongMapper.xml(수정) — itemMap/SELECT에 preview_token, 토큰 역참조 쿼리.../resources/mapper/AdminSheetSongMapper.xml(수정) — insertItem이 토큰 자동 생성(REPLACE(UUID(),'-','')).../publicsite/sheetsong/PublicSheetSongController.java(수정) — 미리보기 경로변수{pdfFilename}→{previewToken}.../publicsite/sheetsong/PublicSheetSongService.java(수정) —resolvePdfFilename추가, 공개 상세에서 pdfFilename 마스킹.../student/ticket/StudentMyTicketController.java(신규) —GET /api/v1/student/tickets.../student/ticket/StudentMyTicketService.java(신규) — userId→본인 학생→보유 수강권 조회
프론트 (student 앱)
src/api/studentTickets.ts(신규) — 내 수강권 조회 APIsrc/pages/MyTicketsPage.tsx(수정/재작성) — 잔여 횟수 카드 + 메뉴 + 보유 목록src/pages/MyPaymentsPage.tsx(신규) — 결제 내역 조회 페이지src/App.tsx(수정) —/my-payments라우트 추가
프론트 (sheets 앱)
src/lib/api/sheetSongs.ts(수정) — 미리보기 호출을 previewToken 기반으로 변경src/app/songs/page.tsx(수정) — 미리보기 버튼이it.previewToken사용
파일별 상세
1. 미리보기 토큰화 — 왜 필요했나
기존 공개 미리보기 API는 이런 식이었다.
GET /api/v1/public/sheet-songs/items/MS16-0003/preview/1
MS16-0003은 곡코드다. 규칙성이 있어서 MS16-0001, MS16-0002 … 로 올려가며 요청하면 로그인 없이 모든 곡의 미리보기(워터마크 찍힌 앞 3장)를 긁을 수 있었다. 미리보기 자체는 홍보용으로 공개하는 게 맞지만, 곡코드 기반 순회로 카탈로그 전체가 통째로 노출되는 게 문제였다.
해결 방향은 "미리보기 URL에서 곡코드를 없애고, 예측 불가능한 난수 토큰으로 바꾸기"다.
2. 마이그레이션 — 토큰 컬럼 추가
ALTER TABLE sheet_items ADD COLUMN preview_token VARCHAR(32) NULL AFTER pdf_filename;
UPDATE sheet_items SET preview_token = REPLACE(UUID(), '-', '') WHERE preview_token IS NULL;
CREATE UNIQUE INDEX uq_sheet_items_preview_token ON sheet_items (preview_token);
REPLACE(UUID(), '-', '')로 32자리 hex 문자열을 만든다. 기존 데이터는 UPDATE로 백필하고, 유니크 인덱스로 충돌을 원천 차단한다. 신규 등록되는 item은 AdminSheetSongMapper.xml의 insertItem에서 같은 방식으로 토큰을 자동 부여한다.
3. 엔티티 & 매퍼
SheetItem.java에 필드 하나 추가.
private String pdfFilename;
/** 미리보기 URL용 난수 토큰(곡코드 순회 차단). 공개 응답에서 pdfFilename 대신 노출. */
private String previewToken;
토큰으로 실제 파일명을 되찾는 역참조 쿼리를 매퍼에 추가한다.
<select id="findPdfFilenameByPreviewToken" resultType="string">
SELECT pdf_filename FROM sheet_items WHERE preview_token = #{previewToken}
</select>
4. 서비스 — 토큰 검증 + 파일명 마스킹
핵심은 두 가지다.
(a) 공개 상세 응답에서 pdfFilename을 숨긴다. 곡코드가 담긴 pdf_filename이 상세 응답에 그대로 나가면 토큰화가 무의미하다.
public SheetSong getSong(Long songId) {
SheetSong song = mapper.findSongById(songId);
if (song == null) throw new BusinessException(ErrorCode.SHEET_NOT_FOUND);
List<SheetItem> items = mapper.findItemsBySong(songId);
// pdf_filename(곡코드 예측 가능) 은 공개 응답에서 마스킹 — 프론트는 previewToken 으로 미리보기.
items.forEach(it -> it.setPdfFilename(null));
song.setItems(items);
return song;
}
(b) 토큰을 검증한 뒤 역참조한다. 형식(32 hex) 검증을 먼저 하고, DB에 없으면 404.
private String resolvePdfFilename(String previewToken) {
if (previewToken == null || !previewToken.matches("[0-9a-fA-F]{32}")) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND);
}
String pdfFilename = mapper.findPdfFilenameByPreviewToken(previewToken);
if (pdfFilename == null || pdfFilename.isBlank()) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND);
}
return pdfFilename;
}
컨트롤러의 경로변수도 {pdfFilename} → {previewToken}으로 바꿨다. S3 저장 키(public/preview/{pdfFilename}/page-{page}.png)는 그대로 두고, 서버가 토큰 → pdf_filename 역참조 후 기존 키로 접근하므로 스토리지 구조 변경은 없다.
5. 프론트(sheets) 대응
sheetSongs.ts의 미리보기 함수가 토큰을 받도록 바꿨다.
export async function getPreviewPages(previewToken: string): Promise<number[]> {
return apiGet<number[]>(`/public/sheet-songs/items/${encodeURIComponent(previewToken)}/preview`) ?? [];
}
songs/page.tsx의 미리보기 버튼은 it.pdfFilename 대신 it.previewToken을 쓴다. 공개 응답에서 pdfFilename이 이제 null이라, 여길 안 바꾸면 미리보기가 깨진다.
6. 학생 "내 수강권" 화면
스크린샷은 이런 구조였다.
- 상단 다크 카드: 현재 수강권 / 남은 횟수(8회) / 수강 기간 / 만료일(D-18)
- 메뉴: 수강권 결제 / 결제 내역 조회
- 하단탭: 홈·예약·레슨·악보·마이페이지
이건 "남은 횟수"를 쓰는 student_tickets(원장이 발급하고 예약 시 1회 차감되는 잔여 원장) 데이터다. 그런데 기존 MyTicketsPage는 enrollment(수강 코스) 기반이라 잔여 횟수 개념이 없었다. 그래서 백엔드에 학생 본인 수강권 조회 API를 추가하고 화면을 재작성했다.
백엔드 조회 API — academyId/studentId를 클라이언트가 아니라 서버가 "본인 학생"에서 강제한다(다른 학생 조회 차단).
@Transactional(readOnly = true)
public List<StudentTicket> myTickets(Long userId) {
Student student = requireStudent(userId); // userId → 본인 학생
return studentTicketMapper.findByStudent(student.getAcademyId(), student.getId());
}
프론트 상단 카드 — ACTIVE 중 만료 임박순으로 대표 수강권을 골라 표시하고, expiresAt으로 D-day를 계산한다.
function dday(expiresAt: string | null): string | null {
if (!expiresAt) return null; // 무제한
const today = new Date(); today.setHours(0,0,0,0);
const exp = new Date(`${expiresAt}T00:00:00`);
const diff = Math.round((exp.getTime() - today.getTime()) / 86400000);
if (diff < 0) return '만료';
return diff === 0 ? 'D-DAY' : `D-${diff}`;
}
"수강권 결제"는 자동결제 미연동이라(원장 수동 발급 정책) 회색 + "준비 중" 안내로 처리하고, "결제 내역 조회"는 별도 MyPaymentsPage로 분리해 기존 payments API를 연결했다.
검증 & 배포
로컬 검증
- 백엔드
./gradlew compileJava통과,bootJar성공 - 프론트 student/academy(Vite), sheets(Next export) 빌드 성공
- sheets 빌드 산출물에
localhost:80800건 확인(.env.local오염 방지)
배포 (dev, mforet.kr)
- 백엔드: bootJar → scp →
/opt/piano/piano-api.jar→systemctl restart piano-api - student/academy: Vite build → tar →
/var/www/piano/{student,academy} - sheets: Next export → tar →
/var/www/piano/sheets→ nginx reload
라이브 검증 결과
| 항목 | 결과 |
|---|---|
/api/v1/public/ping | 200 |
공개 상세 pdfFilename | null (마스킹) |
공개 상세 previewToken | 45c434f8…(난수) |
옛 곡코드 MS16-0003/preview/1 | 404 (순회 차단) |
GET /student/tickets | 401 (엔드포인트 존재·인증 정상) |
| student/academy/sheets/songs 페이지 | 모두 200 |
트러블슈팅 메모 (삽질 기록)
- 셸 cwd 리셋 — 이 환경은 Bash 호출마다 작업 디렉터리가 초기화된다.
cd A && tar -C build .처럼 한 명령 안에서cd+ 상대경로를 묶어야 의도한 디렉터리가 잡힌다. tar 산출물이 맞는지 항상tar -tzf로 파일 해시(예:index-Ba6y2tRQ.js)를 대조했다. - 로컬 curl 없음 — 로컬 셸에서
curl이 안 잡혀서, 라이브 검증은 EC2에 SSH로 들어가 서버에서 curl을 돌렸다. - sheets
.env.local오염 — Next는.env.local을.env.production보다 우선 로드해서, 안 치우고 빌드하면 운영 번들에localhost:8080이 박힌다. 빌드 전.env.local을 잠깐 옮기고 → 빌드 →localhost0건 확인 → 원복 순서를 지켰다. - 공개 응답 pdfFilename 마스킹을 빼먹으면 토큰화가 무의미 — 상세 응답에서 곡코드가 그대로 새어나가기 때문. 서비스 계층에서
setPdfFilename(null)로 확실히 지웠다.
결론 / 배운 점
- 예측 가능한 식별자(곡코드)를 URL에 그대로 노출하면 순회 공격에 취약하다. 난수 토큰 + 서버측 역참조 + 형식 검증(정규식)으로 순회를 막되, 공개 응답 전 계층에서 원본 식별자가 새지 않는지(마스킹) 함께 챙겨야 한다.
- 다만 이 조치는 곡코드 순회만 막는다. 미리보기 페이지를 연 사용자가 네트워크 탭에서 토큰 URL을 복사·공유하는 건 여전히 가능하다(공개 엔드포인트라서). 완전 차단은 "미리보기도 로그인 필수"가 필요한데, 이건 비로그인 둘러보기 퍼널을 잃는 트레이드오프라 이번엔 순회 차단까지만 했다.
- 데이터 모델이 화면 의도와 맞는지 먼저 본다. "내 수강권" 화면이 잔여 횟수를 보여주려면 enrollment가 아니라 student_tickets가 소스여야 했다. 화면을 그리기 전에 어떤 테이블이 그 숫자를 갖고 있는지 확인하는 게 먼저다.
- 본인 데이터 스코프는 서버가 강제한다. academyId/studentId를 요청 파라미터로 받지 않고 userId→본인 학생에서 뽑아 쓰면, 남의 수강권을 훔쳐보는 경로가 애초에 생기지 않는다.
추가 정리 (23:56)
오후~밤 세션에서 대규모 작업을 진행했다. 학원 앱 수강생 상세, 악보 워터마크, 수강권 정책 이력, 비밀번호 초기화, 전역 다이얼로그 유틸, 결제 실패 화면 등.
수정/생성 파일 목록 (오후 세션)
백엔드 (piano-backend)
build.gradle(수정) — Apache PDFBox 3.0.3 의존성 추가PianoApiApplication.java(수정) —@EnableAsync추가PdfWatermarkService.java(신규) — 출력 PDF에 워터마크 이미지 타일 + 사용자 정보 footer 합성PreviewGenerationService.java(신규) — 악보 등록 시 미리보기 PNG 자동 생성 (비동기)resources/watermark.png(신규) — MFORET 워터마크 이미지StudentSheetSongController.java(수정) — 출력 시 워터마크 합성 적용AdminSheetSongService.java(수정) — 악보 등록/수정 시 미리보기 자동 생성 훅AdminSheetSongController.java(수정) — 미리보기 일괄 재생성 엔드포인트AdminSheetSongMapper.java(수정) —findItemsWithFile추가AdminSheetSongMapper.xml(수정) — 재생성용 쿼리StudentController.java(수정) —POST /{id}/reset-password비밀번호 초기화 엔드포인트StudentService.java(수정) —resetPassword메서드 추가TicketService.java(수정) — 이름/active만 수정 가능 + 이력 기록 로직AcademyTicketController.java(수정) — 이력 조회 엔드포인트 추가AcademyTicketMapper.java(수정) — 이력 INSERT/조회 메서드AcademyTicketMapper.xml(수정) — UPDATE 제한(이름/active만) + 이력 INSERT/SELECT 쿼리TicketPolicyHistory.java(신규) — 정책 이력 DTOmigration-20260713f-ticket-history.sql(신규) —ticket_edit_history,ticket_policy_history테이블migration-20260713g-ticket-policy-codes.sql(신규) — 수강권 정책 공통코드 (만료일/환불/추가구매)migration-20260714-ticket-orders.sql(신규) —ticket_orders결제 주문 테이블 (진행 중)TicketOrder.java(신규) — 결제 주문 엔티티TicketOrderMapper.java(신규) — 주문 MapperTicketOrderMapper.xml(신규) — 주문 CRUD 쿼리TicketPaymentService.java(신규) — 카드결제 준비/승인/실패, 계좌이체 요청/확인
프론트 — 학원 앱 (academy)
Students.tsx(수정) — 상세 팝업 탭 제거 → 등록 팝업과 동일 레이아웃, 비밀번호 초기화 버튼StudentRegister.tsx(수정) — 이메일 영역 축소Tickets.tsx(수정) — 체크박스+수강권명 링크+이력 버튼, 정책 설정 셀렉트박스(공통코드), 정책이력 모달students.ts(수정) —resetStudentPasswordAPI 추가tickets.ts(수정) —getTicketHistory,getAllTicketHistoryAPI 추가commonCodes.ts(수정) — 수강권 정책 코드 추가/제거 (이중관리 해소)lib/fx.ts(수정) —$fx.showAlert,$fx.showConfirm전역 다이얼로그 유틸 추가FxDialogHost.tsx(신규) — fx 다이얼로그 렌더링 호스트 컴포넌트App.tsx(수정) —FxDialogHost추가,fx.ts전역 등록 import
프론트 — 악보 앱 (sheets)
LoginModal.tsx(수정) — 배경 클릭 시 닫히지 않도록WatermarkOverlay.tsx(신규) — 미리보기 CSS 워터마크 오버레이 (상단+타일+하단)PasswordChangeModal.tsx(신규) — 초기 비밀번호 로그인 시 변경 강제 팝업AuthContext.tsx(수정) —passwordChangeRequired상태 + 모달 제어auth.ts(수정) —changePasswordAPI 추가layout.tsx(수정) —PasswordChangeModal추가songs/page.tsx(수정) — 미리보기에 워터마크 오버레이 적용globals.css(수정) — 워터마크 CSS 스타일
프론트 — 학생 앱 (student)
PaymentFailPage.tsx(신규) — 결제 실패 전용 페이지TicketPurchasePage.tsx(수정) — 실패 시 sessionStorage로 데이터 전달 후 실패 페이지 이동App.tsx(수정) —/payment-fail라우트 추가
파일별 상세
1. 악보 워터마크 시스템
출력용 PDF 워터마크 (서버사이드)
출력하기 버튼을 누르면 서버가 원본 PDF에 워터마크를 합성해서 내려준다. 사용자 입장에서는 추가 조작 없이 자동 적용.
// PdfWatermarkService.java — 핵심 흐름
public byte[] applyWatermark(byte[] pdfBytes, String userName, Long userId) {
// ① watermark.png 이미지를 타일 패턴으로 합성 (40% 투명도)
// ② 하단 footer: "홍길동(#1024) · 2026-07-13 14:32 · 무단복제 금지" (55% 투명도)
}
컨트롤러에서 응답 직전에 합성:
// StudentSheetSongController.java
byte[] pdfBytes = content.bytes();
User user = userMapper.findById(userId);
pdfBytes = watermarkService.applyWatermark(pdfBytes, user.getName(), userId);
미리보기 PNG 자동 생성
악보 등록/수정 시 비동기로 미리보기 PNG를 자동 생성한다. 기존에는 EC2에서 Python 배치 스크립트를 수동 실행했는데, 이제 백엔드가 자동 처리.
// PreviewGenerationService.java
@Async
public void generatePreview(Long fileId, String pdfFilename) {
// S3에서 PDF 다운로드 → PDFBox로 PNG 렌더(150DPI) → watermark.png 타일 합성 → S3 업로드
}
AdminSheetSongService의 createSong, addItem, updateItemFull에 훅을 걸어서 fileId가 있으면 자동 호출.
2. 수강생 상세 팝업 개편
기존: 탭 5개(기본정보/수강코스/레슨내역/출결/메모) 변경: 탭 제거 → 등록 팝업과 동일한 플랫 레이아웃(계정정보/추가정보/수강정보)
비밀번호 초기화 버튼도 이메일 옆에 추가. 백엔드에서 a!1234로 초기화하고, 수강생이 다음 로그인 시 비밀번호 변경 팝업이 뜬다.
3. $fx 전역 다이얼로그 유틸
매 페이지마다 useAppAlert, useAppConfirm hook을 import하는 비효율을 해결. window.$fx에 등록해서 import 없이 어디서든 호출 가능.
// 어디서든 import 없이:
await $fx.showAlert('저장되었습니다.')
const ok = await $fx.showConfirm({ title: '삭제', message: '삭제하시겠습니까?' })
구현: fx.ts에서 외부 store 패턴으로 다이얼로그 상태 관리 → FxDialogHost.tsx가 useSyncExternalStore로 구독해서 MUI Dialog 렌더링. 전체 14곳의 window.confirm/alert를 $fx로 교체.
4. 수강권 정책 이력 시스템
수강권 상품 변경 시 이력을 남기는 두 테이블:
ticket_edit_history: 필드별 변경 전/후 기록 (이름, active 등)ticket_policy_history: 생성/수정/비활성화 시 전체 정책 스냅샷
수강권 수정도 제한: 이름/active/정렬순서만 수정 가능, 가격/횟수/기간 등 정책은 불변(새로 만들어야 함).
정책 설정 셀렉트박스 옵션은 DB 공통코드(common_code)에서 가져오도록 변경.
5. 결제 실패 페이지 + sessionStorage 데이터 전달
결제 실패 시 URL 파라미터 대신 sessionStorage에 JSON으로 저장 후 페이지 이동. 한글/특수문자 이슈 없고, 탭 닫으면 자동 소멸.
// 저장 (TicketPurchasePage)
sessionStorage.setItem('pageParams', JSON.stringify({ reason, ticket, amount }));
navigate('/payment-fail');
// 읽기 (PaymentFailPage)
const raw = sessionStorage.getItem('pageParams');
if (raw) { setInfo(JSON.parse(raw)); sessionStorage.removeItem('pageParams'); }
6. 수강권 결제 시스템 (진행 중)
카드결제(KSNET PG) + 계좌이체(원장 수동확인) 두 경로를 지원하는 결제 시스템. ticket_orders 테이블과 TicketPaymentService까지 만들었고, 프론트 UI + KSNET 연동 + 학원 앱 입금확인 화면은 다음 세션에서 이어진다.
트러블슈팅 메모
- PDFBox 3.x API 변경:
PDDocument.load(byte[])→Loader.loadPDF(byte[]). 버전별 API 차이 주의. - sheets
.env.local오염 재발: 배포 스킬에.env.local임시 제거 로직을 명시적으로 추가함. - 공통코드 API 타이밍 이슈: 셀렉트 컴포넌트가 공통코드 로딩 전에 렌더링되면 MUI
out-of-range경고.loading상태 확인 후 렌더링하고, 근본 원인은 DBcode_group테이블에 그룹이 안 들어간 것이었음 (컬럼명 불일치). - SUPER_ADMIN 없음: 미리보기 재생성 API 호출 시 admin 권한 사용자가 DB에 없어서, 임시로 public 열고 호출 후 즉시 제거하는 방식으로 처리.
결론 / 배운 점
- 워터마크는 서버에서 합성해야 의미 있다. 클라이언트 CSS 오버레이는 개발자도구로 제거 가능. 출력 PDF에 서버가 합성하면 탈취해도 추적 가능.
- 미리보기 자동 생성은 악보 등록 흐름에 통합해야 한다. 수동 배치 스크립트는 깜빡하면 미리보기 없는 악보가 운영에 올라간다.
- 전역 유틸($fx)은 hook 지옥을 해소한다. 매 페이지마다
useAppConfirmimport하는 건 비효율적. 외부 store 패턴으로 React Provider 밖에서도 호출 가능하게 만들면 코드가 깔끔해진다. - 정책 변경은 반드시 이력을 남긴다. 수강권 가격이 바뀌었을 때 "이 학생이 발급받을 당시 가격"을 추적할 수 없으면 분쟁 시 대응이 안 된다. 스냅샷 저장 필수.
- 페이지 간 데이터 전달은 sessionStorage가 적절하다. URL 파라미터는 한글/길이 이슈, localStorage는 탭 간 공유돼서 충돌 가능. sessionStorage는 탭 단위로 격리되고 닫으면 소멸.
댓글 0
- 첫 번째 댓글을 남겨보세요.