Skip to content

feat(pdf): PDF 내보내기 기능 추가 - #205

Merged
coldsunn merged 27 commits into
developfrom
feature/pdf-export
Jul 27, 2026
Merged

feat(pdf): PDF 내보내기 기능 추가#205
coldsunn merged 27 commits into
developfrom
feature/pdf-export

Conversation

@coldsunn

@coldsunn coldsunn commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

📝 요약(Summary)

사용자 기록(답변 / 주간·월간 리포트)을 기간으로 골라 PDF로 내보내는 유료 기능입니다.
크리스탈 차감 → 서버 렌더링 → S3 업로드 → 7일간 다운로드.

"유료 + 비동기" 조합이라 돈과 산출물이 어긋나지 않게 하는 데 설계 대부분이 들어갔습니다.

전체 흐름

POST /pdf-exports → [TX-A] job 생성 + tryConsume 차감 + CrystalLog(PENDING) +
IN_PROGRESS
↓ 커밋 후 즉시 200 (요청 스레드는 렌더를 안 기다림)
AFTER_COMMIT + @async(pdfExportTaskExecutor)
↓ 짧은 조회로 데이터 받고 커넥션 반납 → Tx 밖에서 렌더
성공 → [TX-B] confirm 실패 → [TX-C] failAndRefund

주요 결정

트랜잭션을 3개로 쪼갬

  • 리스너 핸들러엔 @transactional을 안 붙였습니다.
  • 하나로 묶으면 렌더(최악 수십 초) 내내 커넥션을 잡아 커넥션 풀이 마릅니다.
  • 조회는 짧은 Tx로 받고 반납한 뒤 렌더합니다.

상태 전이에 CAS

  • markCompleted/markFailed가 AND status='IN_PROGRESS' 조건부 UPDATE고, 바뀐 행 수를 반환합니다.
  • 복구 스윕이 두 번째 writer로 들어오면서 완료↔환불 경합이 생깁니다. 없으면 공짜 PDF나 이중 환불이 납니다.
  • NotificationTransactionHelper가 RecoveryScheduler 대비 쓰던 패턴과 같습니다.

동시 1개 제한 = 부분 유니크 인덱스

  • (user_id) WHERE status IN ('PENDING','IN_PROGRESS')
  • save(INSERT) → tryConsume 순서라 차감 전에 제약이 터져서 이중 과금이 원천 차단됩니다.
  • 앱 레벨 조회는 순차 재요청용, 진짜 동시 요청은 이 인덱스가 잡습니다.

렌더 = 전용 스레드풀 + 동시성 1

  • AsyncConfig에 pdfExportTaskExecutor 추가 (core1 / max1 / queue40)
  • 단일 렌더가 힙을 꽤 써서 2개 동시는 위험하다고 판단했습니다.
  • 큐가 넘치면 AbortPolicy가 이미 차감된 작업을 버립니다.
  • 그래서 그 전에 PdfExportRenderQueue(상한 20)가 차감 전에 429로 거부합니다.

멈춘 작업은 60분 뒤 스윕이 환불

  • 재렌더는 하지 않습니다 — 멈춘 원인이 대개 "렌더가 느리거나 실패해서"라 재시도할수록 악화됩니다. 예전 알림 재시도 사례가 있습니다...

결과 PDF·PDFBox 문서모델을 힙 밖 임시파일로

  • 힙에 다 올리면 1년치에서 위험합니다.
  • 임시파일은 정상·예외 경로 모두 정리하고, 강제 종료가 남긴 고아는 부팅 시 청소합니다.
  • /tmp가 tmpfs라 남으면 메모리를 먹습니다.

공유 파일 변경

  • AsyncConfig — pdfExportTaskExecutor 빈 추가
  • CrystalLogReason — PDF_EXPORT_GENERATE 추가
  • ErrorCode — PDF_EXPORT_* 11개 추가
  • ExceptionController — PdfExportInProgressException(409, data에 jobId) 핸들러 추가
  • NotificationType — PDF_EXPORT_COMPLETED 추가 (그룹 = REPORT 재사용)
  • ReportNotificationEventListener — PDF 완료 알림 메서드 1개 추가

