운동 기록 계층 리모델링 — 클린+저크 5세트가 하루 10세트로 부풀던 것에서 다섯 층(세션 → 종목 → 세부 종목 → 세트 → 세부 세트) 한 구조·저장 함수 한 쌍까지 (2026-09-04)
- 기간: 2026-09-03 ~ 2026-09-04 (세션 2개 —
88dc5b1bPhase 1~7·Phase 8 준비,7d93c5c5Phase 8 랜딩(2026-09-04 인수인계); Phase 1~8 한 브랜치feat/1215-session-hierarchy; 오너 결정 D1~D13 확정 2026-09-04, 오너 지시 "질문 없이 phase 끝까지 완수") - 랜딩: PR #1248 (Phase 1~7 한 묶음, squash
c7b11d98) — 마이그레이션 8본20260910013000_session_hierarchy_rename_v1~20260910083000_retire_composite_meta_and_old_plan_tables_v1(이름 바꾸기 → 층 신설·채움 → 보호 장치·계획/보드 흡수 → 쓰기 계약 v5 → 그룹 계획 → 계획 읽기 → 세는 규칙·읽기 → 메모 열·옛 테이블 삭제) · 앱(모바일·데스크톱 공용 저장소·매퍼·컨트롤러) · Production 적용 = 릴리스 레인(main → productionPR #1227) - 설계서: 없음(분석·Phase 계획·"예상 효과·개선사항" = 이슈 #1215 본문 + Phase별 완료 댓글 2026-09-03~04)
- 정본: 데이터 모델
docs/data/session-data-model.md· 쓰기 계약 v5docs/data/app-screen-rpc-contract.md"Session Write Contract v5" · 앱 변환 한 곳src/react/services/sessionWriteContractV5.ts· 서버save_session_v5/delete_session_v5(+_engine,write_session_children_v5,completed_payload_v4_to_v5,plan_payload_v3_to_v5) · 세는 규칙session_set_totals_v1·set_score_observations_v1· 읽기 뷰completed_session_v1·planned_session_v1· 복합 합성session_part_composite_meta_v1 - 도구: 함수 원문 추출·치환 빌더(세션 scratchpad
assemble5.py·assemble6.py+ 템플릿, 레포 밖) · e2e 실패 분석 = Playwrighttrace.zip의*.network에서 RPC 요청/응답 본문 추출(scratchpad 스크립트) - 게이트: pgTAP
session_write_contract_v5.test.sql(45) ·session_set_count_rule_v1.test.sql(26) ·session_hierarchy_layers_v1.test.sql(26) ·session_hierarchy_guards_and_plans_v1.test.sql(24) + 기존 파일 픽스처 이전 · e2eCASE-033(복합 수정·세트 삭제·하루 세트 수)CASE-034(계획 일부 완료 = 같은 행 전이)CASE-035(보드 → 회원 시작 → 계보)CASE-036(구 계약 LG426) + 기존 9케이스 이전 · 단위sessionWriteContractV5.test.mjs·userErrorPolicy(LG426)·workoutFlowPayloadSerializer(일찍 끝낸 종목) · 로컬 pgTAP 통과 기록99파일·1,648 assert @ 2026-09-04 04:16Z (HEAD9b41e5d3, 재번호 뒤 순서로 전체 적용)· 검증 한 줄검증: ci:local full · verify 통과 (5단계) · db reset(마이그레이션 전체 적용) 통과 · pgTAP 통과 99파일/1640 assert · e2e-local 통과 11/11 · e2e-empty 통과 7/7 · e2e-cardio 통과 6/6 · e2e-browser 통과 35/35 · e2e-viewport 통과 13/13 · 11분 38초 - 버그리포트: 없음(오너 보고 결함이 아닌 구조 트랙; 작업 중 드러난 옛 결함은 §4 "작업 중 드러난 것")
- 계약:
docs/data/app-screen-rpc-contract.md(+ko) 계획 상세·쓰기 계약 v5·클라이언트 대기열 절 ·docs/data/session-data-model.md신설 ·docs/architecture/session-hierarchy.md개정 ·docs/data/rpc-catalog.md(v5 행·폐기 표시) ·docs/data/workout-write-path.md·brid.md·rpe-performed-intensity.md·import-pipeline.md·limits-registry.md·stats-refresh-*·process/deployment-pipeline.md·contracts/*·architecture/*·product/prd.md·policies/exercise-model/README.md옛 이름 치환
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 테이블 이름 바꾸기(sessions→session, session_exercises→session_exercise_part, exercise_sets→exercise_set_part) + 이름을 본문에 적은 함수 재발행, 앱 문자열 치환 | ✅ PR #1248 (c1f6e73f 누적) |
| Phase 2 | 층 신설(session_exercise, exercise_set) + 소속 열 + 기존 행 채움(복합 메모 → 종목 묶음) | ✅ (ffdb8288) |
| Phase 3 | 보호 장치(뷰 completed_session_v1·planned_session_v1, 통계 함수가 뷰만 읽음) + 계획·보드를 session 행으로 흡수 | ✅ (688f9b8a) |
| Phase 4 | 쓰기 계약 v5 — save_session_v5/delete_session_v5 한 쌍, 영수증 v2, 앱 변환 한 곳, 옛 문 8개 = LG426 스텁 | ✅ (7a6b06f6) |
| Phase 5 | 읽기·집계를 다섯 층 기준으로 — 세트 수 = 세트 행 수, 계획 카드 = session 행, 복합 판정 = 세부 종목 수 | ✅ (a4445286) |
| Phase 6 | composite_meta 열·옛 테이블 8개 삭제, 층 소속 NOT NULL, 인입용 자동 층 트리거, 계획 반영 앱 코드 제거 | ✅ (02bad2a9) |
| Phase 7 | e2e 9개 이전 + 새 4개, 데이터 모델 문서 신설, 문서 30여 개 옛 이름 치환 | ✅ (0efb2577) |
| Phase 8 | main 합치기(#1234·#1240·#1242) → ci:local → 랜딩 잠금 → renumber(#1249 꼬리 뒤 20260910013000~083000) → PR #1248 → CI 3회(아래) → verify → squash 머지 → 릴리스 PR #1227 → 기록·프로브 | ✅ 이 문서 (Production 프로브는 릴리스 뒤) |
1. 배경
바벨릭의 운동 기록은 "세션 → 종목 → 세트" 세 층이었고, 복합 종목(클린 + 저크처럼 동작 여럿을 한 세트로 하는 종목)은 동작마다 종목 행과 세트 행을 따로 두고 JSON 메모(composite_meta)로 "이 행들은 한 묶음"이라고 표시하는 방식이었다. 계획은 다른 테이블(planned_sessions·planned_sets), 그룹 보드는 또 다른 테이블(group_boards, 종목·세트는 JSON 열)이었고, 저장 함수도 완료 v4 3종·계획 v3 2종·보드 1종으로 갈려 있었다.
이 구조에서 세트 수를 어떻게 세느냐가 화면마다 달랐다. 2026-09-02 실측(오너 계정): 클린+저크 5세트를 한 날의 합계는 "10세트"(동작별 행을 그대로 셈), 종목별 세트 수는 5, 세트 스코어는 동작 행마다 하나씩(#1189에서 세트 단위로 임시 봉합). 이슈 #1189·#1188·#1180이 같은 뿌리를 각각 땜질하고 있었고, 오너가 2026-09-03 "운동 기록 계층 자체를 다시 세우자"고 결정했다(#1215).
2. 문제 제기
세트 수가 화면마다 다르다
- 하루·주·월 합계는 동작 행 수(클린+저크 5세트 → 10), 종목 통계는 5, 카드는 또 다른 값. 원인 = "세트"라는 행이 없고 동작 행만 있어 함수마다 임의로 묶었다.
같은 운동이 세 가지 모양으로 저장된다
- 완료 기록·계획·그룹 보드가 테이블도 저장 함수도 달라 변환 코드가 3벌. 계획을 완료로 바꾸면 행을 새로 만들고 연결 테이블로 이어야 했다.
복합 여부가 JSON 메모에 산다
composite_meta가 손상되면 묶음이 풀리고, 인입(WodUp)과 앱이 메모를 다르게 채웠다.
테이블 이름을 읽으면 층이 안 보인다
session_exercises행이 실제로는 "동작(세부 종목)"이었고,exercise_sets행은 "동작의 세트 값(세부 세트)"이었다.
3. 해결 방안
원칙 (오너 결정 D1~D13, 2026-09-04 확정)
- D1 종목별 세트 수는 세부 종목마다(클린 5, 저크 5) · D2 합계 세트 수 = 세트 행 수 · D3 횟수·볼륨은 세부 종목마다 · D4 계획도 같은 구조(세션 한 묶음) · D5 id = 서버 발급 무작위 uuid, BRID 없음 · D6 세트 스코어는 세트 행으로, 종목 필터 대칭 · D7 종목 테이블 포함 · D8 랜딩 1회 · D9 보드 = 세션 테이블 · D10 구 저장 함수 즉시 폐기 + 앱 업데이트 안내 · D11 진행 중 운동은 앱이 들고 있음(서버 행 없음) · D12 보드 좋아요·댓글을 세션 것으로 합침 · D13 계획 완료 = 같은 행 전이, 안 한 세트·종목은 삭제.
접근
| 대안 | 판단 |
|---|---|
메모(composite_meta)를 고쳐 쓰고 세는 함수만 통일 | 기각 — 정본이 둘(행 + 메모)인 상태가 이어지고 계획·보드 분리는 그대로 |
| 종류별 테이블은 두고 세트 층만 추가 | 기각 — 변환 코드 3벌·저장 함수 6종이 남는다 |
다섯 층 한 구조 + 세션 종류는 열(status, group_id) + 저장 함수 한 쌍, 브랜치 하나에 Phase 1~7 누적, 랜딩 1회(D8) | 채택 |
| 옛 저장 함수를 한동안 호환 유지 | 기각(D10) — 스텁이 SQLSTATE LG426으로 "앱 업데이트 필요"만 돌려주고 아무것도 쓰지 않는다 |
4. 적용한 내용
Phase 1 — 이름 바꾸기
테이블 3개 이름 변경 + 이름을 본문에 적은 함수 재발행(원문은 main 재생 샌드박스에서 추출) + 앱·테스트·e2e 문자열 치환. 함정: 외래키를 drop+create 하면 RI 트리거 순서가 바뀌므로 rename만.
Phase 2 — 층 신설·채움
session_exercise·exercise_set 신설, session_exercise_part.session_exercise_id·exercise_set_part.exercise_set_id 추가, 기존 행을 복합 메모 group_key 기준으로 묶어 채움(backfill_session_hierarchy_v1).
Phase 3 — 보호 장치 + 계획·보드 흡수
뷰 completed_session_v1·planned_session_v1, 통계·달력·피드 함수가 session을 직접 읽지 않는다는 자가 검증(pgTAP 가드). 계획·보드 행을 session(status planned, group_id)으로 이전, 계획 상세·그룹 보드 JSON을 층에서 조립.
Phase 4 — 쓰기 계약 v5
save_session_v5(p_payload, p_client_mutation_id, p_request_hash)·delete_session_v5(p_session_id, p_expected_revision, …). payload는 층 그대로(exercises[] → parts[] → sets[] → parts[]), 영수증 v2(exercises[] id 목록, status, set_scores, 계획은 stats_requested_version 0). 앱은 저장소 경계 한 곳(sessionWriteContractV5.ts)에서만 변환하고 영수증을 옛 children 모양으로 평탄화. 옛 문 8개는 LG426 스텁.
Phase 5 — 읽기·집계 전환
세트 수 = exercise_set 행 수(session_set_totals_v1), 세트 스코어 관측은 exercise_set_id로 묶고 종목 필터 대칭(exercise_ids && …), 복합 판정 = 세부 종목 수, 옛 모양이 필요한 곳은 session_part_composite_meta_v1로 합성. 계획 카드·홈·달력·피드가 session 행에서 나온다.
Phase 6 — 메모·옛 테이블 제거
composite_meta 열 삭제, 옛 테이블 8개 삭제(정책·트리거·외래키 함께), 층 소속 NOT NULL, 인입(WodUp·Motra)이 세부 종목·세부 세트만 넣어도 층이 서도록 BEFORE INSERT 트리거 2개(zz_attach_session_exercise_layer_v1·zz_attach_exercise_set_layer_v1), 계획 반영(adoption) 앱 코드 제거, 읽기 전용 계획 삭제는 안내 문구.
Phase 7 — e2e·문서
기존 e2e 9개를 새 테이블·v5 이름으로, 새 케이스 4개(CASE-033~036), LG426 → "앱 업데이트가 필요해요…"(영구 실패, 재시도 큐 제외), 데이터 모델 정본 문서 신설, 문서 30여 개 옛 이름 치환.
주요 결정과 그 근거
- 세션 정체(
source_ref)는 전이에도 바꾸지 않는다. 계획→완료 전이에서 앱이 보낸 새 source_ref를 행에 채택하려던 엔진 설계는 정체 불변 트리거에 막혔다. 앱이 계획의 source_ref로 전이 저장을 보내고 영수증과 대조하는 쪽으로 확정(엔진은source_ref = s.source_ref). - 분류 세트 합 ≤ 총 세트 수 불변식 폐기. 볼륨 리포트 어댑터의 이 검사는 D1/D2에서 성립하지 않는다(클린+저크 한 세트 → 두 분류에 각각 1). 두 축의 동치만 검사.
- 공유 무게 컴플렉스의 보드 표기. 모든 동작 무게가 같으면 동작별 load를 빼고 세트 reps는 동작 합("20kg 10+10x1").
- 새 층 테이블의 service_role 권한. 나머지 세 층과 같이 SELECT만(pgTAP 단언도 그렇게).
- 미룬 것: 앱
compositeMeta펴기/합치기 잔여(17파일·80참조, #1244)와exercise_set_part.session_exercise_id열 이름(실제로는 세부 종목 id, #1245) 교체 → 후속 이슈.
작업 중 드러난 것
- 계획을 일부만 하고 종료하면 안 한 세트도 완료로 저장되던 옛 결함. 팝업은 "완료하지 않은 세트는 기록되지 않아요"라고 말하지만, 저장 직전 정규화가 "종목이 끝났으면 값 있는 세트는 완료"로 승격시켰다(값이 미리 채워진 계획·보드에서만 드러남). 저장 직렬화에서 일찍 끝낸 종목은 체크한 세트만 실도록 고쳤다(단위 테스트).
- e2e 레지스트리 게이트 형식. USER-JOURNEY 필수 헤딩 4개·사용자 상태 라벨 5개·
| Step 01 |4열 표(행 수 ≥ route 항목 수)·intendedOutcome 원문, README## 1.~## 5.헤딩 + 체크포인트 문장 백틱 원문, 같은 이름의test.step이 둘이면 첫 것만 검사. - main(#1199)이 CASE-032 번호를 먼저 차지 → 새 케이스 033~036. 머지 전까지 게이트가 번호 공백으로 빨간 것은 예상된 상태.
- Docker Desktop 재기동은 소켓 폴더 rename을 먼저(오너 지적) — 실패를 보고 나서 고치지 말고 기동 전에
%LOCALAPPDATA%\Docker\run·docker-secrets-engine을 치운다. - Playwright 산출물(
playwright-report/)이 UTF-8 게이트에 걸린다 — 게이트 전 삭제. - pgTAP 전 파일은 샌드박스 잔존 사용자에 민감(법적 동의 수 전역 카운트) — e2e 실패로 남은 계정을 지우고 돌린다.
- Postgres 이미지 핀(#1233) 직후의 샌드박스 재기동. main을 합친 뒤
ci:local을 다시 돌리자 이 워크트리의 샌드박스 스택이 핀 이전 이미지(17.6.1.158)로 떠 있어 스크립트가 종료 코드 3으로 멈췄다(안내 문구대로--stop뒤 재실행, 핀 이미지 17.6.1.127로 새로 떠서 전부 통과). 핀 도입 직후 살아 있던 스택에서 한 번은 겪는 예상된 상황. - PR을 연 뒤 main이 두 번 움직여 CI가 3회 돌았다(모두 이 브랜치 결함 아님). ① CI 1차 verify 빨간불 = #1236(PR #1243)의 컬럼 등급 계약이 #1215 열을 '아직 없음(pending)'으로 선언 → 합쳐지자 '지우라'고 실패. 로컬은 main을 합치기 전이라 재현 불가. 선언 제거(
d002ad68). ② CI 2차 녹색 뒤 #1241(PR #1249)이 잠금을 잡고 먼저 랜딩(20260910003000) → 재번호 + 겹치는 함수 2개(refresh_user_max_rep_projection·workout_set_score_preview_v1)의 이 브랜치 재발행본에 #1241 상한 규칙을 옮겨 넣음(뒤에 적용되는 묶음이 덮어쓰지 않도록, 원문 대조로 차이 = 이름 변경 줄뿐 확인) + #1249 pgTAP 픽스처의 옛 테이블 이름 수정 → CI 3차. 같은 함수를 여러 트랙이 재발행할 때의 되덮기 함정은 절차(docs/process/migration-landing.md0단계)에 이미 있으나, 잠금 밖에서 준비하는 동안 다른 트랙이 랜딩하면 그 대조를 다시 해야 한다. - ci:local이 잡은 것(PR 전, 4회 실행): ① 층 마이그레이션 자가 검증이 "service_role 닫힘"을 요구해 새 grant와 충돌 → 검증문 갱신. ② v5 검증기가 반복 실패(rep_failure) 세트의 0회를 거부(옛 v4 계약은 허용, CASE-005) → 허용. ③ 노트(자유 기록) 종목은 parts 를 보내지 않아 수정 저장 때 세부 종목 번호표가 새로 발급됨(하네스 "연속 수정") → 앱이 번호표를 종목 id 자리에 실고 서버가 소속 종목으로 해석. ④ 하네스 왕복 테스트의 옛 영수증 종류 이름·계획 updated_at 충돌 검사 → v5(개정번호). ⑤ CASE-030 체중 스냅샷이 시각·시간대에 따라 75/80으로 갈리는 시간 의존 결함(프로필 최신 체중 = coalesce(measured_at, date) 내림차순, 온보딩 행은 measured_at 있음·테스트 행은 없음) → 테스트가 측정 시각을 명시.
get_profile_workspace의 정렬 규칙 자체는 후속 검토 대상.
5. 적용 결과
| 항목 | 전 → 후 | 확인 |
|---|---|---|
| 운동 관련 테이블 | 14(완료 3 + 계획 2 + 보드 4 + 반영 1 + …) → 5(+ 좋아요·댓글·영수증·체크포인트) | 마이그레이션 자가 검증 |
| 저장 함수 | 6종(완료 v4 3·계획 v3 2·보드 1) → 1쌍(save_session_v5/delete_session_v5); 옛 문 8개 = LG426 스텁 | pgTAP v5 45 assert, e2e CASE-036 |
| 세는 규칙 | 함수마다 3가지 → 1가지(세트 수 = 세트 행, 종목별 = 세부 세트, 횟수·볼륨 = 세부 종목) | pgTAP 세는 규칙 26 assert, e2e CASE-033 (3세트 = 하루 3) |
| 복합 여부 | JSON 메모 → 관계(종목 층) | composite_meta 열 0, 합성 함수 |
| 계획 완료 | 새 행 + 연결 테이블 → 같은 행 전이, 안 한 세트 삭제 | e2e CASE-034(id 동일·세트 1·카드 소멸·하루 1) |
| 보드 | 별도 테이블 → session(group_id) 행, 계보 origin_kind/origin_ref | e2e CASE-035, pgTAP groups 3파일 |
| 로컬 게이트 | npm run check 통과, check:unused 통과, pgTAP 전 파일 통과(샌드박스), e2e 36케이스 중 이 트랙이 만진 13 + 파이프라인 3 통과 | 이 세션 |
| Production 프로브(릴리스 v0.17.0, 2026-09-07) | 오너 9/2 하루 메인 세트 32 → 27(워밍업 6은 별도) · exercise_set 행 15,878 · 옛 테이블 10개 이름 전부 부재 · composite_meta 열 0 · session 상태별 completed 2,018 / planned 7(그룹 5) / missed 1 · LG426 오류 이벤트 0 | 읽기 전용 프로브 |
미검증: 실기기 화면(시각 품질), iOS 네이티브 빌드. 자동 검증만으로 닫는다(전역 지침 §14).
6. 이번 개선으로 향상된 것
클린+저크 5세트는 어디서 봐도 5세트
하루·주·월 합계, 리포트, 카드, 세트 스코어가 같은 세트 행을 센다. 클린 5회 + 저크 5회는 10회, 세트는 5.
완료·계획·보드가 한 구조
저장 함수 한 쌍, 변환 코드 한 곳. 계획으로 시작한 운동은 같은 행이 완료로 바뀌고 안 한 세트는 사라진다. 보드로 시작한 운동은 계보가 남아 그룹 화면 "그날 완료 멤버"에 보인다.
테이블 이름을 읽으면 층이 보인다
session → session_exercise → session_exercise_part → exercise_set → exercise_set_part(단수). 정본 문서 docs/data/session-data-model.md.
구조적으로 남는 것
- 계약: 쓰기 계약 v5(payload·영수증 v2), 데이터 모델 문서, 세는 규칙.
- 게이트: pgTAP 4파일(가드: 통계 함수는 뷰만 읽는다·세는 규칙·층·계약), e2e 4케이스, 단위(직렬화·계약·문구).
- 절차: 옛 저장 함수 = LG426 스텁(업데이트 안내), 인입 자동 층 트리거.
남은 것
- 후속 이슈(생성함): #1244 앱
compositeMeta잔여 코드 제거(17파일·80참조) · #1245exercise_set_part.session_exercise_id→session_exercise_part_id열 이름 정정 · #1246 저장 오류·텔레메트리 라벨(save_workout_v4등) v5 이름 정리. 추가 검토 후보:get_profile_workspace최신 체중 정렬coalesce(measured_at, date)가 같은 날 두 기록의 순서를 시각·시간대에 따라 가르는 것(CASE-030에서 발견).