종목 참조 계약 — 읽기 모델 자기 완결 (exercise-ref)
- 상태: 초안 v0.1 (이슈 #1238 Phase 1 서버 반영, 2026-09-04) — Phase 1에서 서버가 아래 묶음을 싣기 시작했고(마이그레이션
exercise_ref_v1), Phase 2에서 앱이 소비하며, Phase 4에서 v1으로 확정한다. - 서버 정본: 공용 함수
public.exercise_ref_json(exercise_id, synonym_id)· 변경 로그public.exercise_catalog_changes(행 단위 트리거exercises_log_catalog_change_v1·exercise_synonyms_log_catalog_change_v1) · pgTAPsupabase/tests/database/exercise_ref_v1.test.sql - 이슈: #1238 종목 카탈로그 의존 구조 개편
- 관련 문서:
docs/architecture/data-loading-strategy.md(라벨용 메타데이터는 셸에, 검색은 온디맨드 — 이 계약은 그 목표의 집행),docs/contracts/app-screen-rpc-contract.md - 게이트:
tests/react/exerciseRefInventory.test.mjs(앱 우회 인벤토리 기준선, Phase 2에서 0)
1. 원칙
화면 하나가 쓰는 서버 응답(읽기 모델)은 그 화면이 그리는 것을 전부 싣는다. 종목을 참조하는 응답은 종목 id만 보내지 않고 표시명을 함께 보낸다. 앱은 서버 데이터를 그릴 때 종목 카탈로그와 맞춰 보지(조인하지) 않는다.
왜: 앱이 카탈로그와 조인해 이름을 만들면, 카탈로그가 아직 없는 순간(부팅 직후·캐시 무효 뒤)에 이름을 못 만들고 id(UUID)를 그대로 내보낸다. 2026-09-04 오너 보고 "부팅 뒤 종목명이 UUID로 2~3초"가 이 구조의 증상이다. 카탈로그는 검색·선택·기록 칸 판정에 쓰는 상호작용 데이터이지, 이미 기록된 것을 그리는 데 필요한 데이터가 아니어야 한다.
2. 종목 참조 묶음 (Phase 1에서 확정)
종목을 참조하는 모든 읽기 모델 항목은 아래 키를 함께 싣는다. 서버 공용 함수 public.exercise_ref_json(exercise_id, synonym_id) 하나가 만든다.
| 키 | 뜻 |
|---|---|
exercise_id | 종목 id(uuid 문자열) |
name_ko | 종목 한글 이름(left_utf8_bytes 160 — exercise_synonyms 이름 상한과 같다) |
name_en | 영문 이름(160), 빈 문자열은 null |
display_name | 기록이 가리키는 표기(synonym_id)가 있으면 그 표기(예: 행 파워 클린), 없으면 name_ko, 그것도 없으면 name_en — #703 세션 상세 name과 같은 규칙 |
missing | 종목 행이 없으면 true. 이때 name_ko·display_name은 변경 로그에 남은 마지막 이름, 로그 이전 삭제분은 null |
exercise_id가 null인 항목(자유 기록 note)은 묶음 자체가 null이다. 앱은 missing:true를 "삭제된 종목 · <마지막 이름>"으로, 이름이 없으면 "삭제된 종목"으로 그린다. 어떤 경로에서도 id 문자열을 이름 자리에 쓰지 않는다 — 앱 어댑터는 존재하는 종목(missing:false)의 name_ko·display_name이 비어 있거나 id와 같으면 응답을 거부한다.
2.1 읽기 모델별 키 (Phase 1 반영)
| 읽기 모델 | 키 | 계약 버전 |
|---|---|---|
즐겨찾기 get_user_exercise_favorites·replace_…·set_…·친구 get_following_exercise_favorites_v1 | items[] = 종목 참조 묶음(정렬 순), items_truncated — exercise_ids는 폐기 | v1 → v2 (오너 결정 D2 (b) 즉시 교체) |
세션 상세 get_session_detail | session_exercises[].exercise_ref (note는 null) | 6 유지(추가 전용) |
일지 하루 get_calendar_day_summary | sessions[].exercise_refs(exercise_ids와 같은 순서·길이) · sessions[].exercises[].exercise_ref · top_sets[].exercise_ref · planned_sessions[].exercise_refs | 5 유지 |
일지 월 get_calendar_month_summary | sessions[].exercise_refs · planned_sessions[].exercise_refs | 4 유지 |
계획 상세 get_planned_session_detail | plan.sets[].exercise_ref | 2 유지 |
종목 PR get_exercise_pr_history·_records·_detail_year | 최상위 exercise_ref | 4·5·4 유지 |
1RM 직접 입력·기록 지표 get_user_manual_records_v1 (신설) | pr_records[].exercise_ref · record_metrics[].exercise_ref | 1 |
2.2 종목 변경 로그 exercise_catalog_changes
exercises 행 추가·수정·삭제와 exercise_synonyms 변경(부모 종목 upsert)이 seq 순번과 함께 남는다. 삭제 행은 마지막 name_ko·name_en을 보존한다(오너 결정 D3 (b)) — exercise_ref_json이 없는 종목의 이름을 여기서 읽는다. Phase 3의 증분 동기화 RPC(§2.3)가 같은 표를 읽으며, 그때 기존 문장 단위 전역 버전 트리거를 폐기했다. 현행 FK(세션·계획·즐겨찾기·직접 입력 → exercises, NO ACTION)로는 참조 중인 종목을 지울 수 없으므로 missing:true는 안전망이다.
2.3 카탈로그 증분 동기화 (Phase 3)
카탈로그는 "전역 버전 하나로 0.9MB를 통째 다시 받는" 것에서 "기기 사본 + 변경분"으로 바뀌었다.
| 층 | 무엇 | 정본 |
|---|---|---|
| 순번 | exercise_catalog_latest_seq_v1(user_id) = 시스템 종목 + 그 유저 커스텀 변경의 최신 순번. 홈 응답 catalog_version 값이 이것(키 이름 유지). 남의 커스텀은 내 순번을 올리지 않는다 | 마이그레이션 exercise_catalog_sync_v1 |
| 전체 | get_exercise_catalog v9 — latest_seq + 항목(exercise_catalog_item_json_v1 한 곳). 기기 사본이 없거나 보존 기간 밖일 때만 | docs/data/app-screen-rpc-contract.md |
| 변경분 | get_exercise_catalog_changes(since_seq, limit) — 종목마다 마지막 1건, items(현재 모양)·deleted(삭제·보관·안 보임), 페이지 ≤1,000, 최근 2분 재전송(멱등 적용), 보존 정리 밖이면 full_resync_required | 같은 문서 |
| 보존 | prune_exercise_catalog_changes_v1(30일, 매일 03:40) — upsert 행만 지우고 삭제 행(마지막 이름)은 남긴다. 지운 최대 순번은 pr_overview_projection_control.catalog_changes_pruned_seq | 마이그레이션 |
| 기기 저장소 | IndexedDB barbelic-exercise-catalog(소유자별 { latestSeq, items }), 저장은 원문·검증은 읽을 때, 단조 증가, 로그아웃·계정 전환 시 소유자 항목 삭제. 구 localStorage 캐시(barbelic:exercise-catalog:v5:*)는 1회 제거 | src/react/services/exerciseCatalogStore.ts |
| 동기화 순서 | ① 기기 사본이 최소 순번(홈 힌트)을 만족하면 네트워크 0 ② 아니면 기기 순번 이후 변경분(≤8쪽) ③ 사본 없음·보존 밖·8쪽 초과면 전체 | catalogDomain.syncExerciseCatalogData |
| 부팅 | 셸 사본 적재와 같은 자리에서 기기 카탈로그를 먼저 싣는다(네트워크 0). 셸 적용은 카탈로그를 비우지 않는다(구 Phase 2-5 결합 해제). 홈 힌트가 더 크면 유휴 시점에 변경분만 | remoteDataController · screenLoadPolicyController |
| 쓰기 직후 | 커스텀 종목 생성·보관·복원 → refreshExerciseCatalog()(변경분 1행). 전역 epoch +1 가정 폐기 | appController · customExerciseStore |
유저 A가 커스텀 종목 하나를 만들면: A의 기기는 변경분 1행을 받고, 다른 유저 B의 순번은 오르지 않아 B는 아무것도 다시 받지 않는다(종전에는 전원이 0.9MB 재다운로드). 관리자가 시스템 종목 하나를 고치면: 모든 유저의 순번이 1 오르고 각 기기는 그 1행만 받는다.
3. 현황 인벤토리 (Phase 0, 2026-09-04)
3.1 서버 읽기 모델
| 읽기 모델 | 종목 이름 | 비고 |
|---|---|---|
PR 요약 get_pr_overview → exercise_summaries | 있음(name_ko) | |
최근 PR get_user_recent_prs_json | 있음(name_ko) | |
홈 벤치마크 get_user_home_benchmark_prs_json | 있음(name_ko) | |
피드·검색 카드 mains | 있음(name) | |
볼륨 리포트 get_volume_overview* | 있음 | |
즐겨찾기 get_user_exercise_favorites | 없음(exercise_ids만) | 홈·기록 탭 주요 종목 보드 — 오너 보고 지점 |
세션 상세 get_session_detail 세트 | 없음(exercise_id·synonym_id) | 앱이 S.exerciseDisplayName으로 조인 |
일지 하루 get_calendar_day_summary 세트(decorate_calendar_day_atomic_sets_v1) | 없음 | 앱 calendarReadModelMapper 조인 |
일지 월 get_calendar_month_summary 종목 목록 | 없음(exercise_ids) | |
계획 상세 get_planned_session_detail | 없음 | |
종목 PR 히스토리·기록·연간 get_exercise_pr_history·_records·_detail_year | 없음 | 선택 종목 이름은 PR 목록에서, 없으면 id |
| 1RM 직접 입력·기록 지표 | 없음 | RPC가 아니라 user_manual_pr_records·user_manual_record_metrics 테이블 직접 조회 |
3.2 앱 우회 (게이트 기준선)
| 종류 | 줄 수 | 위치 |
|---|---|---|
카탈로그 조인(S.exerciseName·S.exerciseDisplayName·S.exerciseById) | 14 | barbelicMappers.ts 10 · calendarReadModelMapper.ts 4 |
id 폴백(name: … || exerciseId 등) | 22 | mobileApp.tsx 2 · barbelicMappers.ts 5 · barbelicViewMappers.ts 2 · 모바일 뷰 매퍼 5 · 데스크톱 화면 7 |
정확한 파일별 수는 게이트 테스트의 BASELINE이 정본이다.
3.3 부팅 순서 (문제가 생기는 지점)
- 스플래시 → 기기 사본으로 홈을 먼저 그린다(#1016). 이때 즐겨찾기 행 이름은 PR 요약에 있으면 이름, 없으면 id.
- 서버 홈 데이터가 오면 상태가
ready가 되고, 그제서야 카탈로그를 요청한다(screenLoadPolicyController). 사본 단계에는 기기 캐시가 있어도 붙이지 않는다. - 카탈로그(약 0.9MB, 전역 버전 하나로 무효화)가 도착하면 이름으로 바뀐다.
4. 검증
- 앱:
tests/react/exerciseRefInventory.test.mjs— 우회 줄 수 기준선. Phase 2 종료 시 0 + allowlist. - 서버: Phase 1에서 pgTAP
exercise_ref_v1.test.sql— 위 3.1의 "없음" 항목 전부가 종목 참조 묶음을 싣는지, 삭제 종목이missing+마지막 이름을 싣는지. - 브라우저:
error-cases/CASE-037-boot-exercise-names-without-catalog— 카탈로그 응답을 붙들어 둔 채 부팅해도 보드 첫 그리기에 이름이 있는지(Phase 2에서 활성화). - 서버(Phase 3): pgTAP
exercise_catalog_sync_v1.test.sql— 유저별 순번·변경분 페이지·삭제/보관deleted·남의 커스텀 제외·2분 재전송·보존 정리·full_resync_required. - 앱(Phase 3):
tests/react/exerciseCatalogStore.test.mjs(변경분 어댑터·페이지 적용·동기화 순서 앵커) ·tests/react/homeFragmentBoundaries.test.mjs(기기 저장소 소유자·단조·읽을 때 검증). - 브라우저(Phase 3):
error-cases/CASE-038-custom-exercise-catalog-incremental-sync— 커스텀 종목을 만들면 전체 카탈로그(get_exercise_catalog) 0회·변경분 1회로 선택창에 보이고, 다른 유저의 커스텀 종목은 리로드해도 카탈로그 RPC를 일으키지 않는다.