Skip to content

종목 참조 계약 — 읽기 모델 자기 완결 (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) · pgTAP supabase/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_idnull인 항목(자유 기록 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_v1items[] = 종목 참조 묶음(정렬 순), items_truncatedexercise_ids는 폐기v1 → v2 (오너 결정 D2 (b) 즉시 교체)
세션 상세 get_session_detailsession_exercises[].exercise_ref (note는 null)6 유지(추가 전용)
일지 하루 get_calendar_day_summarysessions[].exercise_refs(exercise_ids와 같은 순서·길이) · sessions[].exercises[].exercise_ref · top_sets[].exercise_ref · planned_sessions[].exercise_refs5 유지
일지 월 get_calendar_month_summarysessions[].exercise_refs · planned_sessions[].exercise_refs4 유지
계획 상세 get_planned_session_detailplan.sets[].exercise_ref2 유지
종목 PR get_exercise_pr_history·_records·_detail_year최상위 exercise_ref4·5·4 유지
1RM 직접 입력·기록 지표 get_user_manual_records_v1 (신설)pr_records[].exercise_ref · record_metrics[].exercise_ref1

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_overviewexercise_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)14barbelicMappers.ts 10 · calendarReadModelMapper.ts 4
id 폴백(name: … || exerciseId 등)22mobileApp.tsx 2 · barbelicMappers.ts 5 · barbelicViewMappers.ts 2 · 모바일 뷰 매퍼 5 · 데스크톱 화면 7

정확한 파일별 수는 게이트 테스트의 BASELINE이 정본이다.

3.3 부팅 순서 (문제가 생기는 지점)

  1. 스플래시 → 기기 사본으로 홈을 먼저 그린다(#1016). 이때 즐겨찾기 행 이름은 PR 요약에 있으면 이름, 없으면 id.
  2. 서버 홈 데이터가 오면 상태가 ready가 되고, 그제서야 카탈로그를 요청한다(screenLoadPolicyController). 사본 단계에는 기기 캐시가 있어도 붙이지 않는다.
  3. 카탈로그(약 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를 일으키지 않는다.