다른 도메인과 얽힌 부분

  • 크로스도메인 조회는 읽기 전용 마커 레포로 분리했습니다.
    • PdfExportQueryRepository가 Repository 마커라 CRUD가 안 열립니다.
    • pdfexport 도메인에 뒀고, WeeklyQueryRepository·TypeDailyEntryQueryRepository와 같은 관용구입니다.
  • S3는 기존 버킷을 env prefix로만 구분합니다
    • 로컬에서 돌려도 실제 S3에 local/... 객체가 생깁니다.

그 외

  • 테스트 57건 — 정합성 불변식(실 DB·TestContainers) / 트랜잭션 경계(비트랜잭션) / 렌더 파이프라인 / 거부 경로
  • DB — pdf_export_jobs 1개 (마이그레이션 V20260727_1330)
  • 의존성 3개 — openhtmltopdf-pdfbox, imageio-webp, awssdk:cloudfront
  • API 6개 — 상세는 Swagger랑 노션 API 업데이트에 정리해뒀습니다.

🔗 Related Issue

  • Closes:

💬 공유사항

  1. GitHub Secrets 3개 갱신 완료
  • CLOUDFRONT_KEY_PAIR_ID · CLOUDFRONT_PRIVATE_KEY — 신규 등록
  • NOTIFICATION_MESSAGES_B64 — PDF_EXPORT_COMPLETED 템플릿 포함본으로 재인코딩
  • 템플릿이 하나라도 빠지면 validateAllTemplates에서 부팅이 막힙니다. 공유한 내용 확인하고 로컬 env 갱신해주세요!
  1. 렌더 동시성 1이 병목이 될 수 있습니다.
  • 유저가 늘면 대기가 길어지는 구조입니다 (상한 20건)
  • 그때는 인스턴스를 키우거나 캡을 올리는 판단이 필요합니다.
  • 지금 규모에선 문제없다고 봤습니다.

✅ PR Checklist

PR이 다음 요구 사항을 충족하는지 확인하세요.

  • PR 제목을 커밋 메시지 컨벤션에 맞게 작성했습니다.

