Skip to content

운동 기록 계층 리모델링 — 클린+저크 5세트가 하루 10세트로 부풀던 것에서 다섯 층(세션 → 종목 → 세부 종목 → 세트 → 세부 세트) 한 구조·저장 함수 한 쌍까지 (2026-09-04)

  • 기간: 2026-09-03 ~ 2026-09-04 (세션 2개 — 88dc5b1b Phase 1~7·Phase 8 준비, 7d93c5c5 Phase 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 → production PR #1227)
  • 설계서: 없음(분석·Phase 계획·"예상 효과·개선사항" = 이슈 #1215 본문 + Phase별 완료 댓글 2026-09-03~04)
  • 정본: 데이터 모델 docs/data/session-data-model.md · 쓰기 계약 v5 docs/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 실패 분석 = Playwright trace.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) + 기존 파일 픽스처 이전 · e2e CASE-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 (HEAD 9b41e5d3, 재번호 뒤 순서로 전체 적용) · 검증 한 줄 검증: 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테이블 이름 바꾸기(sessionssession, session_exercisessession_exercise_part, exercise_setsexercise_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 6composite_meta 열·옛 테이블 8개 삭제, 층 소속 NOT NULL, 인입용 자동 층 트리거, 계획 반영 앱 코드 제거✅ (02bad2a9)
Phase 7e2e 9개 이전 + 새 4개, 데이터 모델 문서 신설, 문서 30여 개 옛 이름 치환✅ (0efb2577)
Phase 8main 합치기(#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.md 0단계)에 이미 있으나, 잠금 밖에서 준비하는 동안 다른 트랙이 랜딩하면 그 대조를 다시 해야 한다.
  • 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_refe2e 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.

완료·계획·보드가 한 구조

저장 함수 한 쌍, 변환 코드 한 곳. 계획으로 시작한 운동은 같은 행이 완료로 바뀌고 안 한 세트는 사라진다. 보드로 시작한 운동은 계보가 남아 그룹 화면 "그날 완료 멤버"에 보인다.

테이블 이름을 읽으면 층이 보인다

sessionsession_exercisesession_exercise_partexercise_setexercise_set_part(단수). 정본 문서 docs/data/session-data-model.md.

구조적으로 남는 것

  • 계약: 쓰기 계약 v5(payload·영수증 v2), 데이터 모델 문서, 세는 규칙.
  • 게이트: pgTAP 4파일(가드: 통계 함수는 뷰만 읽는다·세는 규칙·층·계약), e2e 4케이스, 단위(직렬화·계약·문구).
  • 절차: 옛 저장 함수 = LG426 스텁(업데이트 안내), 인입 자동 층 트리거.

남은 것

  • 후속 이슈(생성함): #1244compositeMeta 잔여 코드 제거(17파일·80참조) · #1245 exercise_set_part.session_exercise_idsession_exercise_part_id 열 이름 정정 · #1246 저장 오류·텔레메트리 라벨(save_workout_v4 등) v5 이름 정리. 추가 검토 후보: get_profile_workspace 최신 체중 정렬 coalesce(measured_at, date)가 같은 날 두 기록의 순서를 시각·시간대에 따라 가르는 것(CASE-030에서 발견).