Skip to content

통계 오류 수리 — 계정 전체 계산을 커밋 가능한 종목 배치로 (2026-09-14)

  • 기간: 2026-09-14, 수리·배포·운영 복구 확인. 오너: “지금 연간 리포트랑 종목 상세에서 이렇게 통계 오류가 나는데 이유가 뭐야?”
  • 랜딩: main·Production 배포 및 운영 복구 확인 완료. #1590, 검증 앱 a8e5871fd4b13e300d58197b722c2b1507a6d044, 최종 main 1711ae2e84612d8af868670a65ef3b0c48957817, migration 20260915083000. staging 배포 34771151454·Production 배포 34771543350 성공. 승인된 관리자 직행으로 PR/Full CI는 없으며 새 버전 태그도 만들지 않았다.
  • 설계서: 별도 artifact 없음. 이슈 계획과 아래 대안·자원 경계가 설계 기록이다.
  • 정본: 통계 잡, 배치 전환 계약. 앱 compute_stats_projection_job_engine_v1, run_stats_projection_step_v1, stats-process-refresh-jobs가 실행한다.
  • 도구: 개인정보 없는 synthetic fixture·분리된 진단 DB·정식 로컬 sandbox. 앱의 tests/db/statsProjectionExerciseBatching.test.mjs 및 규모별 stress runner로 검증한다.
  • 게이트: 로컬 precheck 1회 9분 24초 통과. 정적/unused/build/artifact, 단위 3,799 pass·148 conditional skip, pgTAP 122파일·2,444단언, 실제 DB Node 107/107(skip 0), 큰 이력 격리·동시 CRUD 수렴 probe 통과. 범위를 명시한 populated upgrade B도 통과했다. 브라우저/viewport 전체 검사를 실행한 Full CI 결과와 구분한다.
  • 버그리포트: BUG-144.
  • 계약: batch_pending/retry_pending, 배치별 별도 커밋, 진행 정보 검증, 완성된 세대만 원자 게시.

Phase 현황

Phase내용상태
Phase 1구조 수리·검증: 원인 재현, 비공개 배치·재개·worker, 정확성·규모 검사로컬 구현과 운영 설정 DB 8/8·스트레스 통과
Phase 2필수 검사·반영·운영 확인precheck·populated upgrade B, staging/main 반영·배포·운영 세대 수렴 및 DB 화면 RPC 확인 완료

1. 배경

운영 계정 한 명의 658세션·296종목 통계가 53200: out of shared memory로 실패했다. 요청 세대는 637인데 게시 세대가 633에 머물러 연간 리포트에 데이터 오류가 표시되고 종목 상세 요약 숫자는 가 됐다. 이력 그래프는 별도 읽기 결과가 남을 수 있다. 원본 운동 기록의 손실로 확인된 사건은 아니다.

2. 문제 제기

사용자 한 명을 격리해도 그 사람의 전체 계산량은 제한되지 않았다

순차 계산기는 종목마다 임시 표와 인덱스를 생성·삭제했다. DROP해도 관계 잠금은 같은 트랜잭션이 커밋될 때까지 남아, 종목 수가 많아질수록 공유 잠금 테이블이 소진됐다. 메모리 한도를 올리거나 임시 표를 재사용하는 것만으로는 계정 전체 계산 시간·복사량·트랜잭션 크기의 증가를 제한할 수 없다.

실제 사본 upgrade 성공을 그 계정의 full 통계 완료로 검증하지 않았다

R05 실제 사본 검증은 migration 적용·원본 보존·복구를 입증했다. 실제 다종목 계정의 신규 full claim→compute→publish 완료 증거는 없었다. 기존 장기 이력 fixture의 세션/세트 수와 고유 종목 수는 다른 증가 축이다. 이번 누락은 사전 검증 누락이며, 과거 성공 증거를 실패로 바꾸거나 미검증을 성공으로 확대하지 않는다.

3. 해결 방안

원칙 — D1, 2026-09-14

오너: “그러면 배치 반복으로 바꾸고, 배치 끝나면 잠금도 없애고 이런식으로 확장성 있게 재설계해줘”.

