보장분석 리포트 API·DDL 델타
feat/pdf-output · 리포트 출력 계약

보장분석 리포트 API·DDL 델타

담당자에게 경로 안내를 받은 시점(ffeb506)과 현재 HEAD 사이에 리포트 출력 API와 DB 스키마 요구가 무엇이 달라졌는지 정리했습니다. 결론은 두 줄이고, 아래는 그 근거입니다.

기준 ffeb506 현재 dda2b14 (HEAD) 42커밋 · ffeb506은 HEAD의 조상(선형 확인)

결론

API는 더해지기만 했고, DDL은 하나도 안 바뀌었습니다

API 가산(additive)만

엔드포인트 3개 신규, get_proposal 응답 필드 2개 추가. 기존 엔드포인트 삭제·경로 변경·필수 파라미터 변경은 0건이라 기존 프론트 코드는 그대로 동작합니다.

DDL 델타 0

두 리비전의 스키마 요구가 완전히 동일합니다. 담당자가 전달한 DDL 요청서(컬럼 6개 + 신규 테이블 5개)는 HEAD에서도 그대로 유효합니다.

DDL 델타가 0이라는 근거는 한 줄입니다. 42커밋 동안 .sql 파일과 repository/ 아래 파일이 단 하나도 바뀌지 않았습니다.

$ git diff --name-only ffeb506..HEAD | grep -cE 'repository|\.sql$'
0

