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

개발일지 2026-07-13

개발일지 (2026-07-13)

개요

피아노 학원 플랫폼에서 두 가지 작업을 했다.

  1. 악보 미리보기 URL 난수 토큰화 — 미리보기 URL이 .../items/MS16-0003/preview/1처럼 곡코드를 그대로 노출해, 코드를 1씩 올려가며 전체 악보를 긁는 순회(enumeration)가 가능했다. 예측 불가능한 난수 토큰으로 바꿔 이 순회를 차단했다.
  2. 학생 앱 "내 수강권" 화면 — 학생이 자기 수강권의 남은 횟수·만료일을 보는 화면을 스크린샷 디자인에 맞춰 만들었다. 기존 화면은 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 (신규) — 내 수강권 조회 API
  • src/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:8080 0건 확인(.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/ping200
공개 상세 pdfFilenamenull (마스킹)
공개 상세 previewToken45c434f8…(난수)
옛 곡코드 MS16-0003/preview/1404 (순회 차단)
GET /student/tickets401 (엔드포인트 존재·인증 정상)
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을 잠깐 옮기고 → 빌드 → localhost 0건 확인 → 원복 순서를 지켰다.
  • 공개 응답 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 (신규) — 정책 이력 DTO
  • migration-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 (신규) — 주문 Mapper
  • TicketOrderMapper.xml (신규) — 주문 CRUD 쿼리
  • TicketPaymentService.java (신규) — 카드결제 준비/승인/실패, 계좌이체 요청/확인

프론트 — 학원 앱 (academy)

  • Students.tsx (수정) — 상세 팝업 탭 제거 → 등록 팝업과 동일 레이아웃, 비밀번호 초기화 버튼
  • StudentRegister.tsx (수정) — 이메일 영역 축소
  • Tickets.tsx (수정) — 체크박스+수강권명 링크+이력 버튼, 정책 설정 셀렉트박스(공통코드), 정책이력 모달
  • students.ts (수정) — resetStudentPassword API 추가
  • tickets.ts (수정) — getTicketHistory, getAllTicketHistory API 추가
  • 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 (수정) — changePassword API 추가
  • 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 상태 확인 후 렌더링하고, 근본 원인은 DB code_group 테이블에 그룹이 안 들어간 것이었음 (컬럼명 불일치).
  • SUPER_ADMIN 없음: 미리보기 재생성 API 호출 시 admin 권한 사용자가 DB에 없어서, 임시로 public 열고 호출 후 즉시 제거하는 방식으로 처리.

결론 / 배운 점

  • 워터마크는 서버에서 합성해야 의미 있다. 클라이언트 CSS 오버레이는 개발자도구로 제거 가능. 출력 PDF에 서버가 합성하면 탈취해도 추적 가능.
  • 미리보기 자동 생성은 악보 등록 흐름에 통합해야 한다. 수동 배치 스크립트는 깜빡하면 미리보기 없는 악보가 운영에 올라간다.
  • 전역 유틸($fx)은 hook 지옥을 해소한다. 매 페이지마다 useAppConfirm import하는 건 비효율적. 외부 store 패턴으로 React Provider 밖에서도 호출 가능하게 만들면 코드가 깔끔해진다.
  • 정책 변경은 반드시 이력을 남긴다. 수강권 가격이 바뀌었을 때 "이 학생이 발급받을 당시 가격"을 추적할 수 없으면 분쟁 시 대응이 안 된다. 스냅샷 저장 필수.
  • 페이지 간 데이터 전달은 sessionStorage가 적절하다. URL 파라미터는 한글/길이 이슈, localStorage는 탭 간 공유돼서 충돌 가능. sessionStorage는 탭 단위로 격리되고 닫으면 소멸.

댓글 0

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