Skip to content

#1215 후속 3건 묶음 — 옛 저장 함수 이름표·세부 세트 소속 열 이름·복합 종목 JSON 메모를 층 구조 하나로 (2026-09-04)

  • 기간: 2026-09-04 ~ 2026-09-04 (세션 4개 — 7d93c5c5(계획·Phase 1·Phase 2 초안) → 0250e3f4(Phase 2 검증·Phase 3 서버 초안) → be824f26(main #1237 합류·Phase 3 앱·Phase 4 랜딩), 오너 결정 "3번 — 셋 다 v0.17.0 에 합류")
  • 랜딩: PR #1266(Phase 1~4, 46432b19) — 마이그레이션 3본 20260912000000_exercise_set_part_parent_column_rename_v1(세부 세트 소속 열 이름 + 함수 41개 재발행)·20260912000100_user_fact_history_and_protection_v1(원본 보호 트리거 재발행, 수리 티켓 issue#1245)·20260912000200_composite_reads_on_layers_v1(읽기 함수 4개 층 필드 + 합성 함수 2개 삭제), Vercel 배포(앱 화면 공용 코드 변경). Production 적용은 릴리스 PR #1227(v0.17.0) 레인 — #1202·#1199·#1241·#1215·#1236·#1237·#1250 과 함께
  • 설계서: 이슈 #1244·#1245·#1246 에 올린 같은 계획 댓글(2026-09-04, "예상 효과·개선사항" 절 포함) — artifact 없음
  • 정본: docs/data/session-data-model.md(테이블 대응 표·층 필드 계약), docs/architecture/session-hierarchy.md(읽기 표면), docs/data/app-screen-rpc-contract.md(+ko, 계획 상세 행 층 필드), docs/contracts/completed-workout-write-pipeline.md(저장 라벨), 앱 src/react/services/compositeExerciseModel.ts(compositeLayerFieldsFromRpc·pickCompositeLayerFields·readCompositeLayer·compositeRowFields), src/react/services/sessionWriteContractV5.ts(행 필드 composite_group_key·composite_name·movement_position·set_position·details)
  • 도구: 함수 재발행 생성기(세션 scratchpad gen1245b.py — 샌드박스에서 rename 뒤 plpgsql_check + SQL 함수 재생성으로 깨진 함수를 기계가 찾고 pg_get_functiondef 원문에 exercise_set_part 참조만 치환, 레포 밖), gen1244.py(rename 마이그레이션의 4함수 정의를 층 필드 판으로 재작성), scripts/migrations/generate-user-fact-protection.mjs(보호 트리거 재발행), npm run ci:local
  • 게이트: pgTAP 전 파일(following_feed_v1·rpe_single_scale_v1 층 필드/새 열 이름으로 갱신), 마이그레이션 자가 검증(옛 열 이름 0·새 열 not null·4함수 본문에 'composite_meta' 키 0·합성 함수 부재), 계약 테스트 userFactColumnsContract(보호 마이그레이션 = 생성기 출력), 앱 이중 키 게이트(check:dual-key) 기준선 감축, 단위 테스트 21파일 갱신. 로컬 pgTAP: ci:local --only db 104파일/1776 assert 통과(main #1237 합류 뒤, 2026-09-04 16:0x) · e2e 5묶음 local 11/11·empty 7/7·cardio 6/6·browser 35/35·viewport 13/13(8분 25초) · db:preflight 104파일·1776 assert 통과(2026-09-04T07:27:46Z, HEAD 2ab468ca)
  • 버그리포트: 없음(오너 보고 결함이 아닌 후속 정리)
  • 계약: docs/data/session-data-model.md 층 필드 계약, docs/contracts/user-fact-immutability.md §7(원본 컬럼 이름 변경 시 보호 트리거 재발행 순서), docs/data/app-screen-rpc-contract.md(+ko) 계획 상세 행·달력 종목 상한

Phase 현황

Phase내용상태
Phase 1#1246 — 앱 오류·텔레메트리 라벨 5곳을 실제 저장 함수 이름(save_session_v5·delete_session_v5·retry_save_session_v5)으로, types/supabase.ts 옛 RPC 항목 8개 제거, 고정 테스트 10파일a202db13 (세션 7d93c5c5)
Phase 2#1245 — exercise_set_part.session_exercise_idsession_exercise_part_id(rename 만) + 함수 41개 재발행 + 원본 보호 트리거 재발행, 앱 6곳·pgTAP 35파일·e2e 13파일·등급 목록2a190c72 (세션 0250e3f4)
Phase 3#1244 — 읽기 함수 4개가 층 필드를 싣고 합성 함수 2개 삭제, 앱 compositeMeta 참조 80곳 → 02ab468ca (세션 be824f26)
Phase 4랜딩 — main #1237 합류·재생성, ci:local·preflight·잠금·renumber·PR 1개·CI 1회·머지, 릴리스 PR #1227 표 행, 이 기록✅ PR #1266

1. 배경

이슈 #1215(2026-09-04)가 운동 기록을 다섯 층(세션 → 종목 → 세부 종목 → 세트 → 세부 세트) 한 구조로 바꾸고 저장 함수를 한 쌍(save_session_v5/delete_session_v5)으로 합쳤다. 랜딩 크기를 줄이려고 세 가지를 미뤘다(#1215 Phase 6·7 완료 보고): ① 앱의 오류·관제 라벨이 옛 함수 이름(save_workout_v4 등)을 그대로 찍는 것, ② 세부 세트 테이블의 소속 열 이름이 session_exercise_id인데 담긴 값은 세부 종목 id인 것, ③ 서버가 복합 종목을 관계로 저장하면서도 읽기 함수는 옛 JSON 메모(composite_meta)를 합성해 내려보내고 앱이 그 메모로 묶는 것. 오너가 "3번 — 셋 다 v0.17.0 에 합류"로 결정해 한 브랜치·PR 1개로 묶었다.

2. 문제 제기

오류·관제 라벨이 실제로 호출된 함수와 달랐다

완료 기록 저장은 save_session_v5 하나인데 앱의 라벨은 save_workout_v4(3곳)·retry_save_workout_v4(1곳)·delete_completed_session_v4(1곳)였고, types/supabase.ts 에는 업데이트 안내(LG426)만 내는 옛 RPC 항목 8개가 남아 있었다. 관제 화면·오류 이벤트에 존재하지 않는 함수 이름이 찍힌다.

세부 세트의 소속 열 이름이 담긴 값과 달랐다

exercise_set_part.session_exercise_id 에 담긴 값은 종목(session_exercise) id 가 아니라 세부 종목(session_exercise_part) id 였다. 같은 이름의 열이 session_exercise_part.session_exercise_id(이것은 진짜 종목 층 id)에도 있어 새 함수를 쓰는 사람이 잘못 조인하기 쉬웠다. 참조: 서버 함수 68개, pgTAP 36파일·142곳, e2e 12파일, 앱 9파일·39곳(2026-09-04 실측).

복합 종목의 정본이 둘이었다 — 서버는 관계, 앱은 합성한 JSON 메모

유저가 "클린 + 저크"를 기록하면 서버는 종목 하나 아래 세부 종목 둘로 저장한다. 그런데 세션 상세·달력·피드·계획 상세 함수는 그 관계에서 옛 composite_meta(group_key·name·group_position·movement_position·exercise_details)를 다시 합성해 내려보냈고, 앱 17파일·80참조가 그 메모를 읽어 두 동작을 한 종목으로 묶었다. 새 화면을 만들 때마다 메모 모양을 다시 만들어야 했다.

3. 해결 방안

원칙 (오너 결정 2026-09-04)

  • D1 "3번 — 셋 다 v0.17.0 에 합류": 세 후속을 한 브랜치에 Phase 1→2→3 으로 쌓아 PR 1개·CI 1회·랜딩 1회, 릴리스 PR #1227 표에 행 추가. 릴리스 v0.17.0 은 그만큼 늦어진다(오너 수용).

접근

대안판단
라벨·열 이름·메모를 그대로 두고 문서로 설명기각 — 혼동이 코드에 남고 #1244 의 이중 구조가 영구화
앱만 고쳐 서버 메모를 계속 읽기(#1244)기각 — 서버가 옛 모양을 계속 합성해야 하므로 근본 수리가 아님(전역 지침 §22)
서버 읽기 함수가 층 정보(소속 종목 id·이름·순서·동작 순서·수행 상세)를 그대로 내보내고 앱은 그 층 정보로 묶는다. 합성 함수 삭제. 열 이름은 담긴 값대로. 라벨은 실제 함수 이름으로. v0.17.0 이 이미 구 앱 저장을 막으므로(LG426) 호환 기간 없이 한 릴리스에 싣는다채택

4. 적용한 내용

Phase 1 — 저장 라벨 (#1246, 마이그레이션 없음)

앱 라벨 5곳을 save_session_v5/delete_session_v5/retry_save_session_v5 로, types/supabase.ts 의 옛 RPC 항목 8개 제거, 고정 테스트 10파일 갱신, 계약 문서 completed-workout-write-pipeline.md 의 라벨 절.

Phase 2 — 세부 세트 소속 열 이름 (#1245, 20260912000000_exercise_set_part_parent_column_rename_v1 + 20260912000100_user_fact_history_and_protection_v1)

alter table … rename column session_exercise_id to session_exercise_part_id(rename 만 — 외래키 drop+create 는 RI 트리거 순서를 바꾸므로 금지) + 인덱스 개명. 이 열을 본문에 적은 함수는 손으로 고르지 않고 샌드박스에서 rename 뒤 plpgsql_check(정적 검사) + SQL 언어 함수 재생성으로 기계가 찾았다(41개; 임시 테이블에 기대는 함수 2개는 정적 검사가 놓쳐 pgTAP 실행이 잡았다). 각 함수는 그 시점의 실제 정의(pg_get_functiondef)를 그대로 가져와 exercise_set_part 를 가리키는 참조만 바꿨다. 원본 보호 트리거(#1236)는 원본 컬럼 이름을 본문에 적으므로 등급 목록(user-fact-columns.json)의 열 이름을 바꾸고 생성기 출력 전체를 새 마이그레이션으로 재발행(수리 티켓 issue#1245, 원본 DML 없음). 앱 6곳(exercise_set_part 행을 읽는 곳만), pgTAP 35파일·142곳, e2e 13파일, 단위 픽스처 4파일 + 계약 정규식 3곳, 문서 5파일.

Phase 3 — 복합 종목 읽기를 층 필드로 (#1244, 20260912000200_composite_reads_on_layers_v1)

서버: build_session_detail_payload_v1·get_calendar_day_summary·get_session_presentation_mains_json_v2_engine·get_planned_session_detail_v1_engine 이 세부 종목 행마다 session_exercise_id(소속 종목)·session_exercise_name(복합이 아니면 ''session_exercise_position·movement_position·details 를 싣고(계획 상세는 exercise_set_position 도), 달력·피드는 종전 상한(이름 160B·수행 상세 8×64B)으로 자른다. 도우미 jsonb_text_array_v1 추가, session_part_composite_meta_v1·bounded_home_composite_meta 삭제. 앱: compositeExerciseModel 이 정본 — 경계에서 서버 행(snake)을 화면 모델 층 필드(camel)로 한 번 변환(compositeLayerFieldsFromRpc), 화면 모델 사이에서는 pickCompositeLayerFields, 묶기는 readCompositeLayer("같은 소속 종목에 동작 2개 이상이면 복합"). 저장 fan-out 행은 JSON 메모 대신 compositeGroupKey·compositeName·movementPosition·setPosition·details 를 싣고(compositeRowFields), 저장소가 snake 행 필드로 넘기며 쓰기 계약 v5 변환이 그 필드로 parts 를 접는다. 자료형(workout·writeContracts·screenRpc·calendarReadModel)·어댑터·매퍼·저장소·화면 직렬화에서 compositeMeta 80참조 → 0. 단위 테스트 21파일·e2e 2파일 픽스처를 층 필드로.

Phase 4 — 랜딩

main 에 들어온 #1237(RPE 단일 척도, 마이그레이션 2본)을 합쳤다: 충돌 9개는 main 쪽 내용 + 새 열 이름으로 풀고, 함수 재발행 마이그레이션은 합친 뒤 main 상태의 샌드박스에서 다시 생성(같은 41개, #1237 이 바꾼 함수 13개의 새 본문 반영), 보호 트리거 재발행도 합친 등급 목록으로 재생성, 읽기 함수 4개도 재생성. 임시 번호가 #1237 과 겹쳐(20260911000000) 셋을 옮긴 뒤 migrations:renumber 로 확정.

주요 결정과 그 근거

  • 층 필드는 "있을 때만 검사"(추가 전용): 재발행 전 서버 응답·옛 캐시 페이지에는 키가 없다. 어댑터는 키가 있으면 형(문자열·양의 정수·문자열 배열·바이트 상한)을 검사하고 없으면 통과시킨다 — #1182 의 composite_meta 와 같은 규칙.
  • 읽기 도우미가 두 어휘를 같이 읽는다: 서버 행에서 옮긴 층 필드와 저장 fan-out 행의 묶음 필드는 같은 것(소속 종목·이름·순서)이라 readCompositeLayer 가 둘 다 읽는다 — 계획 저장 → 운동 시작처럼 서버를 거치지 않고 바로 복원하는 경로가 같은 규칙으로 묶이게.
  • 계획 상세 행은 세트 순서가 있는 행만 묶는다: 계획 상세 행은 #1215 부터 다섯 층 번호표(session_exercise_id)를 실어 왔다. 세트 순서(exercise_set_position) 없이 소속 종목 id 만으로 묶으면 v5 변환이 여러 세트를 한 자리로 합쳐 버리므로(세트 유실), 쓰기 DTO 는 세트 순서와 동작 순서가 함께 있는 행만 묶음 필드로 낸다.

작업 중 드러난 것

  • 재발행 함수는 랜딩 직전 main 상태 샌드박스에서 다시 뽑아야 한다 — 인수인계 초안은 #1236 이전 정의여서 #1236 이 폐기한 함수 2개를 되살렸고(pgTAP 4파일 실패), 이번엔 #1237 이 같은 함수 13개를 바꿔 다시 생성했다. 절차: 내 마이그레이션을 빼고 supabase db reset --no-seed → rename → 정적 검사로 대상 확정 → 생성 → 머리 주석만 옛 파일에서 유지.
  • 정적 검사(plpgsql_check)는 임시 테이블에 기대는 함수를 그 문장에서 멈춰 놓친다 → pgTAP 실행이 최종 오라클.
  • 원본 컬럼 이름을 바꾸면 #1236 보호 트리거가 깨진다 → 등급 목록 → 생성기 → 새 *_user_fact_history_and_protection_v1.sql(티켓) 재발행. 계약 문서 §7 에 순서를 적었다.
  • 앱 층의 camel/snake 이중 키 게이트(check:dual-key)가 새 이중 키를 거부한다 — 층 필드 읽기를 "서버 행 → 화면 모델(경계 1회)"과 "화면 모델 읽기"로 나눠 통과했고 기준선이 줄었다(compositeExerciseModel 16 → 9, barbelicMappers 254 → 253).
  • 세션 상세 함수 소스에 raw_payload 가 나오면 안 된다는 계약 테스트가 있었다 — 수행 상세는 raw_payload->'details' 에서만 꺼내므로 그 형태만 허용하도록 검사를 좁혔다.
  • 임시 번호 충돌: #1237 이 20260911000000 을 먼저 썼다 — 개발 중 번호는 임시이고 renumber 가 확정한다(전역 지침 §15). 같은 번호 두 파일은 db reset 이 거부하므로 합치자마자 옮겼다.
  • CI 빨간불 1회(run 33848973536, browser-journeys 2/2 의 CASE-028): 앱 결함 아님 — 러너에서 rest:profiles 읽기 한 건이 일시 네트워크 실패로 오류 이벤트에 기록돼 "예상 밖 오류 없음" 게이트가 막혔다. 로컬 e2e 35/35 통과와 같은 코드. 분류 = 재현 불가(CI 환경 일시 실패) → 실패 잡만 재실행.

5. 적용 결과

항목전 → 후
앱 코드의 *_v4 저장 라벨5곳 → 0곳
types/supabase.ts 옛 RPC 스텁 항목8 → 0
exercise_set_part.session_exercise_id 참조(서버·앱·테스트)서버 함수 41개·pgTAP 35파일·앱 6곳 → 0 (플래그: plpgsql_check 형 오류 0)
옛 JSON 메모를 합성하는 읽기 함수4개(합성 함수 2개) → 0
compositeMeta 참조80 → 0
앱 이중 키 기준선(compositeExerciseModel / barbelicMappers)16 / 254 → 9 / 253
로컬 게이트npm run check 2,569 통과 · pgTAP 104파일/1776 assert · e2e 72/72
Production 적용(릴리스 v0.17.0, 2026-09-06 머지)프로브 2026-09-07: exercise_set_partsession_exercise_part_id 있음·session_exercise_id 없음 · session_part_composite_meta_v1·bounded_home_composite_meta 부재 · 세션 상세 payload 함수(build_session_detail_payload_v1)에 session_exercise_name·movement_position 포함 · user_fact_history 티켓 issue#1245 0행 · 릴리스 뒤 오류 이벤트에 LG_SCHEMA_VALIDATION 0·옛 함수 이름 0

6. 이번 개선으로 향상된 것

관제·오류 이벤트에 실제 함수 이름이 찍힌다

운영이 오류를 조회할 때 존재하지 않는 save_workout_v4 를 더 찾지 않는다.

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

exercise_set_part.session_exercise_part_id = 세부 종목 id. #1215 의 목표("이름을 읽으면 층이 보인다")의 마지막 자리가 닫혔다.

복합 종목의 정본이 하나(관계)다

새 화면은 서버 행의 층 필드(소속 종목 id·이름·순서·동작 순서)만 읽으면 되고, 옛 메모 모양을 다시 만들 필요가 없다. 구조적으로 남는 것: 읽기 페이로드의 층 필드 계약(session-data-model.md), 저장 행의 묶음 필드 계약(sessionWriteContractV5), 원본 컬럼 이름 변경 절차(user-fact-immutability.md §7), 재발행 대상을 기계가 찾는 오라클 절차.

남은 것

  • tests/react/schemaRls.test.mjs 의 "composite_meta 열" 단언은 이어 붙인 schema.sql 의 옛 create table 만 보고 통과한다 — #1252(실제 상태 덤프 전환)에서 재검토.