담당자에게 경로 안내를 받은 시점(ffeb506)과 현재 HEAD 사이에
리포트 출력 API와 DB 스키마 요구가 무엇이 달라졌는지 정리했습니다.
결론은 두 줄이고, 아래는 그 근거입니다.
결론
엔드포인트 3개 신규, get_proposal 응답 필드 2개 추가.
기존 엔드포인트 삭제·경로 변경·필수 파라미터 변경은 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 출력조차 새 쿼리를 만들지 않고 기존 조회를 재사용합니다.
용어
get_proposal이 돌려주는 목록. "PDF 몇 번째 페이지가 어느 장표(chk 키)인지"의 대응표이고,
프론트의 페이지 선택 UI가 이걸로 라벨을 붙입니다.chk03=표지, chk07=계약목록처럼
레거시 리포트의 장표 번호를 그대로 씁니다.get_proposal이 디스크에 저장해 두는 proposal/render/{tfaId}/{insuredSeq}.pdf.
고객당 최신 1건이고, 이후 다운로드·인쇄·알림톡이 이 파일을 잘라 씁니다.API · 신규
두 리비전의 라우터를 각각 파싱해 전체 경로 목록을 대조한 결과, 차이는 정확히 이 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 바이트를 손에 들고 있습니다. 남은 결정은 그것을 어떤 형태로 HTTP 응답에 실을지뿐입니다. 세 가지 선택지가 있고, 각각 브라우저가 받는 물건이 다릅니다.
| base64 JSON 구 | PDF 바이너리 채택 | 이미지 정적파일 미채택 | |
|---|---|---|---|
| 응답 형태 | application/jsonPDF를 문자로 바꿔 필드에 담음 |
application/pdfPDF 바이트 그 자체 |
image/png × N페이지를 그림으로 구움 |
| 전송량 | 원본의 약 133% + JSON 문자열 이스케이프 |
원본의 100% | 해상도에 비례. A4 300dpi면 페이지당 수 MB |
| 브라우저 처리 | atob → Blob.
문자열·디코드 버퍼·Blob이 동시에 살아 있어 피크 메모리 3배 |
디코딩 코드 없음.
<iframe>·<embed>·내장 뷰어가 바로 렌더 |
<img>로 바로 렌더. 가장 단순 |
| 텍스트·인쇄 | PDF 그대로 — 텍스트 선택·검색 가능, 인쇄 시 벡터 품질 | 동일 | 래스터화 시점에 텍스트·벡터가 사라짐. 선택·검색 불가, 인쇄 해상도 고정 |
| 요청 수 | 1회 | 1회 (manifest 포함 2회) | 페이지당 1개 → 30페이지면 30회 + 정리 대상 파일 30개 |
| 산출물 재사용 | 같은 PDF 바이트를 슬라이스·첨부에 그대로 씀 | 동일 | 선택 페이지를 다시 PDF로 만들려면 이미지를 재조립해야 함 |
proposal/render/에 저장해 두고,
다운로드·인쇄는 거기서 선택 페이지만 남긴 PDF를 만들고(slice_pages),
알림톡은 그 PDF를 talk/에 저장해 첨부합니다. 세 소비처가 같은 바이트를 공유합니다.
화면용만 이미지로 바꾸면 화면과 산출물이 서로 다른 파이프라인이 되고, 픽셀 일치 작업을 두 벌 관리해야 합니다.Content-Type: application/pdf +
Content-Disposition: inline이면 내장 뷰어가 바로 열고, 서버도 표시본 파일을 그대로 흘려보내
디코딩·인코딩 단계가 없습니다.바이너리를 고른 대가 — 그래서 renderId가 생겼습니다
바이너리 응답에는 JSON을 같이 실을 수 없으니, "몇 번째 페이지가 어느 장표인지"(manifest)를 함께 보낼 수 없습니다.
그래서 EP가 2개로 갈라지고, 두 응답이 같은 렌더인지 확인할 장치가 필요해졌습니다.
그것이 다음 섹션의 renderId ↔ X-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 전환 확인 후 제거
}
pdfBase64는 DEPRECATED 표시가 붙었지만
아직 응답에 그대로 들어 있습니다. dev 자동배포 환경에서 프론트·백엔드가 동시에 전환하는 위험을
피하려고 일부러 병행 중이고, 프론트 전환 배포가 확인되면 제거됩니다. 그래서 기존 프론트는 안 깨집니다.
renderId는 왜 필요한가manifest(JSON)와 PDF(바이너리)를 두 번의 호출로 나눠 받게 되면서, 그 사이에 다른 렌더가 끼어들 수 있게 됐습니다. 그래서 양쪽에 같은 산식(PDF 바이트의 sha256 앞 16자)으로 지문을 붙였습니다.
proposal(manifest + 표시본 생성) → proposal.pdfrenderId == proposal.pdf 응답 헤더 X-Render-Id흐름
담당자가 알려준 경로 그대로이고, 여기에 Excel용 형제 인터페이스 한 층이 늘었습니다.
_context_for()라는 공용 지점에서 같은 context를 소비하므로 데이터 출처가 하나입니다
담당자가 확인 요청한 _00_config 여부 — 맞습니다
"설정값을 상위로 뺐던 것 같다"는 기억이 정확합니다.
_00_config/settings.py가 weasyprint_concurrency,
xlsx_concurrency, readonly, env, eai_dest를 들고 있고,
_01_core/utils/concurrency.py가 그 값으로 세마포어를 만들어
렌더 서비스에 넘깁니다.
운영
API 응답 모양과 무관하지만, 같은 EP의 동작 특성과 이미지 빌드에 영향이 있는 항목입니다.
| 항목 | ffeb506 | HEAD | 의미 |
|---|---|---|---|
| APLUS_WEASYPRINT_ |
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
DDL 요청서에는 물리 객체 11개(컬럼 6개 + 신규 테이블 5개)가 있습니다. 하지만 렌더가 타는 SQL 파일 6개를 전수 확인하면, PDF·Excel 출력을 검증하는 데 필요한 것은 3개입니다. 나머지는 고객목록·자동분석 도메인 전용입니다.
| 객체 | 리포트 EP | 근거 |
|---|---|---|
| tb_trd_user .COAL_TFA_CD |
필수 | 없으면 표지(1페이지)부터 1054 Unknown column으로 죽습니다.
account_tfa.find_consultant가 tu.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_user에 COAL_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_CD의
1054 오류가, HEAD에서는 로컬 dev에서 바로 터집니다.
새 DDL이 필요해진 것이 아니라, 이미 미적용이던 DDL이 더 이른 단계에서 드러나는 것입니다. 참조 노트의 "지금 실검증을 막는 그 컬럼"이 여기서 노출됩니다.
tb_contract_sjw.STATUS — 자바 화면에 새 행이 보일 수 있습니다.
파이썬은 자바가 아예 INSERT하지 않던 실효 계약 행까지 적재하고 '실효'로 태깅한 뒤
조회에서 다시 걸러냅니다. 이 필터가 없는 자바 운영앱에는 예전에 없던 계약이 보일 수 있어
DBA·기획 확인이 필요합니다. (두 리비전 모두 동일 — 이번에 생긴 문제가 아닙니다.)SOURCE='MANUAL' 백필과 STATUS NULL → '정상' 백필은 라이브 공유 테이블의
기존 데이터를 씁니다.재현
이 문서의 판정은 모두 아래로 재현됩니다.
# 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