복합 세트 저장 형식 parts 단일화 — 옛 형식 scheme을 코드·서버에서 전부 걷어내기 (2026-09-03)
- 기간: 2026-09-03 (세션 1개, 오너 지시 "scheme은 조사해서 싹 걷어내고, parts로 완전히 이주할 수 있도록 해줘 … scheme 관련한 것들은 남겨두지 말고 싹 지워줘")
- 랜딩: PR #1192(Phase 1~3,
55940b0b) — 마이그레이션20260906235900_composite_parts_only_v1.sql(그룹 보드 검증 함수 재발행 +composite_meta.default_reps제거), 엣지 함수 없음, Vercel main 자동 배포. Production 적용은 다음 릴리스 PR(main → production)에서. - 설계서: 없음 — 조사·Phase 계획은 이슈 #1188 본문("예상 효과·개선사항" 절 포함)
- 정본:
src/react/ui/shared/compositeSemantics.ts(parts 값 모델·compositeSetPatch·compositeRepsPatch·한 줄 표기),src/react/services/compositeExerciseModel.ts(동작 행 → parts 복원),src/react/types/workout.ts(WorkoutSet.parts), 서버group_validated_board_exercises_v1(parts만 정식, 공유 무게 판정) - 도구: Production 실측 프로브·드라이런 스크립트(세션 스크래치패드, 레포 밖) — Management API 읽기 전용 조회 + raise 롤백 드라이런
- 게이트: pgTAP
composite_parts_only_v1(6), 마이그레이션 postcheck(parts 에코·scheme 키 폐기·default_reps 잔존 0), e2e CASE-020(parts 단언)·021·022, 단위 스위트 2,458(개정 17파일·삭제 0), dual-key 베이스라인 래칫 - 버그리포트: 없음(구조 정리 트랙 — 오너 보고 결함 아님)
- 계약:
docs/contracts/workout-screen-props.md(세트parts),docs/contracts/desktop-screens-props.md(복합 종목 절 — 동작별 횟수 칩 hookcompositeMovementReps)
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 조사(#953 반려) · Production 실측 · 오너 결정 D1-a | ✅ 이슈 #1188 댓글 |
| Phase 1 | 값 모델·복원·저장 정본 parts 단일화 (compositeSemantics·타입·복원·초안 정규화·계획/완료 전개·직렬화·계약 화이트리스트·RPC 화이트리스트) | ✅ PR #1192 (55940b0b) |
| Phase 2 | 모바일·데스크톱 화면(공유 무게 편집기·빠른 추가·채우기·보드 표시·스킴 칩 → 동작별 횟수 칩·세션 상세 공용 표기)·계약 문서 2건 | ✅ PR #1192 |
| Phase 3 | 그룹 보드 검증 함수 재발행(scheme 분기 삭제·공유 무게 판정)·default_reps 제거 마이그레이션·pgTAP·e2e CASE-020/021/022 | ✅ PR #1192 |
| Phase 4 | 랜딩(랜딩 잠금·verify·머지) · Production 적용은 다음 릴리스 | ✅ 머지 / ⬜ 릴리스 |
1. 배경
오너가 이슈 #953(구형 scheme 데이터를 신형 parts로 소급 변환)을 진행하라고 했다. 착수 조사에서 전제가 틀린 것이 드러났다. #947이 채택한 "표현 가능성 규칙"에서 scheme(동작별 횟수 숫자 목록)은 옛 형식이 아니라 횟수만 있는 복합의 정식 저장 형식이었다 — 유저 A가 "풀업 10회 + 딥스 10회"를 저장하면 지금 앱도 scheme: [10, 10]으로 저장했고, 시간·거리가 섞이거나 동작별 무게가 다를 때만 parts로 저장했다. 그러니 저장된 scheme을 parts로 바꿔 놓아도 다음 편집에서 scheme으로 되돌아가고, 이슈가 노린 "읽기 분기 제거"는 얻을 수 없었다. Production 실측도 변환 대상 0건(그룹 보드 5장 중 복합 0, 서버 체크포인트 1행 중 복합 0, composite_meta scheme 0)이었다.
오너는 #953을 반려하고 방향을 바꿨다: "장기적으로는 전부 parts 방식으로 이주하는 게 맞다 → scheme을 조사해서 싹 걷어내라". 이 트랙은 그 결정의 실행이다.
2. 문제 제기
한 가지 정보(복합 세트의 동작별 값)를 두 형식으로 적어 두면 읽는 코드마다 "둘 중 어느 형식인가" 분기가 영원히 남고, 새 기능(통계·표시·내보내기)을 만들 때마다 두 형식을 다 챙겨야 한다. 실측: 클라이언트 26개 파일이 scheme을 읽거나 썼고(값 모델 정본·초안 정규화 30곳·모바일 세트 편집기 21곳·데스크톱 스킴 칩), 서버는 그룹 보드 검증 함수 1개, 단위 테스트 17개 파일, e2e 3건, 계약 문서 2건이 scheme을 전제했다. 옛 기본 횟수 계보(동작 정의 composite[].reps·composite_meta.default_reps·legacyCompositeScheme)도 세 군데서 같은 값을 되살리고 있었다.
3. 해결 방안
- 원칙(오너 결정): D1-a 즉시 전부 제거 — 옛 형식 읽기 호환도, 로드 경계 변환 함수도 두지 않는다. 릴리스 직전에 복합 세트를 넣고 완료를 안 누른 기기 안 초안만 영향을 받을 수 있고, 완료 기록·보드·계획은 실측 0건이라 무관.
- 접근: 저장 형식을 "항상 parts"로 바꾸는 코드 트랙(소급 변환은 대상이 없어 불필요). 공유 무게 컴플렉스(바벨 컴플렉스)는 세트 무게 하나 + parts에는 동작별 횟수만 — %·lb 공유 무게 편집기 계약을 그대로 살린다(#947 때 "전량 parts 컷오버"를 기각한 이유였던 편집기 파손을 입력 경로 이전으로 해소).
| 대안 | 판단 |
|---|---|
| A. #953 그대로 소급 변환 | 대상 0건이고 소급해도 다음 편집이 scheme으로 되돌아가 목표 미달 — 반려 |
| B. 저장 규칙을 parts 단일로 바꾸고 scheme 코드 전면 제거 (채택) | 형식 하나 → 분기 소멸. 코드 트랙(클라 26파일·서버 함수 1·테스트 17·e2e 3·계약 2) |
| C. 이슈를 열어 두고 데이터가 쌓이면 소급 | 규칙상 scheme이 정식이라 쌓이는 것이 정상 — 대기해도 바뀌지 않음 |
| D1-b. 로드 경계 변환 함수 한 릴리스 유지 | 오너가 D1-a(즉시 전부 제거) 선택 |
4. 적용한 내용
Phase 1 값 모델·복원·저장 정본
compositeSemantics.ts: 표현 가능성 규칙 폐지 —compositeSetPatch(parts)(인자 하나)는 항상{ parts, reps: 횟수 합 }. 새 도우미compositeRepsPatch(횟수 목록 → parts)·compositeSetRepsOf·compositePartsReps·compositePartsAreRepsOnly. 한 줄 표기 "50kg × (3+1)" 규칙은 "전 동작 횟수만 + 세트 무게".- 타입:
WorkoutSet.scheme/repsParts·CompositeMovement.reps·CompletedSessionWriteSet.scheme삭제,parts추가.CompositePersistenceMeta.defaultReps·RPC 응답 화이트리스트의default_reps삭제. - 복원(
compositeExerciseModel): 세션·계획의 동작 행을 세트로 조립할 때 항상 parts. 순수 컴플렉스(전 행 횟수만·무게 동일)는 무게를 세트에 하나만, 이종 세트(시간·거리·칼로리 행 또는 횟수 없는 행)나 동작별 무게가 다르면 parts에 동작별 무게. - 저장: 초안 정규화·계획 전개·완료 전개 모두 parts 필수(없으면
…parts must contain exactly N movement value objects). 공유 무게 컴플렉스는 세트 무게·lb·%·기준 종목을 각 동작 행에 그대로. 반복 실패 세트의 동작별 배분(3+1 계획에 2회 수행 → [2, 0])은 옛 규칙을 parts 위에서 유지. - 프로필 미상 동작(빌더 보존 fields 없음 + 카탈로그 밖) 폴백: 공유 무게 컴플렉스면 상위 종목 기록 항목(무게×횟수), 그 밖은 횟수만 — 테스트 개정 중 발견한 "카탈로그 밖 동작의 세트 무게 소실"을 옛 경로대로 되돌림.
Phase 2 화면
- 모바일: 공유 무게 편집기·빠른 추가·아래로 채우기·"10+10" 표기·보드 컴포저/표시/왕복/프리필을 parts 기준으로. 보드 표시는 횟수만인 묶음이면 "10+10" + 공유 무게, 그 밖은 동작별 원자 토큰.
- 데스크톱:
dkpSchemeOf→dkpMovementRepsOf, 스킴 칩 → 동작별 횟수 칩(DkpMovementRepsChip, hookcompositeMovementReps, designContract 등록),dksSchemeArr→dksMovementReps, 세션 상세는 모바일과 같은 공용 한 줄 표기 함수. - 계약 문서 2건 parts 기준으로 정정.
Phase 3 서버·e2e
20260906235900_composite_parts_only_v1.sql: 그룹 보드 검증 함수 재발행 — scheme 분기 삭제(키가 와도 미지 키로 조용히 버림), 공유 무게 판정 보강(parts에 무게가 없고 세트에 load 키가 있으면 세트 무게를 모든 동작의 제공 원자로 봄 — 종전엔 이런 세트가 scheme 형식이라 동작별 판정을 거치지 않았음), 같은 날 먼저 랜딩한 #1175의 카탈로그 조회::uuid캐스트를 본문에 합성.session_exercises·planned_sets의composite_meta.default_reps제거(Production 2행). postcheck.- pgTAP
composite_parts_only_v1(6): 공유 무게 통과·명시 0 통과·무게 키 없음 거부·동작별 무게 에코·scheme 키 폐기·default_reps 잔존 0. - e2e CASE-020: 스냅샷 단언
parts [{reps:10},{reps:10}]+ scheme 키 부재. CASE-021/022 문서 문구.
주요 결정과 근거
- D1-a 즉시 전부 제거(오너): 코드에 scheme 흔적 0. 부작용은 기기 안 옛 초안뿐.
- 공유 무게는 세트에 하나: parts에 무게를 복제하지 않는다 — %·lb 편집기가 세트 load 하나를 읽는 계약을 유지하고, 서버 판정이 그 무게를 모든 동작에 적용.
- Production 소급 없음: 실측 0건. 랜딩 직전 재실측도 0건.
작업 중 드러난 것
- CI CASE-020 적발: 서버가 동작별 값마다 종목 필수 입력을 검사해 "백스쿼트+벤치 20kg × (10+10)" 보드 저장을 거부 → 위 공유 무게 판정 보강.
- CI CASE-022 적발: 복원 규칙을 "동작별 무게가 다를 때만"으로 좁혀 이종 세트 "60kg 5회 + 30초"의 무게가 빠짐 → 옛 규칙(이종이면 동작별 무게) 복원.
- 같은 함수 다중 트랙 재발행 되덮기 위험: #1175가 같은 날 먼저 랜딩해 보드 검증 함수를 재발행(uuid 캐스트) — Production 실측 본문에 그 변경을 합쳐 재합성.
- 리베이스 전
git stash를 다른 명령과 한 줄에 묶어 인덱스가 비는 사고(기록 있는 함정 재발). 커밋 전 발견, 인덱스만 재구축, 손실 없음. tabSwitchLatencyProbe.test.mjs1건은 main에서도 Windows 줄바꿈 원인으로 실패하는 기존 문제(이 트랙 무관).
5. 적용 결과
| 항목 | 전 → 후 |
|---|---|
| 클라이언트 scheme 참조 파일 | 26 → 0 (정본 파일 머리글의 폐지 이력 1줄만) |
| 서버 보드 검증 함수 scheme 분기 | 있음 → 없음(키는 미지 키로 폐기) |
| 옛 기본 횟수 예비 경로 | 3 → 0 |
Production composite_meta.default_reps | 2행 → 0 (드라이런 확인, 실제 적용은 릴리스) |
| 단위 스위트 | 2,457 → 2,458 (개정 17파일, 삭제 0) |
| pgTAP | +6 (composite_parts_only_v1) |
| CI | verify·migration-smoke 통과(3회째 — 1회차 CASE-020/022 적발, 2회차 pgTAP 픽스처 부위값) |
| 미검증 | Production 실제 적용 결과(다음 릴리스 뒤 프로브로 확인 예정), 기기 안 옛 초안 영향(실측 불가) |
6. 이번 개선으로 향상된 것
- 복합 세트의 동작별 값은 parts 하나다. 앞으로 통계·표시·내보내기가 한 형식만 다루면 된다.
- 옛 기본 횟수 계보(
composite[].reps·default_reps·legacyCompositeScheme)가 사라져 같은 값을 세 군데서 되살리던 예비 경로가 없다. - 서버 검증이 공유 무게 컴플렉스의 동작별 필수 입력을 세트 무게로 판정한다 — 형식 단일화로 새로 열린 검사 경로를 pgTAP이 고정한다.
- 사용자 화면·저장 결과는 그대로다("10 + 10", "60kg × (3+1)", "60kg 5회 + 30초").
남은 것
- 다음 릴리스 PR에서 Production 적용 → 프로브로
default_reps0·보드 저장 정상 확인 → 이슈 #1188 [반영완료]. tabSwitchLatencyProbe.test.mjs의 Windows 줄바꿈 실패는 별건.