대안판단과 이유
계정 전체 계산 + 한도 증가기각. 같은 누적 구조를 유지하고 실패 시점만 늦춘다.
임시 표 재사용만 적용단독 해결로 기각. relation 누적은 줄여도 전체 계정의 실행 수명은 그대로다.
범위 계산 + 비공개 배치 + 최종 원자 게시채택. 기존 full/incremental 범위와 수치 의미를 보존하며 실행 트랜잭션을 나눈다.
배치마다 즉시 공개기각. 다른 통계 화면이 서로 다른 세대를 읽을 수 있다.

접근과 남는 비용

준비 단계가 정확한 작업 목록을 고정한다. 후보 종목이 100개를 넘으면 최대 100개씩 순차 계산하고 진행 위치와 비공개 결과를 커밋한다. 최종 집계가 끝난 뒤 기존 publisher가 완성된 결과만 공개한다. 배치 내부 임시 표는 재사용·초기화하고 배치 커밋에서 관계 잠금을 해제한다.

증가 축은 사용자 수, 고유 종목 수, 종목별 이력, 출력 바이트, 동시성이다. 순차 배치의 종목 수를 제한하지만 최초 정규화·최종 집계/게시와 단일 종목의 긴 이력은 아직 전체 행·바이트에 비례할 수 있다. 설정 예산은 compute 60초·publish 6초를 유지한다. 100개 분할이나 특정 규모 성공이 미측정 규모의 시간·메모리 안전 보장은 아니다.

4. 적용한 내용

Phase 1 — 기존 계산과 공개의 책임을 보존하며 실행을 나눔

  • run_stats_projection_step_v1이 준비/순차/최종 단계를 담당하고 기존 전체 orchestration은 전체 단계에 위임한다. 수치 정책과 최종 게시 작성자는 늘리지 않는다.
  • 기존 19개 typed stats_output_* 비공개 저장소를 재사용하고 run의 _batch에 목록·위치·입력판·revision·재시도 정보를 둔다. 원본 사용자 기록은 수정하지 않는다.
  • 같은 트랜잭션의 반복 호출은 transaction_boundary_required로 전진하지 않는다. 새 RPC에서만 다음 배치가 실행된다.
  • 57014/55P03에서 완료 배치는 보존하고 실패한 배치만 기존 예산 안에서 재시도한다. 입력판 변경과 낡은 결과는 거부한다. 검증을 통과한 만료 임대만 같은 token으로 제한적으로 재개한다.
  • Edge와 동기 SQL 어댑터는 pending을 완료로 오보하지 않는다. Edge 반복 상한 뒤에도 진행을 남기고, retry 시각을 호출자에게 반환한다.
  • 배포한 Edge 도장은 20260914-exercise-batch-worker-v1이다. 새 테스트 파일을 추가하고 기존 statsRefreshJobs 연결 단언 수정은 tests/audit/pending-changes.json에 신고했다.

작업 중 드러난 것

초기 정식 DB 회귀의 100/793 두 사례는 계산·게시 후 이전 세대 header까지 세는 잘못된 기대값 때문에 실패했다. 실제 계산 성공과 테스트 실패를 구분했고 단언 수리 후 222개 migration 재생 DB에서 8/8을 확인했다. 운영과 같은 설정의 재실행도 8/8, 46.379초로 통과했다.

Phase 2 — 필수 검사에서 드러난 연결·생성물 차이 수리

로컬 npm run check 1차는 전체 3,947개 중 성공 3,792·실패 7·skip 148로 성공률 96.07%였다. 실패는 기존 SQL 테스트 참조 4개와 SQL 작성자 해석의 주석/생성파일 불일치 3개다. 관련 검사 33/33과 작성자 장부 검사 26/26으로 수리·검증했고, 최종 precheck 단위 결과는 같은 전체 3,947개 중 성공 3,799·실패 0·조건부 skip 148(성공/전체 96.25%)이다. 알려진 실패 7/7개 수리 후 precheck를 1회 실행해 9분 24초로 통과했다. 이 기록은 원격 Full CI 실행 결과가 아니다.

새 SQL 반환형의 ACL 누락은 정적 게이트가 잡아 복원했다. 실제 권한은 anon/authenticated EXECUTE=false, service_role=true다. SQL 작성자 해석 문제는 신규 주석의 apostrophe를 본문 문자열로 오인한 것으로, 해당 주석 문구만 정정했고 제품 계산은 바꾸지 않았다.