이 프로젝트는 SQL을 .sql 파일에 두고 이름으로 불러 쓰는 구조라(repository/query/*.sql), SQL 파일이 안 바뀌었다는 것은 읽고 쓰는 컬럼·테이블 목록이 안 바뀌었다는 뜻입니다. 새로 추가된 Excel 출력조차 새 쿼리를 만들지 않고 기존 조회를 재사용합니다.

용어

먼저 알아둘 다섯 단어

EP (엔드포인트)
프론트가 호출하는 URL 하나. 이 문서에서 "EP 3개 추가"는 URL 3개가 새로 생겼다는 뜻입니다.
manifest
get_proposal이 돌려주는 목록. "PDF 몇 번째 페이지가 어느 장표(chk 키)인지"의 대응표이고, 프론트의 페이지 선택 UI가 이걸로 라벨을 붙입니다.
chk 키
리포트 장표 1종의 식별자. chk03=표지, chk07=계약목록처럼 레거시 리포트의 장표 번호를 그대로 씁니다.
표시본
get_proposal이 디스크에 저장해 두는 proposal/render/{tfaId}/{insuredSeq}.pdf. 고객당 최신 1건이고, 이후 다운로드·인쇄·알림톡이 이 파일을 잘라 씁니다.
readonly 배포
디스크 쓰기를 막은 배포 모드. 이때는 표시본을 저장하지 않으므로 PDF 요청마다 즉석 재렌더로 동작합니다.

API · 신규

새로 생긴 엔드포인트 3개

두 리비전의 라우터를 각각 파싱해 전체 경로 목록을 대조한 결과, 차이는 정확히 이 3개입니다.

메서드경로하는 일
GET /ws-user/v2/report/customers/{insuredSeq}/proposal.pdf PDF를 바이너리로 내려주는 EP. 기존에는 JSON 안에 base64 문자열로 넣어 보냈습니다. 세 가지 전달 방식의 차이와 바이너리를 고른 이유는 바로 아래에 있습니다.
GET /ws-user/v2/report/customers/{insuredSeq}/proposal/output.xlsx 데이터 장표만 담은 Excel. 표지·차트처럼 그림인 장표는 시트로 만들 수 없어 에러 대신 응답 헤더 X-Skipped-Keys로 알려줍니다.
POST /ws-user/v2/auth/dev-login 로컬 전용 우회 로그인. include_in_schema=False라 스웨거에 안 뜨고, APLUS_ENV=prod면 404입니다. 프론트 정식 계약이 아닙니다.

"그 외에는 정말 안 바뀌었나"의 근거

API 레이어에서 손댄 파일은 _04_api/report.py, _04_api/auth.py, auth/login/dto/* 셋뿐입니다. main.py는 diff에 없습니다 — 라우터 마운트, 미들웨어, CORS, 예외 핸들러, OpenAPI 메타가 모두 그대로라는 뜻입니다. 나머지 60여 개 경로는 diff 0.

API · 전달 방식

완성된 PDF를 프론트에 넘기는 세 가지 방법

서버는 이미 PDF 바이트를 손에 들고 있습니다. 남은 결정은 그것을 어떤 형태로 HTTP 응답에 실을지뿐입니다. 세 가지 선택지가 있고, 각각 브라우저가 받는 물건이 다릅니다.

base64 JSON PDF 바이너리 채택 이미지 정적파일 미채택
응답 형태 application/json
PDF를 문자로 바꿔 필드에 담음
application/pdf
PDF 바이트 그 자체
image/png × N
페이지를 그림으로 구움
전송량 원본의 약 133%
+ JSON 문자열 이스케이프
원본의 100% 해상도에 비례.
A4 300dpi면 페이지당 수 MB
브라우저 처리 atobBlob. 문자열·디코드 버퍼·Blob이 동시에 살아 있어 피크 메모리 3배 디코딩 코드 없음. <iframe>·<embed>·내장 뷰어가 바로 렌더 <img>로 바로 렌더. 가장 단순
텍스트·인쇄 PDF 그대로 — 텍스트 선택·검색 가능, 인쇄 시 벡터 품질 동일 래스터화 시점에 텍스트·벡터가 사라짐. 선택·검색 불가, 인쇄 해상도 고정
요청 수 1회 1회 (manifest 포함 2회) 페이지당 1개 → 30페이지면 30회 + 정리 대상 파일 30개
산출물 재사용 같은 PDF 바이트를 슬라이스·첨부에 그대로 씀 동일 선택 페이지를 다시 PDF로 만들려면 이미지를 재조립해야 함

바이너리를 고른 이유

바이너리를 고른 대가 — 그래서 renderId가 생겼습니다

바이너리 응답에는 JSON을 같이 실을 수 없으니, "몇 번째 페이지가 어느 장표인지"(manifest)를 함께 보낼 수 없습니다. 그래서 EP가 2개로 갈라지고, 두 응답이 같은 렌더인지 확인할 장치가 필요해졌습니다. 그것이 다음 섹션의 renderIdX-Render-Id 대조입니다.

두 가지 주석

① 문서의 "−33%" 표기. 33%는 base64가 얹는 오버헤드 비율입니다. base64 응답을 기준으로 줄어드는 양으로 보면 약 25%입니다. 방향과 결론은 같지만 숫자를 인용할 때 구분하세요.

② 이미지 방식은 레포에 검토 기록이 없습니다. 위 비교는 커밋·문서에 남은 결정이 아니라 코드 구조(표시본 공유·슬라이스·알림톡 첨부)에서 도출한 것입니다. base64 → 바이너리 전환만 2026-08-23 프론트 합의로 기록되어 있습니다.

API · 응답

get_proposal 응답에 필드 2개가 붙었습니다

{
  "insuredSeq": ...,
+ "renderId":  "<sha256 앞 16자>",   // 이번 렌더 산출물의 지문
+ "totalPages": <pages 길이>,
  "pages": [{page, key, category, defaultChecked}],   // 불변 (defaultChecked는 ffeb506에도 있었음)
  "pdfBase64": "..."   // deprecated — 과도기 병행, FE 전환 확인 후 제거
}

pdfBase64DEPRECATED 표시가 붙었지만 아직 응답에 그대로 들어 있습니다. dev 자동배포 환경에서 프론트·백엔드가 동시에 전환하는 위험을 피하려고 일부러 병행 중이고, 프론트 전환 배포가 확인되면 제거됩니다. 그래서 기존 프론트는 안 깨집니다.

renderId는 왜 필요한가

manifest(JSON)와 PDF(바이너리)를 두 번의 호출로 나눠 받게 되면서, 그 사이에 다른 렌더가 끼어들 수 있게 됐습니다. 그래서 양쪽에 같은 산식(PDF 바이트의 sha256 앞 16자)으로 지문을 붙였습니다.

흐름

PDF 요청이 지나가는 층

담당자가 알려준 경로 그대로이고, 여기에 Excel용 형제 인터페이스 한 층이 늘었습니다.

라우터 _04_api/report.py — 파일 IO, readonly 가드, 응답 envelope, pages 검증
서비스 _02_biz/report/proposal/service/render.py — 오케스트레이션(DB 조회 → 빌더 → 인터페이스)
조회 / 가공 repository/ SELECT 전용 · domain/render/context/chkXX_*.py 키별 context 빌더 · domain/render/grade.py 등급 규칙
렌더 (PDF) _02_biz_interface/report/render/pdf/ — WeasyPrint 경계, Jinja2 템플릿 + styles.css + fonts
렌더 (xlsx) _02_biz_interface/report/render/xlsx/ NEW — PDF와 형제. _context_for()라는 공용 지점에서 같은 context를 소비하므로 데이터 출처가 하나입니다

담당자가 확인 요청한 _00_config 여부 — 맞습니다

"설정값을 상위로 뺐던 것 같다"는 기억이 정확합니다. _00_config/settings.pyweasyprint_concurrency, xlsx_concurrency, readonly, env, eai_dest를 들고 있고, _01_core/utils/concurrency.py가 그 값으로 세마포어를 만들어 렌더 서비스에 넘깁니다.

운영

계약은 아니지만 배포할 때 알아야 하는 변화

API 응답 모양과 무관하지만, 같은 EP의 동작 특성과 이미지 빌드에 영향이 있는 항목입니다.

항목ffeb506HEAD의미
APLUS_WEASYPRINT_CONCURRENCY 2 1 WeasyPrint/Pango는 C 레벨 전역 상태를 공유해 한 프로세스 안에서 동시 렌더하면 간헐적으로 죽습니다. 그래서 프로세스당 직렬로 고정했고, 처리량은 워커(프로세스) 수로만 늘립니다. 올리지 마세요.
렌더 CPU 단계 동기 실행 asyncio.to_thread 오프로드 렌더가 이벤트 루프를 붙잡고 있지 않아, 렌더 중에도 다른 요청이 응답됩니다.
요청 내 반복 조회 키마다 재조회 요청 스코프 캐시 5종 고객·설계사·표준모델·공통코드는 한 요청 안에서 안 바뀌는데 장표마다 다시 조회했습니다(N+1). 캐시 수명은 요청 1건이라 요청 간 오염이 없습니다.
런타임 의존 xlsxwriter>=3.2 이미지 재빌드가 필요합니다.
신규 환경변수 APLUS_EAI_DEST
APLUS_XLSX_CONCURRENCY
개발 EAI 라우팅 헤더가 설정으로 승격됐습니다. 빈 값이면 헤더를 붙이지 않습니다(운영 기본).

검증

페이지 번호는 그대로인가

다운로드·인쇄 EP는 ?pages=페이지 번호를 받습니다. 그런데 이 42커밋은 레거시 PDF 픽셀 일치 작업이라 styles.css를 1404줄 재작성하고 본문 폰트를 NanumGothic으로 바꿨습니다. 페이지 분할 지점이 움직였다면 페이지 번호의 의미가 달라집니다. 그래서 직접 측정했습니다.

# 두 리비전에서 빈 목 데이터로 render_detail() 실행 (ffeb506은 worktree로 체크아웃)
양쪽 동일: total_pages = 30
  chk03:1  chk04:2  chk07:1  chk38:1  chk39:1  chk08:2
  chk09_1:1  chk09_2:1  chk10:1  chk11:1  chk13~23:각 1
  chk05:3  chk05_2:4
page → key 매핑 30행 전부 일치

DETAIL_PAGE_ORDER(출력 순서 상수)의 본문도 바이트 단위로 동일합니다. 정적 페이지 구조는 불변입니다.

단, 실데이터에서는 보증하지 않습니다

위 측정은 빈 데이터 기준입니다. 행이 많은 장표(chk07, chk09_1/2, chk11, chk13~23)는 행 높이·폰트·줄바꿈이 바뀌었으니 실데이터에서 키당 페이지 수가 ±1 움직일 수 있습니다. 작업 문서 자체가 "chk09 행밀도가 레거시보다 촘촘 → 페이지수 −1"을 잔여로 기록해 두었습니다.

따라서 프론트가 페이지 선택을 저장·북마크해 두었다면 두 리비전 간 이식은 보증 불가입니다. 매번 proposal manifest를 새로 받아 쓰는 흐름이면 무해합니다.

diff를 보고 오해하기 쉬운 2가지

BEFORE_AFTER_PAGE_ORDER가 바뀐 게 보이지만 호출처가 0건입니다. 전후비교 출력 경로는 selection-keys 폐지 때 진입점이 사라져 dead 상태입니다. 실제 출력 순서는 DETAIL_PAGE_ORDER 하나뿐이라 페이지 순서는 변하지 않았습니다.

② 정본 계약 문서에는 신규 EP가 아직 없습니다. docs/API-V2/는 diff에 없고, proposal.pdf·output.xlsx 계약은 legacy docs/API/report.md와 스웨거 docstring에만 있습니다.

DDL

리포트 EP가 실제로 필요한 스키마는 3개뿐

DDL 요청서에는 물리 객체 11개(컬럼 6개 + 신규 테이블 5개)가 있습니다. 하지만 렌더가 타는 SQL 파일 6개를 전수 확인하면, PDF·Excel 출력을 검증하는 데 필요한 것은 3개입니다. 나머지는 고객목록·자동분석 도메인 전용입니다.

객체리포트 EP근거
tb_trd_user
.COAL_TFA_CD
필수 없으면 표지(1페이지)부터 1054 Unknown column으로 죽습니다. account_tfa.find_consultanttu.COAL_TFA_CD를 SELECT하고 (tfa.sql:132+), 이 쿼리는 chk03 표지와 chk04 TFA소개가 호출합니다.
tb_contract_sjw
.STATUS
필수 (STATUS IS NULL OR STATUS='정상') 필터가 report_proposal.sql:149,239, coverage.sql:607,664, result.sql 다수에 있습니다.
tb_contract_sjw_dambo
.STATUS
필수 report_proposal.sql:150, coverage.sql:607
tb_contract_sjw.SOURCE 불필요 고객목록 셀(P_SOURCE) 전용
tb_profile.LICENSE12
tb_profile.LICENSE_LIST_CD
불필요 프로필 모달(find_profile)과 로그인 seed 전용. find_consultant에는 없습니다.
신규 테이블 5개
tb_user_cust_relation
tb_contract_bojang
tb_auto_analysis_req/_pdf/_log
전부 불필요 렌더가 쓰는 SQL 6개 파일에서 참조 0건. 각각 customer/, analysis/history/ 도메인 전용입니다.

실용적 함의

PDF·Excel 출력만 dev에서 검증하려면 DDL 11개 전부가 아니라 tb_trd_userCOAL_TFA_CD 1개 + sjw/dambo STATUS 2개만 있으면 됩니다. 신규 테이블 5개와 데이터 백필 2건은 리포트 검증의 블로커가 아니므로 DBA 요청 순서를 쪼갤 수 있습니다.

DDL · 유일한 변화

dev-login이 만든 것은 새 요구가 아니라 새 경로

스키마 요구는 늘지 않았지만, 같은 스키마에 도달하는 길이 하나 새로 생겼습니다. POST /auth/dev-login은 EAI 비밀번호 인증만 건너뛰고 나머지 로그인 단계 (월드행 확보 → 로그인 SELECT → save_consultant upsert → save_tfa_profile seed → JWT)를 그대로 수행합니다.

그래서 이런 차이가 생깁니다

ffeb506에서는 EAI 비밀번호가 없어 만나지 못했던 COAL_TFA_CD1054 오류가, HEAD에서는 로컬 dev에서 바로 터집니다.

새 DDL이 필요해진 것이 아니라, 이미 미적용이던 DDL이 더 이른 단계에서 드러나는 것입니다. 참조 노트의 "지금 실검증을 막는 그 컬럼"이 여기서 노출됩니다.

양쪽 리비전 공통으로 남아 있는 주의사항

재현

직접 확인하는 명령

이 문서의 판정은 모두 아래로 재현됩니다.

# 1. DDL 델타 0 — SQL·repository 변경 파일 수
git diff --name-only ffeb506..HEAD | grep -cE 'repository|\.sql$'

# 2. API 레이어에서 바뀐 파일 (main.py 부재 확인)
git diff --stat ffeb506..HEAD -- app/_04_api/ app/main.py

# 3. 출력 순서 상수가 동일한지
for r in ffeb506 HEAD; do
  git show $r:app/_02_biz/report/proposal/service/render.py \
    | sed -n '/^DETAIL_PAGE_ORDER/,/^]/p'
done | uniq -c

# 4. 전후비교 경로가 dead인지 (정의만 있고 호출처 0)
grep -rn "page_order_for" app/ tests/

# 5. COAL_TFA_CD 가 표지 쿼리에 있는지
sed -n '132,160p' app/_02_biz/account/tfa/repository/query/tfa.sql