D11 — 불완전한 통계 발행에서 공통 계산·원자적 게시로 (2026-09-10)
- 기간: 2026-09-10, 1세션. 오너 요청 “바벨릭 #1422 진행해줄래”, 승인 “응 고”. 이후 변경 없는 행 보존의 새 수용 기준까지 이번 작업에 포함하도록 승인했다.
- 랜딩: 앱 PR #1547,
d058cbb7가release/v0.18.0에 병합됐다. staging/Production·Vercel 배포는 실행하지 않았다. - 설계서: #1422 계획·Phase 보고, 예상 효과는 §5에 실제 측정 범위로 기록한다.
- 정본: 전환 runbook, DAG §0, 세대 계약 §6–7.
- 도구: 앱의 D01/D06/D07/D11 DB 검사,
db:upgrade,stats-isolated-convergence.mjs; 일회성 정렬 대조 도구는 로컬work/에만 보존한다. - 게이트: Phase별
npm run check, 격리 DB pgTAP·동치·concurrency를 실행했다. precheck 5회는 모두 실패했으며 마지막 660세션 게시 6,119ms 실패를 유지한다. 오너의 “프리첵하지말고 구현 마무리해서 릴리스 병합” 지시로 추가 precheck를 생략하고 구현 개별 검사·Merge Check·실제 병합 결과를 별도로 기록한다. 개별 검사와 release 병합 상태를 아래에 구분한다. 브라우저/e2e·승격 full CI 미실행. - 후속 검사 정합: 앱 PR #1548,
00a93ad3release/v0.18.0 반영. BUG-108에 사전 검증 누락과 수리 결과를 기록했다. - 버그리포트: BUG-106 · BUG-107.
- 계약: DAG §0, 정책 §6–7, D11 전환 runbook, G05 coverage의 D11 구현 증거.
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 불완전·stale 게시 재현 | 완료 e796da38 |
| Phase 2 | 공통 단계 계산·완료 manifest·원자 게시 | 완료 6178a087 |
| Phase 3 | full 우선·구 작업 이관·shadow·upgrade | 완료 518599e7 |
| Phase 4 | 통합 회귀·660세션 예산·변경 행 게시·계약/장부 | 완료 f269aef4, Merge Check 후 d058cbb7 release 반영 |
1. 배경
D05 관측·세션, D06 기간·달력, D07 순차 계산과 D08 worker, D09 인입, D10 복구 탐색, A06 조회, G04 측정이 목적 release에 들어왔다. D11은 이 모듈들이 같은 원본판과 세대를 완성한 뒤 게시하는 실행 경계를 맡는다.
2. 문제 제기
PR 스냅샷만 있으면 빠진 출력도 완료로 처리될 수 있었다
기존 완료 게이트는 snapshot 3일창을 확인했다. 18개 출력 중 일부 누락, 단계 미완료 또는 계산 도중 원본판 변경을 전체 게시 조건으로 검사하지 않았다. Phase 1에서 실제 DB 재현 3건이 실패했다.
같은 원본을 재계산해도 요약의 표시 순서가 달랐다
shadow에서 최근 PR의 마지막 정렬 키가 재생성 이벤트 UUID이고, 개요의 최근 활동이 통계 계산 시각인 것을 확인했다. 부분 계산과 full의 실행 순서가 화면 순서에 새어 나왔다.
3. 해결 방안
원칙
D1(2026-09-10): 승인된 #1422 범위에서 full과 부분 계산을 같은 작성자로 실행하고, 원본을 이중 쓰거나 shadow를 사용자에게 노출하지 않는다. Production cohort 전환은 R05/R06이다.
접근
| 안 | 판단 |
|---|---|
| 화면에 추가 stale 조건·재시도 타이머 | 기각 — 불완전한 서버 게시 자체가 남는다 |
| 표별 계산기를 새로 복제 | 기각 — 정책·수치의 두 구현을 만들게 된다 |
| 기존 D05/D06/D07을 한 단계 체인으로 조립하고 전체 완료를 게시 전 검사 | 채택 — 공통 계산·full 재계산·호환 문 모두 같은 결과 계약을 사용한다 |
| 모든 통계 행의 세대·계산 시각을 새 번호로 덮어쓰기 | 기각 — 값이 같아도 public 전체 쓰기가 반복된다 |
| 동일 값·정책·실제 source의 행은 이전 provenance를 유지하고 PR 요약은 header별 참조로 재사용 | 추가 승인에 따라 채택 — 게시 세대의 완성과 개별 행의 생성 출처를 구분한다. full 계산·임시 전체 복사는 유지한다 |
4. 적용한 내용
Phase 1 — 실제 게시 실패 재현
누락 출력, 잘못된 세대와 변경된 원본판을 실제 claim/compute/publish로 검사했다. 이전 구현에서 실패한 단언을 유지하며 수리했다.
Phase 2 — 공통 계산과 게시
run_stats_projection_v1의 D05 → D07 → D06 → snapshot manifest를 validate_stats_projection_payload_v1이 검사한다. 요청/catalog/policy token을 계산 전후와 게시 잠금 뒤 비교한다. 이는 낙관적 버전 검사이며 여러 SQL의 동일 MVCC snapshot을 주장하지 않는다. publisher는 18개 출력의 사라진 키를 삭제하고 변경 행만 upsert하며 completed/applied와 함께 확정한다. D05 원본 순수 캐시는 기존 public 작성자를 유지한다.
Phase 3 — 이관과 full 우선
기본 설정은 full이고 owner별 첫 D11 완료도 full이다. incremental은 같은 엔진에서 범위만 줄인다. 구 payload·실행 기록 없는 processing/failed를 bounded retry로 이관하며 scope·target·attempts·기존 오류와 pending을 보존한다. 신규 high migration은 4년 populated 데이터에서 중단·rollback·재실행과 원본 보존을 검사했다.
Phase 4 — 회귀와 인계
동기 실패가 공통 worker backoff를 유지하는 실제 DB 회귀를 추가했다. 660세션 probe는 계산 중 새 저장이 들어와 source가 바뀔 때의 명시적 40001 폐기를 인정하되 저장·계산·게시 시간 예산과 후속 dirty 수렴 단언은 유지한다. D01 기준값에서 변경된 것은 요약 순위이며 아래 대조로 숫자 변경과 구분했다.
첫 Precheck에서 660세션 첫 게시가 6초를 초과했다. 단계별 계측으로 큰 TOAST 압축 payload를 필드 접근마다 다시 해제하는 비용을 확인했다. validator와 publisher가 payload를 한 번 펼쳐 재사용하도록 바꾸고, 전체 단계·표·세대·source 검사를 그대로 두었다. 같은 fixture의 첫 게시가 6초 초과 → 3,252ms로 줄었다. 최종 fresh DB의 결과는 §5에 따로 기록한다.
Phase 4 추가 승인 — 변경 없는 통계·PR 요약 행 보존
작업 중 추가된 수용 기준을 오너가 이번 #1422에 포함하도록 승인했다. 15개 통계표에서 계산값·정책·실제 source 및 시각이 같으면 기존 행의 applied_version·materialized_at 등 생성 출처를 보존한다. 의미가 바뀐 행은 새 provenance로 게시하고 owner의 applied는 완성된 결과 세대로 전진한다. checkpoint·무결성은 검증된 과거 provenance를 읽을 수 있어야 하며, 새 후보의 세대를 임의로 낮추는 것은 거부해야 한다. 달력 source_updated_at의 실제 변화는 비교에서 빼지 않는다.
PR 요약은 user_pr_overview_snapshot_headers.row_generations JSONB의 운동별 summary generation 참조로 재사용한다. 최신·직전 publication은 각각 자기 header의 membership을 읽고, 기존 {} header는 exact-generation 방식으로 읽는다. 현재·직전 publication이 참조하는 더 오래된 summary와 필요한 header를 보존하며 참조가 사라진 행만 정리한다. full 계산과 pg_temp 전체 복사는 유지한다. 이 추가 구현의 개별 검사는 통과했으며 release 병합 상태는 아래 기록과 구분한다.
statsDeltaPublication.test.mjs에 동일 원본 새 세대의 16개 재사용표(15개 통계표+summary)의 I/U/D 0·전체 행/ctid 보존·과거 checkpoint reader 검증을 작성했다. 같은 canonical set ID의 무게 편집, 동일 수치의 새 세트로 교체되어 실제 source 시각이 바뀌는 경우, 정상 저장의 세트 제거와 정상 삭제 명령을 검증한다. 무관 종목·날짜의 행 보존과 900→950→550→삭제된 날짜 행 없음이라는 fixture의 실제 값 단언을 함께 둔다. 추가 검사와 D01/D06/D07/D10/D11 회귀·SQL 계약은 첫 실행 66개 중 65개 통과, 손상 checkpoint 재사용 1건 수정 후 관련 5개 모두 통과했다. 최신·직전 PR 개요/홈 조회와 membership 누락 거부도 실제 DB에서 확인했다(첫 실행, 수정 후 실행).
주요 결정과 그 근거
정렬은 canonical 세션 수정 시각과 PR 원본 논리 키를 사용한다. 비교에서 순위·배열 순서·업무 날짜·원본 생성 시각·null/0를 제외하지 않았다. 같은 260세션 입력에 이전 요약 함수만 임시 설치한 뒤 rollback하자 기존 baseline의 모든 표 digest가 복원됐다. 두 snapshot의 실제 차이는 summary_rank뿐이었다. 그래서 1년 baseline의 해당 표 digest만 갱신했다. 검사 비교 범위나 수치 정책은 바꾸지 않았다.
작업 중 드러난 것
populated 시험 뒤의 공유 sandbox 실행에서 busy/anchor 간섭을 관측했다. 새 DB의 단독 D01 실행에서는 손 계산·재계산·불변식·anchor가 통과했다. 이전 간섭의 원인을 Production 결함으로 단정하지 않는다. 스키마 dump 완료 전에 테스트를 시작해 참조 데이터 검사가 실패한 실행은 폐기하고, 프로세스 종료까지 기다린 새 빈 DB 재생으로 다시 검증했다.
사전 검증 누락: D04의 옛 “public 통계 행 잠금=계산 중” barrier가 새 구조에서는 500ms 제한의 게시 단계에 걸린 것을 첫 Precheck에서 확인했다. D05 계산 쓰기에 barrier를 두고, 동시 저장 성공·정확한 40001 거부·이전 applied 보존·후속 dirty 흡수/수렴을 단언하도록 교체해 4개 모두 통과했다. 같은 Precheck의 D11 실패 시각에는 중단 대상 cron의 실제 실행 기록이 있었다. 이후 cron inactive를 기록한 동일 순서 27개 검사는 모두 통과했다. 최초 cron 간섭의 원인은 확정하지 않았으며 최종 Precheck 결과와 구분한다.
두 번째 Precheck에서 새 DB의 660세션 gate와 D04/D06/D07/D11이 통과했지만 D10 날짜 전환의 대상 세대 단언이 실패했다. 이 검사는 전역 claim의 owner를 확인하지 않았다. 앞선 사용자의 대기 작업을 넣자 다른 owner를 처리하며 같은 applied=0 실패가 재현됐다. fixture 자신의 만기·큐 순서를 명시하고 claimed owner와 경쟁 작업의 pending 보존을 검증했다. 다음 통합 실행에서는 claim 이후에도 다른 잔여 run을 선택할 수 있음을 확인해 compute·publish의 순서와 job ID 단언까지 명시했다. 수정 후 D10 7/7이 통과했다. 제품의 스캐너·worker 코드는 바꾸지 않았다.
세 번째 Precheck에서도 D11 fixture 정리와 예약 worker의 deadlock 및 busy가 발생했다. active=false 기록만으로 실행 격리를 증명할 수 없으므로, D11 시험 연결이 실제 claim·compute와 해당 owner의 publish 잠금을 보유하게 했다. 같은 연결의 실제 worker 호출은 잠금을 재진입하며, 다른 예약 backend만 진입하지 못한다. 예약 claim·compute·publish를 의도적으로 켜 둔 추가 재현에서 D11 14/14, 12개 원본 상태·11개 증분 후보 차이 0을 확인했다(실행 원문). 복합세트 pgTAP의 동기 계산도 같은 계산 잠금을 시험 트랜잭션에서 잡아 예약 계산과 겹치지 않게 했다. 예약기의 내부 원인은 확정하지 않았고, 이들 실패를 통과로 합산하지 않았다.
네 번째 Precheck의 efaec0aa에서는 D11의 전역 claim이 시험 도중 추가된 다른 owner의 대기 작업을 선택했다. 잘못 선택한 작업을 처리하지 않고 남긴 후속 검사들은 활성 임대 한도에 걸렸다. 모든 D11 사례에 경쟁 owner를 명시적으로 넣고 fixture의 큐 순서와 선택 owner를 확인하도록 수정했다. 소모 한도·backoff의 idle 단언도 “내 작업의 시도 횟수는 늘지 않으며 다른 owner의 작업은 진행한다”는 실제 계약으로 바꿨다. 경쟁 작업을 넣은 13건이 통과한 뒤 precheck와 같은 27건 묶음으로 재현했다. 실패 원인을 충분히 격리하기 전에 전체 precheck를 반복해 시간을 소모한 점은 사전 검증 진행 오류로 기록한다.
다섯 번째 Precheck도 실패했으며 마지막 660세션 게시 시간은 6,119ms로 6초 경계를 넘었다. 앞선 단독 측정의 3,252ms나 다른 단계 통과를 이 실행의 성공으로 대체하지 않는다. 오너가 반복 실행을 중단하고 “프리첵하지말고 구현 마무리해서 릴리스 병합”을 지시했으므로 추가 precheck를 실행하지 않는다. 승인된 추가 구현의 필요한 개별 검증, 자기 PR의 Merge Check와 실제 release 병합은 구분해 진행한다. 이 지시는 precheck/full CI 성공 증거를 만들거나 Production 승격을 승인한 것이 아니다.
5. 적용 결과
| 항목 | 결과 |
|---|---|
| 불완전·stale 게시 재현 | 이전 3건 실패 → 수리 후 거부·공개 출력 보존 |
| full/부분 동치 | 12개 원본 상태·11개 실제 incremental 후보, 수치/논리 키/PR 근거/4종 공개 DTO 차이 0, 외부 연결에 shadow 비노출 |
| Phase 3 정적·단위 | 3,572 통과 / 실패 0 / 조건부 skip 83. skip은 DB 통과에 합산하지 않음 |
| D06/D07 및 이관 DB | 24개 통과; D11 별도 13개 통과(Phase 3 기준) |
| populated upgrade | 1,043세션·12,516세트·원본 33,127행, 278ms, 잠금 대기 0, 구 앱 오류 0/20, 원본 보존·4/9문장 중단 재실행 통과 |
| precheck·660세션 | precheck 5회 실패. 마지막 660세션 게시 6,119ms로 6초 경계 초과. 오너 지시로 추가 precheck 생략; 통과로 보고하지 않음 |
| 변경 행 게시·summary 재사용 추가 기준 | 첫 실행 66개 중 65개 통과, 손상 checkpoint 재사용 1건 수정 후 관련 5개 모두 통과. 안정된 동일 원본 publication에서 재사용 16표 I/U/D 0, 전체 JSON·ctid 보존; 실제 수정·삭제 때 무관 15표 행 보존 |
| Merge Check·release 병합 | 앱 #1547, d058cbb7 release/v0.18.0 반영 완료 |
| 배포·운영 효과 | staging/Production 미실행·미측정 |
upgrade는 Phase 2 6178a087에서 --to worktree로 측정한 Phase 3 후보다. 증거의 to SHA는 당시 HEAD이며, 최종 Phase 3 커밋은 518599e7이다. 측정 원문, 기계 증거, shadow·이관 실행을 보존한다.
6. 이번 개선으로 향상된 것
화면이 믿는 완료 조건이 전체 계산과 일치한다
모든 단계·출력·원본판을 통과한 generation만 fresh가 된다. 실패 시 이전 확정 결과와 dirty가 남는다. 저장 v5·조회 A06·A10/A11의 계약은 유지한다.
전체 재계산이 운영 전환의 기준이 된다
새 owner와 새 이관은 같은 엔진의 full을 먼저 사용한다. 부분 계산은 같은 원본으로 검증한 뒤 설정으로 전환하며 되돌릴 때도 이전 writer를 부활시키지 않는다.
남은 것
R05/R06의 환경별 cohort 검증·승격·Production 적용은 이번 release 통합과 별개다. D10에서 관측한 3,650세션 full의 60초 초과는 원인 미확정이며 D11 통과 수치로 바꾸지 않는다. G05 전체 캠페인의 완료를 이 문서로 선언하지 않는다.
추가 precheck를 생략한 뒤 최종 마이그레이션을 새 sandbox에 재생했고 SQL 원천·schema·registry·DB 장부와 멱등성 검사를 통과했다. 별도 660세션·7,920세트 게시 예산 검사는 최초 4327ms, 수렴 4507ms로 기존 6초 안에서 완료됐다. 동시 create/update/delete, 실제 취소·backoff·임대 교체·full 재계산 동치도 통과했다(기계 증거). 이는 precheck/full CI 통과 증거가 아니다.
Merge Check를 통과해 검증한 merge commit d058cbb7af1e0ae94bed1ade02408ec3301ec1f2가 release에 반영됐다. 추가 precheck는 오너 지시로 생략했고 개별 동작 검사와 구분했다. 문서의 버그 보고는 병합 직후 같은 작업에서 작성했다.
7. 후속 조회 계약 검사 정합 보완
U07·D14가 release d058cbb7에서 관측한 기존 6개 실패를 D11 담당이 이어받았다. 마지막 delta 구현 뒤 기존 계약 검사를 놓친 사전 검증 누락이다. 중간 Phase의 통과 수치는 당시 상태의 결과이며 최종 변경 전체를 보증하지 않는다.
serializer/Home에서 새 reader로 전달하는 인자와 실제 reader의 참조 세대·빈 맵 fallback을 검증하도록 기존 검사를 보완했다. payload·배열·행 개수·바이트 상한을 유지하고, 원본 재계산·원본 테이블·큰 원시 payload 조회 금지를 전체 읽기 경로에서 확인한다. 실제 DB에서 기존 형식의 행 전체 값과 순서, 이전 세대 참조, owner·날짜·세대 격리를 검증한다. 제품 SQL·마이그레이션·예산 수치는 바꾸지 않았다.
| 확인 | 전 → 후 | 증거 |
|---|---|---|
| 관련 SQL 계약 77개 | 71 통과/6 실패 → 77 통과/0 실패 | 실패 재현, 수리 후 |
| 기본 검사 | 후속 담당 관측 3,566 통과/6 실패/87 skip → 3,572 통과/0 실패/88 skip | 이번 실행 요약; 신규 DB 사례는 기본 실행에서 skip, 아래에서 실제 수행 |
| 실제 DB delta·reader | 기존 2개 + 신규 전환·격리 1개 → 3 통과/0 실패/0 skip | 전체 실행 |
| Merge Check·release | PR #1548 → 00a93ad384b9209f363399cba64b055ad2ebf4dc | 실행 |
검증 커밋은 8af3d09b20c6e95f739fffba4892f9ac2e81e434다. 추가 precheck 생략 지시를 유지했으며 위 결과를 precheck/full CI 성공으로 보고하지 않는다. BUG-108와 코드 PR을 연결했다. staging/Production 배포는 실행하지 않았다.