반복수 투영 정본화 — 기록 유형에서 뺀 반복수가 통계·화면에 섞이지 않게 (2026-09-07)
- 기간: 2026-09-07 (세션
dd9f7df4— 분석·Phase 계획·Phase 1~5) - 랜딩: PR #1294 (Phase 1~4 한 묶음) — 마이그레이션 2본
20260913000000_reps_stats_projection_v1(반복수 투영 열 + 트리거 확장 + 백필)·20260913000100_reps_stats_readers_v1(통계 14·표시 6 함수 재발행), Vercel 배포 없음(앱 코드 변경 없음). Production 적용 = 릴리스 v0.17.1 PR #130358e9b047(2026-09-07 21:36 KST 머지, Production Deploy run 34122817046 성공, 태그v0.17.1) — 프로브 2026-09-08 00:31 KST 이상 없음(§5) - 설계서: 이슈 #1256 분석·Phase 계획 댓글(2026-09-07), 오너 go + D1 (a)
- 정본:
docs/contracts/user-fact-immutability.md§2 R4(숨긴 값은 어떤 계산에도 섞이지 않는다 — 투영 열만 읽는다)·§5(잔여 위험 해소),docs/data/session-data-model.md5층 표(stats_reps) - 도구: 함수 조립기
build-p2.mjs·emit-p2.mjs(세션 scratchpad, 레포 밖 — schema.sql 마지막 정의에서 함수 20개를 통째로 뽑아exercise_set_part별칭의.reps → .stats_reps치환, 치환 개수·응답 키 유지 패치 4곳 단언),npm run ci:local -- --only db,npm run schema:snapshot -- --sandbox … - 게이트: pgTAP
user_fact_projection_rule_change_v1(17 → 28) + 신설reps_projection_stats_v1(20), 마이그레이션 자가 검증 2본(원본 반복수 행 수·합 전후 동일, 보이는 세트 투영 = 원본·숨은 세트 null, 함수마다stats_reps참조 수 = 조립기 단언·별칭.reps잔존 0), 계약 테스트tests/react/repsProjectionReadersGate.test.mjs(원본reps를 읽는 함수는 허용 목록만·통계·표시 20함수는stats_reps), 등급 목록 계약 테스트 - 버그리포트: 없음(오너 보고 결함이 아닌 #1236 잔여 위험의 구조 수리 — Production 실측 잘못 섞인 값 0)
- 계약: 등급 목록 v2
exercise_set_part.stats_reps: projection
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 반복수를 읽는 서버 함수 전수 조사(28 = 통계 14·표시 8·쓰기/검증 6)·Production 실측·Phase 계획 게시·오너 go(D1 (a)) | ✅ 이슈 댓글 |
| Phase 1 | stats_reps 열 + 투영 함수·트리거 확장 + 백필 + 등급 목록 + pgTAP 11단언 | ✅ b0e8bf5d |
| Phase 2 | 통계 함수 14개 재발행(치환 60곳 중 51) + pgTAP reps_projection_stats_v1 | ✅ 4d078d37(리베이스 뒤) |
| Phase 3 | 표시 함수 6개 재발행(9곳, 응답 키 유지 패치 3곳 + 보드 1곳) — 오너 D1 (a) | ✅ 같은 마이그레이션 |
| Phase 4 | 재발 방지 게이트·계약 문서·데이터 모델 문서·이 기록 | ✅ |
| Phase 5 | ci:local --full → Production 유실 감사·사본 드라이런 → 랜딩 잠금·renumber → PR·CI 1회 → 릴리스 → 프로브 | ✅ PR #1294 57fd2bb4 → 릴리스 v0.17.1 58e9b047 → Production 프로브 이상 없음(2026-09-08 00:31 KST) |
1. 배경
유저가 기록한 값은 원본, 앱이 계산한 것은 투영이라는 원칙(#1236)에 따라, 세부 종목의 기록 유형이 바뀌어도 세트 값은 지워지지 않고 남는다(숨김이지 삭제가 아니다). 무게 계열은 #1236 Phase 2에서 통계용 투영 열(stats_load_kg·stats_effective_load_kg)을 기록 유형을 아는 트리거가 채우게 해, 숨은 무게가 통계에 섞이지 않게 했다. 반복수는 그때 뒤로 미뤘고(Phase 2-2 → 이 이슈), 계약 §5에 잔여 위험으로 남겨 두었다.
2. 문제 제기
유저 A가 로잉 세트에 "20회"를 적어 두었다가 그 종목의 기록 유형을 "거리·시간만"으로 바꾸면, 세션 상세 화면은 기록 유형을 보고 20회를 숨기지만, 주간 반복수 합계·최대 반복수 기록·추정 1RM·세트 스코어·통계 무결성 검사는 원본 reps 열을 직접 읽어 그 20회를 계산에 섞는다. 피드 카드·PR 히스토리·일지 표 칸 상세·그룹 보드도 원본을 그대로 보여 준다.
전수 조사(schema.sql 마지막 정의 기준, exercise_set_part의 reps를 읽는 함수 28개): 통계·추정·검사 14 · 표시 8(자체 필터 3, 거르지 않음 5) · 쓰기·검증·인입 6.
Production 실측(2026-09-07): 세트 값 행 16,704 · 반복수 있는 행 15,425 · 기록 유형에 반복수가 없는 세부 종목 1,264(거리+시간 1,016 · 시간 60 · 무게+시간 4 · 거리 2 · 빈 유형 181)와 그 아래 세트 1,279행 중 반복수 값이 남은 행 0 — #1236 전까지 트리거가 지워 왔기 때문. 지금 틀린 숫자는 없고, 앞으로 값이 남을 때 섞이는 구조를 고치는 작업이다.
3. 해결 방안
원칙 = 오너 결정: go(2026-09-07) · D1 (a) 표시 함수 6개도 같은 랜딩에서 투영으로(§22 근본 구조 개선 — "계산엔 안 섞이는데 피드엔 보인다"는 어긋남을 남기지 않는다).
| 안 | 내용 | 판정 |
|---|---|---|
| A. 반복수 투영 열(채택) | exercise_set_part.stats_reps(투영 등급)를 기존 투영 함수가 기록 유형을 보고 채우고, 통계·표시 함수는 reps 대신 stats_reps를 읽는다. 규칙이 한 곳(투영 함수)에만 산다 | 채택 |
| B. 함수마다 기록 유형 조건 추가 | 20곳에 같은 조건 복제 | 새 함수가 생기면 또 빠진다(#1236이 무게에서 버린 방식) |
| C. 방치 | 지금 잘못 섞인 값 0 | 값이 남는 순간부터 통계가 틀린다 |
설계 세부: stats_reps integer는 숨김이면 null(무게 투영은 0). 통계 함수는 이미 "반복수 없음"을 is null·> 0로 판단하므로 함수 교체가 이름 바꾸기로 끝나고, 0으로 두면 세트 스코어 미리보기의 "무게만 기록한 실패 세트(reps = 0)" 판정과 충돌한다. 기록 유형이 비어 있으면(자유 기록·옛 데이터) 전부 보인다. 기록 유형 변경 시 재투영은 기존 경로(refresh_session_effective_loads가 stats_load_kg = stats_load_kg로 트리거 재발화) 그대로 — 투영 함수 하나가 열 4개를 모두 채운다.
4. 적용한 내용
Phase 1 — 투영 열 (20260913000000_reps_stats_projection_v1)
alter table … add column stats_reps integer+ 투영 함수project_exercise_set_part_values_v1에 한 줄(new.stats_reps := case when 기록 유형 비었거나 'reps' 포함 then new.reps else null end), 트리거 감시 열에reps추가.- 백필 =
update … set stats_load_kg = stats_load_kg(투영 열만,-- user-fact: projection-only). 자가 검증: 원본 반복수 행 수·합·전체 행 수 전후 동일, 보이는 세트stats_reps = reps, 숨은 세트 null, 트리거가reps를 감시. - 등급 목록
stats_reps: projection— 보호 트리거 생성기 출력은 투영 열을 포함하지 않아 보호 마이그레이션 재발행 없음(계약 테스트가 판정).
Phase 2·3 — 함수 20개 재발행 (20260913000100_reps_stats_readers_v1)
- 본문은 schema.sql(실제 상태 덤프) 마지막 정의를 기계로 뽑아
exercise_set_part별칭(exercise_set·set_part·sp·source_set…)의.reps만.stats_reps로 치환(60곳). 파생 테이블·응답 키 이름을 지키는 자리 4곳은 손 패치 — 달력 탑세트·피드 카드 v2·일지 표 칸 상세는stats_reps as reps(응답 키reps유지), 그룹 보드는first_set_part.stats_reps. - 관측 표의
raw_reps·기록 표의reps같은 투영 표 사본도stats_reps에서 복사되고, 무결성 검사 2개도 같은 값을 대조한다. - 통계 재계산은 하지 않았다 — Production 실측 기록 유형 밖 반복수 행 0이라 값이 바뀌는 사용자가 없다.
- 자가 검증: 함수마다
stats_reps참조 수 = 조립기 단언, 별칭.reps잔존 0.
Phase 4 — 게이트·문서
tests/react/repsProjectionReadersGate.test.mjs: schema.sql 함수 본문에서exercise_set_part를 읽으며 별칭.reps를 참조하는 함수는 허용 목록(보호 컬럼 목록 문자열·자체 필터 읽기 3개)만, 통계·표시 20함수는stats_reps참조.- 계약 §2 R4에 "숨긴 값은 어떤 계산에도 섞이지 않는다 — 투영 열만 읽는다" 추가, §5 잔여 위험 문장 해소. 데이터 모델 5층 표에
stats_reps.
작업 중 드러난 것
- 자가 검증 정규식의
\b는 Postgres에서 단어 경계가 아니라 백스페이스 —\y로 수정. 샌드박스가 PR 전에 잡았다. - 기존 pgTAP
atomic_recording_integrity_repair가 무결성 검사 함수 본문 문자열exercise_set.reps is not null을 앵커로 잡고 있어stats_reps로 재조준(감사 신고). - 조립기의 별칭 치환만으로는 파생 테이블 열 이름이 바뀌어 응답 키(
reps)와 바깥 참조(top_set.reps·ranked.reps·page.reps)가 깨진다 — 남은 barereps참조를 보고서로 뽑아 4곳을 손 패치하고 조립기가 정확히 1회 매칭을 단언. - Bash 히어독 안의
\\b·\n이스케이프가 망가진다(메모리 함정 재확인) — 스크립트는 Write 도구로.
5. 적용 결과
| 항목 | 전 | 후 | 확인 |
|---|---|---|---|
원본 reps를 읽는 통계·표시 함수 | 20 | 0 | 게이트 테스트·마이그레이션 ② 자가 검증 |
| 기록 유형 밖 반복수가 세션 합계·하루 요약·최대 반복수·추정 1RM 세트 반복수·피드 카드·일지 표에 반영 | 전부 반영 | 0건(로잉 20회 픽스처) | pgTAP reps_projection_stats_v1 20단언 |
| 규칙 변경 왕복 | — | 반복수를 넣으면 25, 되돌리면 5, 원본 20 그대로 | 같은 pgTAP |
| 무결성 검사 켠 통계 작업 | — | 정상 종료(failed_count 0) | 같은 pgTAP |
| 원본 무변경 | — | 백필 자가 검증 행 수·합 동일 · 유실 감사 파괴 0건·삭제 0행·기본값 정리 0건 (Production 2026-09-06T15:42Z, 되돌림) | 마이그레이션 ①·db:loss-audit |
| 로컬 게이트 | — | 검증: ci:local full · verify 통과(5단계) · db reset(마이그레이션 전체 적용) 통과 · schema.sql 스냅샷 --check 통과 · pgTAP 통과 109파일/1899 assert · e2e-local 통과 11/11 · e2e-empty 통과 7/7 · e2e-cardio 통과 6/6 · e2e-browser 통과 37/37 · e2e-viewport 통과 14/14 (2026-09-07 KST: full 11분 51초 → 스냅샷 재생성 뒤 db 단계 2분 28초 · 스펙 날짜 의존 수리 뒤 viewport 2분 7초 재실행) · preflight 109파일·1899 assert @ 2026-09-06T16:08:50Z | ci:local |
| Production 프로브(릴리스 뒤) | — | 마이그레이션 3본 장부 등재(20260913000000·20260913000100·20260913010100) · 세트 16,726 · stats_reps 채움 15,446 = 원본 reps 채움 15,446(투영 누락 0·값 불일치 0) · 반복수 없는 기록 유형 세트 1,280 전부 null(위반 0, 원본 반복수 잔존 0) · 투영 트리거 감시 열에 reps 포함 · stats_reps를 읽는 함수 21 · exercise_set_part 별칭으로 원본 .reps를 읽는 함수 0(남은 .reps 9곳은 전부 파생 테이블·인입 임시 표·통계 표 참조) · 통계 표 user_exercise_stats updated_at 갱신 397행 = 배포 직후 smoke 계정 2행 + 15:01Z 정시 backfill 스캔 cron 395행(2계정), 릴리스 뒤 새 세트 0건이라 이 트랙이 값을 바꿀 입력이 없음(값 변동 없음은 이 근거의 추론) | 읽기 전용 프로브 2026-09-08 00:31 KST(릴리스 2시간 55분 뒤), 세션 97917578 scratchpad probe-1256-post*.mjs |
미검증: 없음 — Production 실제 적용은 위 프로브로 확인.
6. 이번 개선으로 향상된 것
- 기록 유형에서 뺀 값은 무게든 반복수든 어떤 계산·화면에도 섞이지 않는다 — 규칙이 투영 함수 한 곳에만 산다.
- 새 통계·표시 함수는
stats_reps만 읽으면 되고, 원본을 읽으면 게이트가 PR에서 잡는다. - 구조적으로 남는 것: 투영 열
stats_reps, 계약 R4 문장, 허용 목록 게이트, "schema.sql 마지막 정의 → 별칭 치환 → 남은 참조 보고 → 손 패치 단언" 재발행 절차.
남은 것
- 없음 — 릴리스 v0.17.1 반영·프로브 확인 뒤 이슈
[v0.17.1 반영완료]로 닫음(2026-09-08).