coldsunn added 27 commits July 27, 2026 23:00
openhtmltopdf-pdfbox 1.1.40(순수 JVM 서버 렌더링), twelvemonkeys imageio-webp 3.13.1(webp 디코드), AWS
CloudFront SDK(signed URL) 의존성 추가, 렌더 전용 스레드풀 pdfExportTaskExecutor, commitlint scope에 pdf 추가
Flyway 마이그레이션 추가, PdfExportJob/Status/Type 엔티티·enum 추가, 리포지토리 메서드로 markCompleted·markFailed CAS
전이(IN_PROGRESS일 때만)·dedup 쿼리 추가, 크리스탈 로그 사유 PDF_EXPORT_GENERATE 추가
기간 조회 레포: 답변 BETWEEN+감정 조인, 리포트 overlap+COMPLETED, 월간 V1·V2, 카운트, 보상 트랜잭션, 다운로드 인프라에 UUID
키·CloudFront signed URL·업로드 시 파일명 각인·best-effort 삭제, 발급 rate-limit, api 5가지 구현, ErrorCode 8종·409/429
예외·핸들러, CloudFront config 및 ci 추가
openhtmltopdf XHTML 조립, Pretendard 임베드·감정 레이더(Java2D)·하이라이트/divider 오버샘플 PNG·사진 cover 정사각 리샘플, 비동기
리스너로 조회→렌더→S3 업로드→confirm, 실패 시 자동 환불, 개별 사진 실패는 스킵, 완료 이벤트 발행
NotificationType.PDF_EXPORT_COMPLETED 추가, REPORT 그룹에 속하도록 하였고 딥링크 data는 jobId
백엔드 displayNameKo는 인간관계인데 실제 화면 상에서는 관계라 PDF에서만 변경
리스너 오케스트레이션 테스트(조회→렌더→업로드→confirm/환불 경로), 디자인 프리뷰 하네스(합성 사진)
반복 baked 에셋(배너·divider·그림자·아이콘·로고·섹션)을 asset: 프로토콜로 1회 서빙하도록 변경, 답변 사진도 data URI → asset:photo-N 으로
전환(base64 인라인·재디코드 없이 raw 바이트 서빙), assemble 이 XHTML+사진 바이트맵(AssembledDocument) 반환, render 가 맵을
asset: 스트림으로 서빙, 렌더 동시성 max1(단일 렌더가 공유 힙을 거의 다 써 동시 2개면 OOM)
순수 JVM 하네스로 assemble→render 실경로로 peak heap·기간·사진수·공존 스윕 측정, pdfBench 태스크(-Xmx2g 기본,
-PbenchXmx/-PbenchCpu·-Dpdf.bench.*파라미터), 일반 test 는 bench 제외, 합성/실 webp 사진 소스, 시각 확인용 PDF
덤프(-Dpdf.bench.dumpPdf) 옵션
유니크 인덱스 (user_id,type,start,end) → (user_id), 진행 중 다른 조건 재요청 시 409(PDF_EXPORT_ALREADY_IN_PROGRESS,
진행 중 jobId 반환), 같은 조건은 기존대로 무과금 재사용, GET /pdf-exports/current 추가, 아카이브(GET /pdf-exports)는 COMPLETED만
노출(생성중은 /current로 분리)
fontbox 3.0.3가 라틴 런마다 GSUB 정규식을 재컴파일해서 GSUB만 삭제(GPOS·cmap·hmtx·family명 유지)
-PbenchJfr(할당 프로파일)·-PbenchGcLog(churn/live 분리) 플래그, renderMode 변형(BAOS/파일 × 메모리/scratch)으로
A·scratch peak 델타 측정, histoAtMB 고수위 live class histogram, fontDir 폰트 A/B(GSUB 유무)·assetRequests 요청횟수
카운터
byte[] render() → Path render()로 변환, 렌더 실패 시 자기 임시파일 삭제 후 rethrow, RequestBody.fromFile로 스트리밍 업로드,
업로드 후 finally에서 로컬 임시파일 정리
webp를 풀디코드 후 축소하던 것을 ImageReader 서브샘플링으로 디코드 단계서 640²로 바로 받음
다운로드를 전용 스레드 3개가 창 4장까지 앞서 받아두고 디코드는 호출 스레드가 순서대로 한 장씩 처리해 S3 대기를 디코드 뒤로 숨기도록 함
사진 1장 단계분해 프로브, 프리페치 A/B 스위치, assemble/render 분리 출력, GC 종류 고정과 GC 로그 PID 분리
렌더 대기가 20건 이상이면 start()에서 429로 거부, 실행기 큐는 40으로 늘려 상한이 항상 먼저 걸리도록 함
60분 넘게 IN_PROGRESS인 작업을 5분마다 쓸어 실패 확정 + 환불, 서버 부하 가능성 때문에 렌더는 다시 돌리지X,  markFailed가 IN_PROGRESS일 때만
이기므로 두 번 쓸어도 이중 환불이 없음
정상·예외 경로는 렌더러가 스스로 정리하지만 SIGKILL·커널 OOM-kill 등은 못 잡음, 계속 쌓이면 메모리를 계속 차지하므로 부팅 시 한 번 쓸어주는 로직 추가
작업 상태와 크리스탈 로그 상태가 항상 짝이 맞는지, 잔액이 원장과 일치하는지 등  TestContainers Postgres 에서 확인
개별 사진 실패는 그 장만 스킵하는데 상한이 없어서 한 장도 성공하지 못한 경우는 job 을 실패시켜 기존 환불 경로를 태우는 로직 추가, 일부만 실패한 경우는
스킵으로 남김
유 검증 403(조회·발급), 진행 중 재요청 재사용/409, 발급 3단 가드(완료 전·만료·상한), 아카이브 COMPLETED
만, 파일명, 발급 빈도 제한
예약·완료·환불 각 트랜잭션이 실제로 커밋·롤백되는지 비트랜잭션 테스트로 확인
테스트 11건 제거(67→57), 알림 발행 실패가 완료를 실패로 기록하던 문제 수정, 실패·환불 로그 5종 추가와 장애 시 로그 폭주 억제, 어긋난 주석 정리
나답_나에게답하다_(시작일)-(종료일).pdf → 나답_(시작일)-(종료일).pdf
측정 완료로 역할이 끝나 PdfExportRenderBenchmark, PdfSyntheticData 와 pdfBench 태스크를 제거
@1Seob

1Seob commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

수고하셨어요 머지 고고!!!

@coldsunn
coldsunn merged commit 60b498a into develop Jul 27, 2026
1 check passed
@coldsunn
coldsunn deleted the feature/pdf-export branch July 27, 2026 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants