Skip to content

반복수 투영 정본화 — 기록 유형에서 뺀 반복수가 통계·화면에 섞이지 않게 (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 #1303 58e9b047(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.md 5층 표(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 1stats_reps 열 + 투영 함수·트리거 확장 + 백필 + 등급 목록 + pgTAP 11단언b0e8bf5d
Phase 2통계 함수 14개 재발행(치환 60곳 중 51) + pgTAP reps_projection_stats_v14d078d37(리베이스 뒤)
Phase 3표시 함수 6개 재발행(9곳, 응답 키 유지 패치 3곳 + 보드 1곳) — 오너 D1 (a)✅ 같은 마이그레이션
Phase 4재발 방지 게이트·계약 문서·데이터 모델 문서·이 기록
Phase 5ci: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_partreps를 읽는 함수 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_loadsstats_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)가 깨진다 — 남은 bare reps 참조를 보고서로 뽑아 4곳을 손 패치하고 조립기가 정확히 1회 매칭을 단언.
  • Bash 히어독 안의 \\b·\n 이스케이프가 망가진다(메모리 함정 재확인) — 스크립트는 Write 도구로.

5. 적용 결과

항목확인
원본 reps를 읽는 통계·표시 함수200게이트 테스트·마이그레이션 ② 자가 검증
기록 유형 밖 반복수가 세션 합계·하루 요약·최대 반복수·추정 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:50Zci: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).