v0.18.0 D10 — 전체 이력 탐색을 페이지 순회로, 날짜 전환을 새 세대로 (2026-09-10)
- 기간: 2026-09-10, Codex 1세션. 오너 지시: “바벨릭 #1418 진행해줘”, “분석 끝나면 바로 진행해”.
- 통합: 앱 PR #1521 →
release/v0.18.0의b62a358cf3ba73ea5858691e13ccabaea6b08761, 2026-09-10 16:30 KST 반영. staging·Production 배포 없음. 마이그레이션20260914093000_d10_maintenance_cursors.sql1개, Edge·앱 UI 변경 없음. - 기준·선행:
a1d462a8621d8f1d92e8932d4c17939ba2044eb1, D04 PR #1454·D08 PR #1510 포함. - 설계서: #1418 분석·Phase 계획, 예상 효과·대안·Production 읽기 전용 실측 포함.
- 정본: 유지 작업 계약, 통계 worker, DAG §7.
- 관측 결함: BUG-093 — 한 후보에도 전체 이력을 읽던 정기 복구 탐색. release 수리와 Production 적용 전 상태를 구분한다.
- 도구: 앱
scripts/performance/probe/stats-maintenance-{discovery,processing,sweep}.mjs; SQL 재생/추출·DB 장부 생성·populated upgrade 도구. 원시 JSON·로그는 세션work/1418-*에 별도 보관, 인증정보와 실제 사용자 행 원문은 저장하지 않음. - 게이트: 각 Phase
npm run check; pgTAP 51단언·실제 연결 7회귀·실제 cron 1회귀. 최종 precheck/전체 pgTAP와 Merge Check 통과, 아래 통합 증거 참조. 브라우저·viewport full CI는 이 작업에서 실행하지 않음. - 계약: 새 cursor/watermark/retry·catalog token·새 세대 요청, 기존 두 cron/RPC 진입점 호환, old/new 동시 전역 scan 제거.
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 기준선·반복 첫 후보·전체 이력 읽기 재현 | 완료 de2c41dd |
| Phase 2 | 원본 행 페이지·cursor·watermark·retry | 완료 7e01ffe1 |
| Phase 3 | catalog·날짜·bootstrap 요청과 D08 연결 | 완료 811910a0 |
| Phase 4 | 전 소스 비용·최종 upgrade/precheck·문서·PR 준비 | 완료 10c519b4, 병합은 아래 별도 기록 |
1. 배경
고칠 사용자를 한 명만 반환해도 모든 사용자의 완료 세션과 롤업을 집계하는 정기 탐색이었다. 접속하지 않는 사용자도 순회 내 복구 기회를 얻어야 하며, 날짜 전환 때 이미 발행한 통계 세대의 내용을 바꾸지 않아야 한다.
2. 문제 제기
LIMIT을 줄여도 후보 발견이 전체 이력을 읽었다
운영 읽기 전용 실측은 사용자 5명·완료 세션 2,030개·롤업 3,995개였다. LIMIT 50은 2명을 반환해 15.009ms·shared hit 1,729, LIMIT 1은 1명을 반환해 12.884ms·동일한 hit 1,729였다. 작은 결과가 작은 탐색을 뜻하지 않았다. 운영 rollover 후보는 카탈로그 함수 EXECUTE 권한 때문에 측정하지 못했고, 권한을 바꾸거나 운영 writer를 호출하지 않았다.
격리 DB의 3,650세션에서는 LIMIT 1/50 모두 세션 3,650행·hit 127을 읽었다. 반복 호출은 같은 첫 후보를 돌려줬다. bootstrap은 매번 사용자 목록 처음부터 찾았고 rollover는 due·catalog·header COUNT를 OR로 묶었다.
날짜 전환이 같은 발행 세대를 다시 썼다
종전 rollover는 refresh_user_pr_overview_snapshot_window(user, applied_version, current_date)를 직접 실행했다. 날짜에 따라 같은 generation의 내용이 달라졌다. 카탈로그 로그 seq만 완료 watermark로 삼는 대안은 낮은 seq의 늦은 커밋과 보존 정리를 다뤄야 했다.
3. 해결 방안
원칙
오너의 “분석 끝나면 바로 진행해”를 같은 이슈의 Phase 1~4 승인으로 적용했다. 대상은 release/v0.18.0이며 staging·Production 승격을 포함하지 않는다. 사용자 원본, 계산 의미, 3일 snapshot 창은 유지한다.
| 대안 | 결정·근거 |
|---|---|
| LIMIT만 축소·cron만 늦추기·기존 집계에 인덱스 추가 | 기각. LIMIT 이전 전체 집계와 재개 위치 부재가 남음 |
| 원본 행 페이지를 먼저 정하고 cursor·유한 상한·실패 요청을 함께 저장 | 채택. 성공·실패·취소의 재개 지점이 명시됨 |
| D08/D11의 별도 계산·발행 사본 | 기각. D04 요청과 D08의 기존 임대·예산·발행을 사용 |
4. 적용한 내용
Phase 1 — 기준선과 재현
기준 release와 운영 함수 본문을 대조했다. 동일 10년/3,650세션 합성 입력과 실행계획 JSON을 보관했다. 시간은 단일 warm 표본이며 p95가 아니다.
Phase 2 — 발견 자체를 페이지로 제한
완료 세션·세션 종목·롤업·사용자 네 소스를 순회한다. calls로 소스 차례를 정하고 PK cursor·watermark를 저장한다. 검사 후 enqueue 또는 durable retry와 cursor가 같은 트랜잭션에서 확정된다. 사용자/큐 잠금은 try-lock으로 확인하고 잠긴 사용자가 뒤 페이지를 막지 않게 했다. generic plan도 cursor 경계에서 시작하는지 검사한다.
Phase 3 — 날짜·catalog를 새 세대로
카탈로그 변경 트랜잭션에 pending token을 남긴다. 현재 pass를 끝낸 뒤 최신 token으로 다시 순회한다. 낮은 seq가 뒤늦게 커밋돼도 새 요청이 남으며, 로그 삭제와 무관하게 진행한다. 날짜 due 인덱스는 정렬과 같은 (next_refresh_at,user_id)로 바꿨다. 날짜·catalog·repair는 새 D04 target version을 요청하고 D08이 게시한다.
기존 backfill job의 cadence만 매분으로 변경했다. ID·active를 보존하고 중복 job을 만들지 않는다. 신규 계정 최초 generation 0 생성은 기존 bootstrap이며 누락 상태는 owner cursor가 복구한다.
Phase 4 — 비용·통합·기록
세션 이외 세션 종목·롤업과 1,001명 사용자·due 페이지의 generic plan도 측정했다. 전체 처리 비용은 별도 3,650세션·종목/세트 각 3,650개로 기존 D08의 세 단계를 커밋해 측정했다. 측정한 원본 세트 id·reps·load checksum의 전후 일치를 확인했다. 문서·장부는 이 문서 저장소에, 실행 SQL/검사는 앱에 둔다.
작업 중 드러난 것
- scanner 호출마다 seed INSERT를 하면 다른 scanner가 고정행에서 기다릴 수 있었다. 초기화는 migration으로 옮기고 실제 연결로 SKIP LOCKED 재개를 검증했다.
- 기존 SQL 문자열 검사는 새 scanner/요청 계약으로 옮겼으며 권한·명시적 owner·손상 사유·실제 DB 단언을 유지했다. 감사 manifest 등재 파일만
pending-changes에 선언한다. 미등재 기존 cron·운영 관측 검사는 새 세대 수렴과 매분 page 계약으로 갱신했다. - 기준선 DB 레인에서 완료 저장 세대 동시성 1건 실패를 변경 전에 관측했다. 같은 단독 재현은 최종 코드에서 1건 통과했다. 원인을 단정하거나 별도 수리 범위를 추가하지 않았으며 최종 precheck 결과와 구분한다.
- 처분용 sandbox에 실제 backend가 없는
connectingcron 이력 2개가 남아 계측 격리가 막혔다. 해당 이력 상태를 확인해 두 행만 정리했다. 제품 함수·Production 예약 상태·테스트 실패 기준은 바꾸지 않았다. - 초기 대량 순회 fixture는 1,001명 bootstrap과 모든 페이지를 한 트랜잭션에 넣어 잠금 슬롯 부족으로 실패했다. 실제 cron처럼 fixture와 페이지를 커밋하도록 계측을 고쳐 통과했다. DB 잠금 설정이나 제품 동작을 바꾸지 않았다.
- 저장소 전체 커버리지의 기존 미분류 75개·미등록 cron 1개·장부 행 누락 2개는 이 이슈에서 수정하지 않았다. D10 추가 파일의 미분류는 0이다.
- 사전 검증 누락: 최종 upgrade 증거를 갱신한 뒤 registry·DB 장부 지문을 다시 생성하지 않아 첫 통합 precheck의 SQL 계약 검사가 실패했다. 해당 실행을 중단하고 공식 추출·장부 도구로 생성한
8be0e6da에서 SQL·DB 장부 일치를 확인한 뒤 precheck를 재실행했다. 실패한 실행을 성공으로 합산하지 않는다. - 두 번째 통합 precheck는 정적·단위·실제 cron/동시 저장·복구·D04/D08/D10 연결을 통과했으나, 기존 pgTAP의 시간당 cadence 기대값과 격리 수렴 DB 복제의 D10 큐 부속행 제외 누락이 실패했다. cadence는 매분 page 계약으로 맞추고, 복제에서 이미 제외하는 job에 종속된 maintenance 요청·retry·catalog pending 데이터도 제외했다. 제품 FK·수렴 단언·시간 예산은 유지했다. 이 두 누락도 사전 검증 누락으로 기록한다.
5. 적용 결과
| 항목 | 이전 → 후보 결과 |
|---|---|
| 동일 3,650세션 LIMIT 1 | 세션 3,650행·hit127 → 1행·hit3·0.097ms |
| 동일 3,650세션 LIMIT 50 | 세션 3,650행·hit127 → 50행·hit52·0.131ms |
| 뒤쪽 세션 종목 페이지, 위치 3,600 | generic plan: 종목49행+경계1행, 부모 세션 PK49회·hit249·0.287ms |
| 뒤쪽 롤업 페이지, 위치 3,600 | generic plan: 49행+경계1행·hit53·0.182ms |
| 사용자 1,001명 중 뒤쪽 페이지 | 사용자50행+경계1행·hit53·0.146ms |
| 사용자 1,001명 중 due25 | 상태25행·hit30·0.069ms |
| 정상 사용자 1,000명 뒤 손상 사용자 | 21회 커밋된 페이지 호출·총 1,001명 검사 뒤 요청, 호출당 최대50명 |
| D06 통합 전 별도 3,650세션/세트 전체 처리 | enqueue5.498ms·claim4.069ms·compute24,744.866ms·publish2,368.616ms. 측정한 세트 id·reps·load checksum 동일 |
| D06 포함 최신 release의 같은 규모 전체 처리 | b8c19017: compute 60,105.973ms에 60초 statement timeout으로 failed. publish 미실행·전체 수렴 미검증. 원인 미확정 |
| 별도 필수 격리 회귀: 660세션·7,920세트 | 큐 부속행 복제 누락 수정 뒤 통과. 계산 중 CRUD 79/59/29ms, 취소·backoff·lease 교체·재시도·최종 full 동치 확인. 위 3,650세션 표본과 입력이 다름 |
| 실패·중단·late commit | cursor/요청 atomic rollback, 실패 owner 재시도, 다음 순회 발견, catalog 후속 pass 확인 |
| 실제 예약 | 정지 중 고정 창 유지, 복원 후 새 세대의 현재 창 수렴, 다섯 job 원래 상태 복원 |
| SQL·DB 장부 | 표87·뷰3·인덱스150·FK167, 정책 누락0·중복/접두 인덱스0·기존 FK 예외43 유지 |
| 환경 | PostgreSQL17.6.1.127, 로컬 Docker. 운영은 변경 전 read-only 기준선만 측정. staging·Production 배포 없음 |
계산 비용의 한계: D10은 발견 비용을 제한한다. 날짜·catalog 요청이 현재 D04 full scope를 사용하므로 일일 전체 재계산 비용이 생긴다. 24.7초/2.37초는 D06 통합 전 한 장기 계정의 warm 단일 표본이다. 최신 통합에서는 같은 규모 계산이 60초 제한에 실패했으므로 이 계정의 전체 수렴이나 자정 backlog SLA는 입증하지 못했다. 실패를 숨기거나 예산을 늘려 통과시키지 않았다. R04가 입력 규모·요청 병합·D08 동시 실행 상한과 함께 재측정할 입력이며, 원인을 D06 결함으로 단정하지 않는다. 날짜별 선택 투영은 D11의 기존 소유 경계에서 판단한다.
통합 증거
15:51 KST precheck가 최신 release의 D06 PR #1518, 5b16a162를 반영하면서 생성 파일·manifest·감사 이유의 충돌을 발견했다. D06 구현을 보존하고 D10 migration만 새 꼬리 20260914093000으로 옮겨 전체 재생과 원천·registry 일치 검사를 통과했다. 통합 커밋은 b8c19017이다. 이전 비용 표와 upgrade는 D06 통합 전 입력임을 구분한다.
통합 전 SQL(811910a0, 실행 입력 worktree)의 populated upgrade는 2026-09-10 15:35 KST에 통과했다. 기존 release→forward migration 1개, 원본 fact 4,444행·세션3,650개, 적용389ms·WAL0.31MB·관측 잠금대기0/0ms·프로브 오류0/10·owner 프로브8·원본 checksum 유지.
최신 D06 포함 기준 5b16a162 → b8c19017 worktree의 populated upgrade도 통과했다. 동일 원본 fact 4,444행·세션3,650개, forward migration 1개 적용304ms·WAL0.31MB·관측 잠금대기0/0ms·프로브 오류0/10·owner 프로브8·원본 checksum 유지, 구/신 공존 break0. 최종 증거 머리줄·snapshot 지문 커밋은 44f10722이며 이어 8be0e6da에서 registry/model 지문까지 맞췄다. 부분 검증을 full CI 성공으로 보고하지 않는다.
최종 10c519b4의 precheck는 A11 PR #1516을 포함한 daca15d9를 기준으로 6분 31초에 통과했다. 정적·타입·앱 빌드/자산, 단위 3,509통과·0실패·67조건부 미실행, 전체 migration replay·snapshot 일치, pgTAP 142파일·2,804단언, 실제 cron/동시 저장/복구·D04 범위·D08/D10 연결·660세션/7,920세트 격리 수렴을 모두 통과했다. 실행 ID는 local-1789024792958-76048이다. 이후 U03 PR #1520의 c1af78e8이 release에 들어왔으며 DB migration 추가는 없었다. 최신 base와의 병합 검사는 Merge Check가 맡는다.
Merge Check 실행 34450196844은 성공했다. 잡 실행은 16:29:25~16:30:27 KST, 62초였다. 최신 release와 PR head를 합친 검증 merge b62a358c를 그대로 반영했고 PR #1521의 실제 merged 상태·release 포함을 확인했다. 새 세션 제목의 반영완료는 이 release 병합을 뜻한다. Production 성공 전이므로 이슈 #1418은 제목을 유지해 열어 두었다.
6. 이번 개선으로 향상된 것
페이지 한 번의 발견 비용이 사용자 한 명의 전체 이력 집계에 묶이지 않는다. inactive 사용자도 owner 순회에 들어오며, 실패한 사용자를 기록하고 다음 대상으로 진행한다. 카탈로그 알림·cursor·retry에서 무엇을 요청했고 어디까지 진행했는지 볼 수 있다. 날짜 전환이 새 세대를 요청해 유지되는 직전 세대의 의미가 일정해진다.
남은 것
D11에는 새 세대 요청과 old scan 퇴역 계약, R04/R05에는 발견/계산 분리 계측과 재개 fixture를 넘긴다. staging·Production·브라우저/viewport full CI 및 다수 장기 계정의 자정 부하는 이 작업의 실행 증거가 아니다. 인계 계약을 따른다.