v0.18.0 D01 — 통계 함수 10개가 같은 표에 겹쳐 쓰고 "같은 결과" 의 정의가 없던 것에서 계산 DAG·작성자 장부·정책/세대 계약·손 계산 oracle·결정성 검사까지 (2026-09-07)
- 기간: 2026-09-07 ~ 2026-09-07 (세션 1개
08b11132, 오너 지시 "1286 진행해줘" → 분석·계획 게시 → "ㄱ") - 랜딩: PR #1319(Phase 1~5 한 PR, squash) — 마이그레이션·서버 함수·앱 화면·공용 서비스 변경 없음. 문서 2·기계 판독 장부 1·테스트 도구 4·테스트 2·fixture 15·기준 요약 2·장부 규칙 2줄. 선행 G02
b0e5076a·G03e94f0d34·스텝 1-3(D0316b8e996) 머지 확인 뒤 착수, 읽은 mainbeff760b(브랜치 기준884cc181) - 설계서: 없음 — 분석·Phase 계획은 이슈 #1286 댓글("예상 효과·개선사항" 절 포함)
- 정본:
docs/contracts/stats-projection-dag.md(현행 10단계·표별 현재/목표 작성자·목표 DAG 6단계·작성자 14·DTO 6·화면 읽기 표·동치 판정 정의) ·docs/contracts/stats-projection-policy.md(정책 9종·반올림·null/0·동률·세대 G-1~G-8·비교 제외 사유·불변식 16·근거 매핑) · 장부supabase/contracts/stats-projection-writers.json(계약 버전 1) · 총괄 문서2026-09-07-v0-18-0-architecture-roadmap.md§12 D01 행 - 도구:
tests/db/support/projectionWriters.mjs(schema.sql 정적 분석 — 작성 함수·열) ·oracleLoader.mjs(oracle 사례를 서버 저장 문으로 적재·큐 소진·metric 읽기 SQL) ·projectionSnapshot.mjs(논리 키 스냅샷·uuid 정규화·요약 sha256·diff) ·projectionInvariants.mjs(불변식 SQL 12) - 게이트:
tests/db/statsProjectionWriters.test.mjs7건(CI 단위 잡 — 작성자 드리프트·목표 중복 0·동적 SQL 0·미등재 표 0·단계 순서) ·tests/react/statsOracleTools.test.mjs+1(어휘·단계·1/0) ·tests/db/statsProjectionDeterminism.test.mjs4건(샌드박스 전용 — oracle 대조·결정성·불변식·workload 기준 요약). 로컬npm run check통과. pgTAP·e2e 미접촉(샌드박스는ci:local --full --only db로 만들어 별도 사용) - 버그리포트: 없음(수리 건 아님 — 드러난 것 4건은 §4 와 후속 이슈 인계로)
- 계약: 새 계약 문서 2(위). 기존 dirty event·잡 큐·원본 불변·정책 파일(
docs/policies/**) 무변경. 수치 정책 값 변경 0(P2). G02 oracle 적재기tests/support/statsOracle.mjs는 복합·무게 없는 세트를 허용하도록 형식 검사만 넓힘
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 조사(worker 10단계·작성자 지도·읽기 RPC·정책 목록·Production 실측)·계획 게시·워크트리 | ✅ 이슈 댓글 |
| Phase 1 | 계산 DAG·작성자 계약 문서 · 장부 JSON(표 20) · 정적 분석 검사 7건 | ✅ a6aedf9e |
| Phase 2 | 정책·세대 계약 문서 | ✅ 7b420a5c |
| Phase 3 | oracle 코퍼스 사례 14건(002~015) · 입력 단계·metric 어휘 규칙 · 적재기 형식 확장 | ✅ 47fbbd4f |
| Phase 4 | 샌드박스 동치 검사(oracle 적재 대조 116/116·결정성·불변식 12·workload 1년/4년 기준 요약) · 문서 정정(e1RM 저장 1kg) | ✅ f948f54c |
| Phase 5 | 장부 규칙·이 기록·등록 2곳·전체 검사·PR·인계·HQ 갱신안 | ✅ PR #1319 |
1. 배경
v0.18.0 통계 트랙(D01~D11)은 "세션 하나를 고치면 그 뒤 전부를 다시 쓰는" 지금 구조를 증분 계산·사용자별 worker·완전한 세대 발행으로 바꾼다(G04 실측: 매분 cron WAL 46GB/20일, 10년 이력 소진 127초). 그 전에 무엇이 "같은 결과" 인지, 어느 표를 누가 쓰는지 를 적어 두지 않으면 바꾼 뒤에 맞는지 틀린지 판정할 수 없다. D01 은 코드를 바꾸지 않고 그 기준을 만든다.
2. 문제 제기
유저 A 의 세트 하나가 지나는 길에 주인이 없었다
유저 A 가 8월 12일 스쿼트 세션의 세트 무게 하나를 고치면 서버는 잡 하나를 큐에 넣고 worker 가 통계 함수 10개를 순서대로 돌린다. 그 10개가 같은 표에 겹쳐 쓴다 — 세션×종목 합계 표는 6개 함수(첫 함수가 행을 지우고 다시 넣되 추정 1RM 열은 비워 두고, 뒤 함수 넷이 열을 나눠 채우고, 세트 삭제 트리거가 12열을 NULL 로), 기간 통계 5개(지우는 함수가 둘), 훈련 기간 4개, 종목 통계 3개. 한 행의 최종 값은 "마지막에 쓴 함수" 가 정하고, 어느 함수가 어느 열의 주인인지 적힌 곳이 없었다.
"같다" 의 정의가 없었다
표마다 updated_at·materialized_at·applied_version·무작위 id 가 논리 값과 섞여 있어 그대로 비교하면 항상 다르다. 같은 입력을 두 번 계산해 같은 결과인지 검사하는 테스트가 없었고, 기대값이 있는 테스트는 대부분 앱 출력을 박제한 것이었다(G02 가 oracle 규칙을 만들고 사례 1건만 남긴 상태).
정책 버전과 세대가 내용을 고정하지 않았다
근력 3표는 행마다 정책·곡선 버전을 갖지만 기간·달력·종목 통계·훈련 기간 6표에는 버전 열도 세대 열도 없다. 매일 도는 롤오버는 이미 발행된 세대 번호로 PR 스냅샷을 다시 써서, 같은 세대의 내용이 날짜에 따라 바뀌었다.
3. 해결 방안
원칙 (오너 결정 없음 — 전제 1~6 으로 진행)
- 코드를 바꾸지 않는다. 계약·장부·fixture·검사만 만든다(유저 데이터·Production 무영향).
- 수치 정책은 값 그대로. 기대값 출처는 손 계산(
hand:)·정책 표(table:)·집계 실측(production:)만. - 장부에 "현재 작성자" 와 "목표 작성자" 를 나란히 두고, 검사는 현재 = 실제 코드(드리프트), 목표 = 표당 하나 를 잰다. 실제 이동은 D02~D11 이 한 표씩.
- 동치 = ① oracle(맞는 답) ② 기준 스냅샷(옛 구현과 같은 답) ③ 정책 불변식(구현 무관 성질) 셋 다.
접근
| 대안 | 채택 | 이유 |
|---|---|---|
| 장부(JSON) + schema.sql 정적 분석 검사 + 문서 2 + oracle 사례 + 샌드박스 결정성 검사 | 채택 | 코드를 안 건드리고도 "지금 누가 쓰는가" 를 기계가 고정하고, 후속 트랙이 옮길 때마다 같은 검사가 판정한다 |
| 작성자를 지금 바로 하나로 합치는 SQL 리팩터링 | 미채택 | D02·D04~D11 소유. 기준 없이 옮기면 "같은 버그" 와 "같은 답" 을 구분 못 한다(완료 조건 3) |
무결성 검사 함수(validate_user_exercise_stats_integrity_engine_v1)만 확장 | 미채택 | 그 함수는 관측을 같은 코드로 다시 계산해 대조한다 — 구현 복사 비교. 손 계산 oracle 과 불변식이 따로 필요 |
| 결정성 검사를 CI 에 넣기 | 미채택(지금) | 샌드박스 스택이 필요. PR 마다 로컬 결과 줄을 붙이는 규칙으로 보완, CI 편입은 D08/R 계열 판단 |
4. 적용한 내용
Phase 1 — DAG·작성자 장부 (a6aedf9e)
docs/contracts/stats-projection-dag.md: 유저 A 의 세트 수정 한 건이 지나는 길(§1) · worker 한 라운드의 10단계 입력·출력·잠금 표(§3) · 표별 현재 작성자 요약(§4) · 목표 DAG 6단계(관측 → 세션 합계 → 순차 파생 → 기간·달력 → 스냅샷 → 발행)·작성자 14·규칙 6·DTO 6(§5) · 화면 RPC 가 읽는 투영/원본 표(§6, A06 입력) · 세대(§7) · 확장·이전 절차(§8) · 동치 판정 정의(§9) · 후속 인계(§10).supabase/contracts/stats-projection-writers.json: 표 20개(투영 16·control 3·catalog 1)마다 단계·논리 키·비교 제외 열(사유 7종)·현재 작성자(함수·insert 열·update 열·delete)·목표 작성자.current는tests/db/support/projectionWriters.mjs가 schema.sql 을 정적 분석해 만든 것을 그대로 넣는다(손으로 고치지 않음).tests/db/statsProjectionWriters.test.mjs7건: ① 현재 작성자 = schema.sql(드리프트) ② 표마다 목표 작성자 하나 ③ 논리 키·제외 열이 실제 열 ④ 동적 SQL 0 ⑤refresh_*가 쓰는user_*표 전부 등재 ⑥ 단계 순서. CI 단위 잡에서 돈다.
Phase 2 — 정책·세대 계약 (7b420a5c)
docs/contracts/stats-projection-policy.md: 정책 9종의 id@version·정본·DB 고정·행에 새겨지는 곳·검사(§2, P-1~P-3) · 반올림 자리(§3) · null/0(§4) · 같은 날·동률(§5) · 세대 G-1~G-8 + control 불변식(§6) · 비교 제외 사유(§7) · 유효 무게·manual baseline·PR 근거(§8) · 불변식 16(§9) · 정책 문장 ↔ 근거 함수 ↔ 기존 테스트(pgTAP 25파일·Node 4파일)(§10).
Phase 3 — oracle 코퍼스 (47fbbd4f)
- 사례 14건 추가(002~015): 과거 세트 수정·세션 삭제·날짜 이동·종목 교체·복합(클린+저크)·체중(풀업)·덤벨 배수·날짜 있는 수동 PR·날짜 없는 baseline·같은 날 동률·반복 실패(3회/0회)·반복수만(푸쉬업)·출석 주/월·바벨 세트 목적. 기대값 120개 전부
hand:/table:. - README 에 입력 단계(
steps저장·수정·삭제·수동 PR)·복합parts·무게 없는 세트·metric 어휘 표(접두 9)·1/0·absent규칙.tests/react/statsOracleTools.test.mjs가 어휘·단계를 검사.
Phase 4 — 샌드박스 동치 검사 (f948f54c)
tests/db/statsProjectionDeterminism.test.mjs: ① 사례 15건을save_session_v5_engine/delete_session_v5_engine/수동 PR 삽입으로 넣고 worker 로 큐를 비운 뒤 투영 표에서 metric 을 읽어 대조 — 116/116 일치(정책 함수 값 2개는 G02 도구 테스트가 대조) ② 전체 재계산 전후 스냅샷 동일(D-1) ③ 불변식 12 위반 0 ④ G04 workloadhistory-1y(세션 260·투영 행 9,097) 적재 → 결정성 → 불변식 → 기준 요약(표별 행 수·sha256) 저장.history-4y(세션 1,043·투영 행 35,967) 도 같은 절차로 diff 0·불변식 0·기준 요약 저장(82초).- 기준 요약
tests/fixtures/stats-projection-baseline/{history-1y,history-4y}.json(기준 커밋884cc181) — 계산 결과가 바뀌면 검사가 알려 주고, 의도한 변경이면BARBELIC_WRITE_BASELINE=1로 다시 쓰며 PR 에 이유를 적는다.
주요 결정과 근거
- 장부의
current는 생성물이다 — 새 작성 함수가 생기면 검사 ①이 막고, 장부를 고치려면 생성기 출력을 붙여야 한다. 손으로 쓰면 드리프트가 다시 생긴다. - 기준 파일은 요약(sha256)만 저장 — 전체 행 JSON 은 1년 프로필만 수 MB. 요약이면 변화 여부를 잡고, 어디가 다른지는 로컬
BARBELIC_D01_SNAPSHOT_DIR로 두 스냅샷을 남겨 diff 한다. - oracle 적재는 metric 을 투영 표에서 직접 읽는다 — 화면 RPC 를 거치면 A06 의 변경이 D01 검사를 깨뜨린다. 투영의 값 자체가 계약이다.
- 세대 번호·시각·무작위 id 는 비교에서 뺀다 — 사유 7종을 장부에 적고 검사 ③이 열 존재·사유 정의를 확인한다.
작업 중 드러난 것
- e1RM 은 관측·일별·상태 표 모두 1kg 정수로 저장된다(policy.json
published_projection_kg). 계획 단계 문서에는 "numeric 그대로" 로 잘못 적었다가 oracle 적재 대조(3건 불일치: 111.28 → 111, 100.58 → 101, 104.59 → 105)로 잡아 정정 — 손 계산 oracle 이 첫 실행에서 문서 오류를 찾은 사례. user_exercise_records는 화면 함수가 하나도 읽지 않는 투영(Production 3,970행). 1단계가 종목 통계를 만들 때 중간 표로 읽고 5단계가 보정까지 하지만 세션 합계 표가 같은 값을 갖는다 → D02 폐기 판단.- 롤오버가 이미 발행된
applied_version으로 PR 스냅샷을 다시 쓴다 → 세대 계약 G-3 어긋남, D11 수리. - 세션 합계·기간·달력 등 6표에 세대·정책 버전 열이 없다 → "어느 정책으로 계산됐는가" 를 세대로만 판정(P-2), D11 판단.
- 관측 표에 3단계(상태)의 값이 되적히는 열 10개(
reference_e1rm_kg·set_intensity_percent·recent_performed_1rm_kg…) → 목표 규칙 3(한 방향) 위반, D02 가 열 위치를 정한다. - 카탈로그 한글 이름은
프론트 스쿼트(띄어쓰기) — oracle 사례가 이름으로 종목을 고르므로 정확히 적어야 한다(README 에 명시). - 저장 문은
request_hash가 소문자 SHA-256 이어야 한다(적재기가 처음에 uuid 로 보내 거부됨).
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
| 투영 표 작성자 장부 | 없음 | 표 20개 × 현재/목표 작성자·논리 키·제외 열, 드리프트 검사 7건(CI) |
| 표당 작성자 수(현재) | 세션 합계 6 · 기간 5 · 훈련 기간 4 · 종목 3 · 그 외 1~3 | 그대로(코드 미변경) — 목표 열은 표당 1, 검사 ②가 고정 |
| "같은 결과" 정의 | 없음 | 논리 키·제외 열·허용 오차·random_ref 치환·스냅샷 창 anchor(DAG §9) |
| 손 계산 oracle 사례 | 1(기대값 6) | 15(기대값 120, app: 0) — 샌드박스 대조 116/116 |
| 결정성 검사 | 없음 | oracle 소유자 15명 + workload 1년(투영 행 9,097) 전체 재계산 전후 diff 0 · 4년(세션 1,043·투영 행 35,967) diff 0 |
| 정책 불변식 | pgTAP 정책 단언(구현 복사 포함) | 구현 무관 불변식 12 SQL, 위반 0 |
| 옛 구현 결과 기준 | 없음 | 기준 요약 파일 2(1년·4년, 커밋 884cc181) |
| 정책 문장 ↔ 근거 매핑 | 흩어짐 | 정책 9종 표 + 근거 함수·기존 테스트 매핑 1표 |
| 세대 규칙 | 코드에만 | G-1~G-8 문장 + control 불변식(C-1 검사) |
미검증: 10년 프로필 결정성(opt-in, 약 5분 — 이번에 돌리지 않음). 증분 계산과의 비교는 증분 구현이 없으므로 절차만 있다(합격 기록 없음 — 이슈 완료 조건 그대로).
6. 이번 개선으로 향상된 것
- D02·D04~D11 이 통계 표를 옮길 때 "누가 마지막에 썼는가" 가 아니라 장부·검사가 판정한다 — 새 작성 함수는 장부 없이는 CI 를 통과하지 못한다.
- 통계 구조를 바꾼 뒤 "같은 답인가" 를 세 가지 독립 증거(손 계산 oracle·기준 요약·불변식)로 확인할 수 있다. 첫 실행에서 문서 오류(e1RM 반올림)를 잡은 것이 그 증거다.
- 정책 값이 어디에 고정돼 있고 어느 표에 새겨지는지, 세대가 무엇을 약속하는지가 한 문서에 있다 — 정책을 바꾸는 것과 구조를 바꾸는 것이 구분된다.
남은 것
- 후속 인계(이슈 #1286 댓글): D02(작성자 14·DTO·records 폐기·논리 키 unique) · D04(단계별 입력 표·부수 효과 제거·S-1) · D08(잠금 단위·lease) · D11(세대 열 없는 6표·롤오버 세대·전 표 발행 게이트) · A06(화면 읽기 표).
- 결정성 검사의 CI 편입 여부(샌드박스 필요) — HQ/R 계열 판단.