Populated upgrade B — 현행 테이블 상태에 구 계산 runtime을 복원한 재현

현행 222 migration의 테이블 상태에 구 runtime 함수 10개를 복원하고 신규 helper 2개를 제거했다. 296종목에서 구 계산의 53200 실패(5.868초)를 실제로 확인한 뒤 **전체 migration 20260915083000**을 적용했다. 복구 job은 1개 생성됐고, prepare → offset 100 → 200 → 296 → computed가 5개 독립 트랜잭션에서 각각 212/1,095/986/1,012/765ms로 완료됐다. 별도 publish는 301ms였고 applied_version=requested_version=2로 수렴했다.

원본 5개 표의 각 296행과 hash가 변경되지 않았다. migration 2·3회 재적용에서 추가 enqueue는 0이었다. 이는 원본이 있는 상태에서 구 runtime 실패→전체 수리 migration→자동 복구·재적용을 검사한 B 조건의 증거다. 구 221 schema 전체를 처음부터 재생한 upgrade 증거로 확대하지 않는다.

실제 반영 경로와 검증 재사용

오너가 승인한 관리자 직행으로 반영했으며 수리 PR·원격 Full CI는 실행하지 않았다. staging의 a8e5871f는 DB·Edge·frontend·smoke 배포를 통과했다. main 직전 다른 세션의 AGENTS.md 전용 변경 92b6d731을 보존해 최종 1711ae2e를 만들었다. a8e5871f와 비교해 AGENTS.md를 제외한 모든 파일이 byte-identical임을 확인하여 기존 실행 검증을 재사용했고, 1711ae2e에서 정적·로컬 build·artifact 검사를 추가 통과했다.

Production 배포는 DB·Edge·frontend·public-origin·smoke를 통과했다. Production browser journeys는 기존 if: false 정책으로 skip됐으므로 브라우저 여정 성공으로 보고하지 않는다. merge에 SemVer가 없어 새 버전 태그를 생성하지 않았다.

5. 적용 결과

검증 앱 a8e5871f와 실행 파일이 같은 main 1711ae2e의 실제 Production 결과까지 확정한 기록이다. 진단 DB·정식 sandbox·Production 결과를 합산해 성능 향상을 계산하지 않는다.

항목전 → 적용 결과 / 판정
운영 통계 세대최초 requested 637 / applied 633 → 복구 확인 시 requested=applied=639
진단 DB 296종목기존 계산 53200 → 준비·100·100·96·최종 계산·게시 실행 성공
진단 DB 시간순차 배치 각각 약 1.2초, 최종 계산 634ms, 게시 227ms. 전체 사용자 체감 지연이나 정식 성능 판정 아님
스트레스 sandbox 조건PostgreSQL 17.6, max_connections 60, max_locks_per_transaction 64, shared_buffers 256MB. 전체 메모리 제한과 구분
101종목 기존 전체 호출과 동치같은 수치 정책을 사용하는 기존 전체 호출 결과와 배치 결과의 모든 게시 투영 일치. 수치 정책 자체의 독립 기대값 검사와 구분
793종목초기 계산·게시 약 13.8초. 100/793 header 기대값 수리 후 222개 migration 재생 DB 묶음 8/8 통과
동시/동일 TX/원본 변경/55P03 등 DB 8개운영과 같은 설정에서도 8/8, 46.379초 통과
Edge·기존 자원 계약7 + 5 = 12/12 통과, 실패 0·skip 0. 전송은 대역이며 DB 잠금 증거와 구분
1,001종목 스트레스compute 13 RPC(준비+11배치+최종). 가장 긴 compute 3,719.552ms, 게시 615.276ms. 사전 compute 60초·publish 6초 예산 통과
단일 종목 2,609세션·7,827세트compute 17,319.930ms, 게시 1,310.944ms. 같은 사전 예산 통과
스트레스 정확성·커밋·정리1,001종목/집중 이력의 프로필 볼륨 등 독립 기대값 검사 통과. 관계 잠금 표본 peak 각각 658/741, 커밋 후 관측한 임시 relation/advisory/write 잠금 0, synthetic 원본 fixture 잔재 0
스트레스 보장의 한계단일 종목 이력을 날짜별로 나누지 않는다. 더 긴 이력·출력 바이트·최대 메모리·미측정 동시성의 한계는 미검증
최종 로컬 precheck1회, 9분 24초 통과. static/unused/build/artifact 포함
최종 단위전체 3,947 / 성공 3,799 / 실패 0 / 조건부 skip 148. 성공/전체 96.25%; skip을 통과로 세지 않음
최종 pgTAP122파일·2,444단언 통과
실제 DB Node107/107 통과, 실패 0·skip 0. 묶음 33+4+43+12+7+8
큰 이력 격리·동시 CRUD실제 수렴 probe 통과
Populated upgrade B구 runtime 복원 조건의 296종목 53200 → 전체 migration으로 복구 job 1개 → 5 compute 트랜잭션+publish → requested=applied=2. 원본 5표 각 296행·hash 보존
Migration 재적용2·3회 적용의 추가 enqueue 0. 구 221 schema 전체 재생을 검증한 것은 아님
로컬 npm check 1차전체 3,947 / 성공 3,792 / 실패 7 / skip 148, 성공률 96.07%. 실패 7/7 수리 후 위 최종 precheck 통과
알려진 검사 실패 수리관련 33/33, 작성자 장부 26/26 통과. 새 반환형 ACL 복원 확인
staginga8e5871f, 배포 34771151454 성공(DB·Edge·frontend·smoke)
main·Production 배포1711ae2e, 배포 34771543350 성공(DB·Edge·frontend·public-origin·smoke)
운영 큐·오류2026-09-14 02:28:52 KST(17:28:52 UTC): stale 사용자 1→0, open job 0, 미해결 53200 작업 1→0
운영 복구 작업completed, error=null. compute 합계 43,853.488ms, publish 1,984.670ms
운영 화면 RPC02:32:43 KST(17:32:43 UTC), DB READ ONLY 트랜잭션의 report/detail/detail_year 3개 모두 contract 4·stale=false
브라우저·태그Production browser journeys는 기존 정책으로 skip. 별도 HTTP·실기기 화면 확인 미실행. 새 SemVer 태그 없음

