v0.17.5 통계 수렴 hotfix — 긴 계산이 저장을 막던 구조에서 계산·게시 분리까지 (2026-09-09)
2026-09-09 13:10 KST, 미반영 두 계정의 최신 통계 게시를 확인했다. 운영 배포와 CRUD smoke가 통과했고 13:13에는 원본 체크섬·영수증 보존과 authenticated 통계 조회도 확인했다. 운영 게시에는 아래의 6초 상한 조정이 필요했다. 휴대폰 화면 확인은 미수신이므로 #1432는 열어 둔다.
- 기간: 2026-09-09 ~ 진행 중. HQ 작업과 독립 SQL 구현·검증을 병행했다. 오너 지시: “진행해줘”.
- 랜딩: #1450 staging
5e6c97db7→ #1451 main61750dcc8. Migration20260913230000양 환경 적용. Edge·앱·문서·CRUD smoke 통과. v0.17.5는 실제 운영 커밋을 가리킨다. - 설계서: 없음(장애 후속 수리). 원인·영향·대응 평가는 BUG-084 포스트모템, 앞선 긴급 완화는 v0.17.4 작업 기록을 따른다.
- 정본:
supabase/definitions/stats/functions/{claim,compute,publish}_stats_projection_job_v1.sql,stats_projection_tables_v1.sql,stats_projection_is_staged_v1.sql,supabase/definitions/stats/tables/user_stats_projection_runs.sql. 통계 작성자·세대 계약은 통계 작업 문서와 D01 기록에 연결한다. - 도구: 레포의
scripts/performance/probe/stats-isolated-convergence.mjs; 개별 점수·strength 비교는 레포 밖work/incident1432/의 SQL/실행 도구. 원본 로컬 DB를 읽어 복제한 별도 DB만 변경했다. - 게이트: 신규 pgTAP 점수 18단언, strength 27단언, 상태·게시 45단언. 187개 마이그레이션 전체 재생 후 2026-09-09 12:47 KST, pgTAP 129파일·2,322단언 통과. 같은 새 구조에서 별도 복제 DB를 만든 큰 이력 게이트도 12:49 KST 통과했다(660세션·7,920세트, 생성 63ms·수정 43ms·삭제 18ms, 최초 계산 18.763초, 변경 후 재계산 13.396초·게시 1.315초, 전체 재생 동치와 실제 57014/임대 교체).
db:preflight전체 명령을 실행한 기록으로 대체하지 않는다. 이번 DB 중심 후속 수리에서 브라우저 E2E 전체 묶음은 반복하지 않았으며, 실제 DB 다중 연결 authenticated CRUD 검증과 배포 후 smoke로 범위를 나눈다. - 버그리포트: BUG-084. 후속 복구가 끝나면 동일 보고서에 운영 결과를 갱신한다.
- 계약: 원본·영수증·통계 수치 의미를 보존한다. 새 worker의 임대, 계산 격리, 세대 재검증, 원자적 게시 계약이 추가된다.
1. 배경
v0.17.3의 점수 정책 변경이 모든 계정의 과거 통계 재계산을 만들었고, 긴 계산이 저장과 같은 잠금을 잡으면서 기록 저장까지 실패했다. v0.17.4는 실제 문장 취소 처리, 재시도 상한, 앱에서 무거운 worker 실행 차단, 마지막 게시 통계 조회를 복구했다. 그러나 큰 이력은 3초 계산 예산을 넘겨 최신 통계가 계속 미반영으로 남았다.
이번 작업은 그 미완료 부분을 복구하기 위한 별도 hotfix다. v0.18.0 전체 리팩터링 완료를 기다리지 않고 계산 비용을 줄이고 저장과 계산의 잠금 관계를 분리한다. 원본 기록을 고쳐 통계 결과를 맞추거나, 오래된 결과를 최신으로 표시하는 변경은 포함하지 않는다.
2. 문제 제기
최신 기록 두 개를 저장해도 전체 과거를 여러 번 계산했다
strength는 dirty 날짜를 받지 않아 대상 종목의 관측·일별 상태를 모두 지우고 다시 계산했다. 점수 관측은 세션별 과거 조회를 반복했고, 기간 집계·리포트·달력이 같은 계산을 각각 수행했다. 앞선 계측에서 최신 dirty 뒤 전체 worker가 55.162초였던 이유다.
이전 미게시 쓰기가 남아 있을 때 enqueue가 종목 범위를 전체로 넓히는 동작은 변경 누락을 막는 안전장치다. 그 범위 확장만 제거하면 실패했던 과거 변경이 누락될 수 있으므로 유지했다.
계산 제한을 늘리면 사용자 저장이 다시 기다릴 수 있었다
advisory lock뿐 아니라 파생 테이블의 원본 외래키, 삭제 cascade, dirty 날짜 행도 저장과 경합한다. 기존 worker 안에서 제한을 3초에서 60초로 바꾸는 것만으로는 저장 가용성을 보장할 수 없다.
취소·새 쓰기·자정 경계를 계산 결과의 유효성에 반영해야 했다
계산 후 새 기록이 저장되면 완성된 payload도 이전 세대의 결과다. PR snapshot은 기준 날짜의 전후 하루 창을 포함하므로, 계산 뒤 자정을 지나면 같은 세대라도 창을 다시 만들어야 한다. 프로세스가 종료됐을 때는 이미 시작한 시도를 잃지 않고 새 임대로 이어가야 한다.
3. 해결 방안
원칙
- D1 — 2026-09-09: 오너의 “hotfix니까 바로 해”와 “진행해줘”에 따라 긴급 복구를 v0.18.0 완료와 분리한다.
- D2 — 2026-09-09: 앞서 승인된 이번 장애 대응의 전체 CI 중복 실행 생략 맥락을 유지한다. 변경된 후보의 관련 회귀·이관 검증과 staging·Production 배포 검증은 필요하다. 실행하지 않은 검사를 통과한 것으로 기록하지 않는다.
- D3 — 구현 판단: 서버 정규 기록과 영수증은 그대로 두고 파생 통계 계산만 격리한다. 계산 시작·끝과 게시 시점에 요청/게시 세대를 재검증한다.
접근
원본 쓰기 → canonical + receipt + dirty job
↓
트랜잭션 1: claim → 임대·시도 횟수 저장
트랜잭션 2: compute → 임시 파생 테이블 → durable JSON payload
트랜잭션 3: publish → 세대·날짜 재검증 → public 파생 테이블 + 완료 세대| 선택지 | 판단 |
|---|---|
| 기존 worker 제한만 늘리기 | 긴 계산과 저장의 잠금 경합이 남으므로 채택하지 않음 |
| dirty 시작일을 90일씩 넣기 | 현재 작업은 종료 경계/cursor가 없어 구간 분할이 되지 않으므로 충분하지 않음 |
| 점수 계산만 배치로 바꾸기 | 비용은 줄지만 저장과 계산의 잠금 관계가 남으므로 단독 해결책으로 쓰지 않음 |
| 계산을 임시 출력으로 격리하고 완성 결과만 짧게 게시 | 이번 hotfix에서 채택. 실패 시 이전 게시 결과와 outstanding dirty를 보존 |
4. 적용한 내용
세 트랜잭션으로 임대·계산·게시를 분리했다
claim_stats_projection_job_v1은 계산 전에 시도 횟수와 5분 임대를 기록한다. 활성 claimed/computed 임대는 최대 2개로 제한한다. 계산 하나와 게시 하나가 진행할 자리를 남기고, 다른 사용자는 pending / attempts 0으로 기다려 대기만으로 임대를 소모하지 않는다.
compute_stats_projection_job_v1은 17개 파생 테이블의 해당 계정 행을 임시 테이블로 가져와 기존 projector를 실행한다. canonical 조회와 작업·세대 상태는 public을 명시하고, 파생 출력만 임시 테이블을 사용한다. 임시 테이블에는 canonical 외래키를 복제하지 않는다. 완성된 결과는 user_stats_projection_runs.payload에 JSON으로 남기고 계산 트랜잭션을 끝낸다. 이 durable payload는 아직 사용자에게 게시된 통계가 아니다.
publish_stats_projection_job_v1은 requested/base 세대, 작업 상태, snapshot 기준 날짜와 payload 소유자를 확인한다. 부모/자식 순서에 맞춰 해당 계정의 public 파생 출력을 교체하고, 기존 완료 트리거가 검증하는 세대와 PR snapshot 창을 같은 트랜잭션에서 게시한다. 실패하면 출력 교체를 되돌리고 dirty와 이전 게시 세대를 보존한다.
cron 명령이 함수 호출 전에 설정하는 문장 예산은 claim 3초, compute 60초, publish 3초다. 세 스케줄은 5초 간격이다. 이는 운영에 적용할 명령의 예산이며, 현재 적용 여부는 대기다. 취소 후 실패 상태를 정리하는 시간까지 포함한 절대 응답시간 보장은 아니다. 사용자 쓰기와 경합하는 게시 잠금은 기다리지 않거나 짧게 제한한다.
실제 query_canceled를 처리해 실패 상태를 남기며, 만료/재시도 가능 오류는 새 token으로 다시 임대한다. 시도는 3회까지다. 이전 payload는 다음 임대로 넘기지 않고 다시 계산한다. snapshot 기준 날짜가 바뀌면 40001로 처리해 같은 세대를 다시 계산한다.
점수 관측을 배치 계산하고 같은 작업 안에서 재사용한다
set_score_observations_v1은 세션마다 과거 전체를 다시 조회하던 경로를 배치로 계산한다. 정책 의미는 개별 세션 점수와 대조한다. 신규 테스트는 종목·날짜 135개 필터 조합과 mixed set 종류를 포함해 18단언을 검사한다.
격리 compute는 한 세대의 점수 관측을 한 번 준비해 기간·리포트·달력이 재사용한다. 복합 세트는 여러 구성 종목을 대표 행 하나로 합치므로, 특정 종목 필터를 대표 ID 하나에만 적용하면 두 번째 구성 종목이 빠진다. 필터가 있는 요청은 구성 종목 겹침을 보존하는 canonical 배치 경로를 사용한다.
Strength는 이전 확정 상태를 이어받아 dirty 날짜부터 계산한다
refresh_user_strength_estimation_projection_from_v1은 dirty 날짜 이전의 마지막 accepted 값과 마지막 trusted 값을 각각 복원한다. 최근 수행 1RM은 마지막 이전 세션의 저장된 값·신호로 세션 끝 상태를 복원한다. 같은 날 여러 세션의 순서를 보존하기 위해 dirty 날짜 전체를 다시 계산한다.
이전 관측을 유지하면서 이후 관측만 재생하되, base projector가 현재 집계 행을 전체 재생성하므로 최종 rollup·record·period 보강은 보존된 이전 관측까지 포함한다. 새 assembled generation은 입력 세대 검증 아래 재사용된 파생 행도 현재 세대로 인증한다. 기존 privileged 전체 재계산 signature는 유지한다.
27개 회귀는 수정·삭제, 같은 날 재정렬, 90일 기준 만료 전후, 최초 세션의 측정 상한, 새 기록 추가, 체중 몫·무게 배수, 빈 suffix, 전체 삭제를 검사한다. 수정 전 strength 함수를 별도 oracle로 쓴 동일 비교도 통과했다.
주요 결정과 그 근거
- 파생 계산에서 canonical 원본이나 저장 영수증을 변경하지 않는다. 원본 쓰기의 기존 잠금·세대 보호를 제거하는 대신 계산 출력과 실행 트랜잭션을 분리한다.
- 임시 테이블은 source FK·알림 트리거를 복제하지 않지만, 복합 종목의 strength eligibility 같은 순수 정책 트리거는 계산 전에 적용한다.
- generation이 바뀐 결과는 게시를 포기하고 최신 요청을 다시 계산한다. 실패한 payload로 최신 표시만 앞당기지 않는다.
- 게시 후 JSON payload를 비워 계정 이력의 파생 사본을 불필요하게 계속 보관하지 않는다.
작업 중 드러난 것
임시 테이블의 순수 정책 트리거 누락은 복합 세트를 strength 투표에 잘못 포함할 수 있었다. 점수 캐시의 대표 종목 필터는 복합의 두 번째 종목을 놓칠 수 있었다. 두 조건을 고치고 실제 fixture가 그 분기를 밟는 회귀를 추가했다.
같은 backend의 이전 실행계획이 public 출력을 계속 가리키지 않도록 임시 스키마 준비 후 계획을 폐기한다. pgTAP은 한 트랜잭션이므로 계산 뒤 임시 출력만 명시적으로 제거한 다음 게시해, 실제 별도 트랜잭션의 payload 게시 조건을 검증한다. 다중 연결·실제 커밋 검증은 별도 부하 도구가 담당한다.
전체 CI 반복 생략은 이번 긴급 대응에서 오너가 승인한 맥락이다. staging·Production의 마이그레이션 적용, 새 cron 정의, 실제 통계 수렴과 CRUD 확인을 생략하는 승인은 아니다. #1450 병합에 staging의 PR 전용 임시 관리자 예외를 적용하고 즉시 원본 ruleset 전체를 복원·대조했다. #1451은 기존 main 관리자 예외를 사용했다. LANDING_PREFLIGHT_BYPASS는 사용하지 않았다. 승격 때 자동 생성된 전체 CI 34309490341은 중복 실행을 막기 위해 취소했고 성공으로 제출하지 않았다. 환경별 배포/CRUD 검사는 유지했다.
운영에서 확인한 게시 예산과 명시적 설정 차이
운영 큰 계정은 계산을 약 25초에 마쳤지만 3초 게시 상한에 걸렸다. 합성 이력은 세션·세트 수가 커도 종목 수가 작았고, 운영의 287종목·기간별 출력 비용을 대표하지 못했다. 계산 격리와 실제 게시 비용 검증은 별개였다.
13:09:43 KST에 Production의 barbelic-stats-projection-publish 명령만 set statement_timeout = '6s'; select public.publish_stats_projection_job_v1();로 조정했다. 계산 60초·claim 3초·임대/재시도 상한은 유지했다. 게시 상한은 저장 RPC의 8초 제한보다 작지만 실제 동시 저장의 보편적인 지연 보장으로 확대 해석하지 않는다.
이는 운영 설정 override다. v0.17.5 migration·schema·로컬 게이트의 기본 게시 예산과 staging은 3초다. 일상 배포 중 운영 값을 무심코 3초로 되돌리지 않는다. D08은 변경행 게시와 대표 workload를 보강하면서 실제 양 환경/코드/게이트 예산을 통합하고, 운영 규모 검증 후 이 override를 제거해야 한다. 기존 migration을 소급 수정하거나 운영과 코드가 이미 동일하다고 기록하지 않는다.
전체 단위 검사는 3,431개 중 3,377 통과·27 실패·27 skip이었다. 공유 함수 이동에 따른 본문 앵커와 생성물/배포 버전 불일치를 수정해 해당 39개 및 76개 검사, 장부 26개 검사, 타입·lint·마이그레이션·배포·테스트 감사 검사와 앱·관리자·문서 빌드가 통과했다. 이후 전체 단위/E2E를 다시 모두 실행한 것으로 기록하지 않는다.
5. 적용 결과
아래는 배포 전 로컬 결과다. 측정별 fixture·호출 범위가 다르므로 helper 시간과 전체 compute 시간을 합하거나, 서로 다른 실행을 하나의 Production 전후 수치로 묶지 않는다.
| 항목 | 결과 |
|---|---|
| 점수 관측 배치 | 동일 큰 이력에서 21.375초 → 1.862초. 결과 7,908행, 양방향 차이 0 |
| 점수 정책·필터 회귀 | 135개 종목/날짜 필터 조합을 포함한 신규 pgTAP 18단언 통과 |
| Strength 최신 dirty | 660세션·7,920세트 warm 후 새 2세션/8세트: 기존 전체 9.234초 → suffix 1.818초. 8개 파생 테이블 결과 동치, 최종 관측 7,928행 |
| Strength 회귀 | 신규 27단언 및 수정 전 함수 oracle 비교 통과 |
| 게시 상태·경계 회귀 | 신규 45단언 통과: public 불변, superseded 거절, 날짜 창 재개, 두 종류 lease 만료, 3회 상한, 활성 임대 2개 제한, 복합 정책/필터, 권한 |
| 실제 큰 이력 초기 계산/게시 | 660세션·7,920세트: compute 18.763초 / publish는 3초 상한 내 성공 |
| 계산 중 실제 authenticated CRUD | 생성 63ms / 수정 43ms / 삭제 18ms, 각 영수증 1건. 수정·삭제는 기존 관측이 있는 세션으로 검증 |
| 계산 중 새 generation | 기존 계산은 55000으로 폐기. 최신 요청을 compute 13.396초 / publish 1.315초로 계산·게시하고 requested=applied, dirty 해소 확인 |
| 전체 재계산 oracle | 최종 격리 게시 결과가 동일 원본의 privileged 전체 재계산 결과와 동치 |
| 실제 문장 취소 | 외부 statement_timeout=100ms에서 실제 57014, attempts 1·failed 보존, 같은 job 재임대 시 token 교체 확인 |
| 통합 migration replay·전체 pgTAP·후보 검사 | 187개 재생, 129파일·2,322단언 통과. 최종 게시 lifecycle45개와 동시 저장 세대/영수증13개도 통과 |
| staging PR/배포/CRUD | Deploy34309191453 성공 |
| Production PR/배포/CRUD | Deploy34309502657 성공 |
| 운영 계정 A | 663세션·8,531세트·287종목. 계산25.024초 / 게시4.449초, 13:10:19 KST 완료, requested=applied=524 |
| 운영 계정 B | 1,026세션·5,460세트·108종목. 계산15.927초 / 게시2.604초, 13:10:38 KST 완료, requested=applied=479 |
| 원본 보존 | 두 계정의 세션·세트 체크섬 및 건수, 영수증 건수 복구 전후 일치 |
| 실제 통계 읽기 | 두 계정 authenticated detail/year/history 모두 stale=false·동일 세대. 계정A 상위10종목 응답 전부 fresh, 9종목에 연간 그래프89지점. 모든 종목에 올해 관측이 있다는 의미는 아님 |
| 휴대폰 화면 확인 | 사용자에게 요청, 응답 대기 |
큰 이력 통합 도구의 해당 실행은 2026-09-09 12:47:26~12:49:14 KST다. 원시 결과 scripts/performance/evidence/stats-isolated-convergence.json은 gitignored 로컬 JSON이며 PR에 포함된 공개 CI artifact가 아니다. 점수·strength 비교와 개별 pgTAP 원시 로그도 레포 밖 work/incident1432/에 있다. 이 문서는 측정값과 재현 도구를 기록하며 개인 계정 식별자나 실제 운동 payload를 공개하지 않는다.
이 결과는 합성 사용자 1명과 로컬 장비의 정해진 이력에서 얻었다. 실제 운영 계정의 종목 분포, 더 긴 이력, 동시 트래픽에 대한 보편적인 지연 보장은 아니다. 운영 정상화 시각이나 남은 소요시간을 이 수치만으로 단정하지 않는다.
6. 이번 개선으로 향상된 것
계산 중에도 운동 기록을 저장할 수 있는 실행 경계가 생겼다
무거운 계산은 원본과 연결된 public 출력 행을 장시간 수정하지 않는다. 필요한 잠금과 출력 교체를 짧은 게시 단계로 모았고, 실제 다중 연결에서 생성·수정·삭제가 계산 종료를 기다리지 않는 것을 확인했다.
계산을 끝내지 못한 결과가 최신 통계로 섞이지 않는다
작업 token, 요청/게시 세대, 기준 날짜, 소유자와 완전한 snapshot 창이 게시 조건이다. 취소·lease 만료·새 쓰기·자정 변경을 개별 상태로 검사하며 이전 통계와 미처리 dirty를 보존한다.
계산 비용 개선과 정확성 검증이 함께 남는다
점수 배치와 strength suffix는 각각 기존 수치 의미와 비교한다. 최초 세션, 복합 구성원 필터, 과거 삭제, 만료 경계처럼 빠지기 쉬운 사례가 실행 회귀로 남는다. 이 검증은 실제 운영 규모와 동시 쓰기 검증을 대체하지 않고 함께 사용한다.
남은 것
- 서버 복구·원본 보존·배포 검증은 완료했다. 휴대폰 종목 상세 확인 후 #1432와 BUG-084의 사용자 확인 상태를 갱신한다.
- 운영 게시6초 override를 D08에서 코드/양 환경/게이트 예산과 통합한다. 대표 workload와 변경행 게시 검증 없이 3초로 되돌리지 않는다.
- 기존 privileged maintenance, import, Edge 실행 경로가 남아 있다. 새 격리 cron 도입을 모든 통계 진입점 이식 완료로 기록하지 않는다.
- 이번 구현은 완전한 날짜 chunk/cursor worker가 아니다. 과거 전체 dirty는 여전히 전체 계산이 필요하며 게시도 계정의 파생 출력 전체를 교체한다. 더 큰 규모에서 60초 계산·3초 게시 예산과 payload 크기가 유지되는지는 D08, #1412 후속에서 다룬다.
- 무거운 사용자가 3회 실패한 뒤에도 dirty는 남는다. 실패 알림·운영 재개 절차, 단계별 checkpoint와 유한 종료 범위를 보강해야 하며 무한 자동 재시도로 바꾸지 않는다.
- DB CI에 큰 이력 경합/수렴 게이트를 연결했다. 다음 보강은 종목 수·기간별 출력량·실제 게시 비용까지 대표하는 workload다. 전체 CI 횟수를 해당 검증의 대체 근거로 삼지 않는다.