운영 화면 RPC에서는 백스쿼트 대상 1개, measured 1RM 객체 존재와 12주 결과를 확인했다. 진단 집계는 report year_days=556, exerciseRows=55, JSON 크기 923,180바이트, detail 12,692바이트, detail_year 14,998바이트였다. 이는 DB 함수 결과의 구조·신선도와 직렬화 크기 검사이며 HTTP 전송량이나 실기기 렌더링 검사로 확대하지 않는다. 원문 payload·계정 ID·실제 개인 수치는 공개하지 않는다.

실행한 Edge 검증:

powershell
node --import tsx --import ./tests/support/registerNodeTestBudget.mjs --test tests/react/statsBatchWorker.test.mjs tests/react/resourceContract.test.mjs
node scripts/check-test-manifest.mjs
git diff --check

6. 이번 개선으로 향상된 것

큰 계정도 완료한 계산을 다음 호출에 이어갈 수 있는 구조

사용자 전체를 한 번에 계산하던 실행 경계를 나눠, 한 배치가 커밋되면 다음 배치가 그 위치부터 이어갈 수 있게 했다. 원본 변경·임대·출력판 검증은 유지하고 미완성 값을 공개하지 않는다. 실제 운영에서 세대 639까지 수렴하고 화면 RPC 3개가 최신 결과를 반환하는 것을 확인했다.

검증할 규모와 실패 조건을 구체적으로 남김

종목 수 증가, 한 종목에 집중된 이력, 동시 실행, 중단 후 재개를 분리해 테스트한다. migration 적용·원본 보존·실제 통계 완료를 구분하며 미검증 값을 비워 두거나 성공으로 채우지 않는다.

완료 범위와 검증 한계

요청한 구조 수리·로컬 필수 검증·main 반영·Production 배포와 통계 복구 확인을 마쳤다. 문서 원격 게시 절차는 앱 운영 반영과 별개다. 로컬 precheck·populated upgrade B·운영 설정 DB·스트레스는 위 조건의 증거이며 더 큰 입력 전체, 단일 종목의 무제한 이력, 최대 메모리, 구 221 schema 전체 재생을 보장하지 않는다. 정책상 skip된 Production browser journeys와 별도 HTTP·실기기 화면 검사는 미실행으로 남긴다. 이러한 검증 상한을 이번 운영 복구 완료와 구분한다.