종목 카탈로그 의존 구조 개편 — 홈 보드 UUID 2~3초·남의 커스텀 한 건에 전원 0.9MB 재다운로드에서, 읽기 모델 자기 완결 + 기기 사본·변경분 동기화로 (2026-09-04)
- 기간: 2026-09-04 ~ 2026-09-04 (세션 4개 —
9f3e2174(분석·계획·Phase 0) →792816d5(Phase 1·2) →438d4b64(main #1215·#1236 합류·Phase 3 서버) →eaa95ba3(Phase 3 앱·검증·main #1237 합류·랜딩), 오너 보고 "부팅 스플래시 뒤 홈 주요 종목 보드에 종목 이름 대신 UUID가 2~3초 보이다 이름으로 바뀐다", 오너 결정 D1~D3 전부 (b) = 랜딩 1회·즐겨찾기items즉시 교체·삭제 종목 마지막 이름 보존) - 랜딩: PR #1271(Phase 1~4,
cb2004bc) — 마이그레이션 2본20260912030200_exercise_ref_v1 · 20260912040200_exercise_catalog_sync_v1, Vercel 배포(앱 화면·저장소 변경). Production 적용은 릴리스 PR PR #1227(v0.17.0) 레인 - 설계서: 이슈 #1238 본문 2차(분석 + Phase 0~4 계획 + 예상 효과 + D1~D3) — artifact 없음
- 정본:
docs/contracts/exercise-ref.md(종목 참조 묶음 §2, 변경 로그 §2.2, 증분 동기화 §2.3),docs/data/app-screen-rpc-contract.md(get_exercise_catalogv9·get_exercise_catalog_changesv1·즐겨찾기 v2·get_user_manual_records_v1), 서버exercise_ref_json·exercise_catalog_latest_seq_v1·exercise_catalog_item_json_v1, 앱src/react/services/exerciseCatalogStore.ts(기기 저장소)·catalogDomain.syncExerciseCatalogData(동기화 순서)·exerciseRefAdapter.ts(참조 검증) - 도구: 함수 재조립 스크립트(세션 scratchpad
build-migration-v2.mjs·build-migration-phase3.mjs·extract-fns.mjs— origin/mainschema.sql마지막 정의 위에 키 삽입, 앵커 1회 강제),npm run migrations:renumber,npm run ci:local - 게이트: pgTAP
exercise_ref_v1.test.sql(36)·exercise_catalog_sync_v1.test.sql(32) + 갱신 3파일, 앱tests/react/exerciseRefInventory.test.mjs(카탈로그 조인·id 폴백 기준선 0 + allowlist)·exerciseCatalogStore.test.mjs(변경분 어댑터·페이지 적용·동기화 순서)·homeFragmentBoundaries.test.mjs(기기 저장소), e2e CASE-037(카탈로그 응답 붙든 채 부팅해도 보드 이름)·CASE-038(커스텀 생성 = 변경분 1회·전체 0회, 남의 커스텀 = 요청 0회). 로컬:ci:local fullverify 통과(단위 2,586·미사용·빌드) · pgTAP 108파일·1,868 assert PASS · e2e-local 11/11 · e2e-empty 7/7 · e2e-cardio 6/6 · 브라우저 37/37 · viewport 14/14 (4173 포트를 다른 세션이 써서 브라우저·viewport 단계는 같은 빌드를 내 포트로 띄워 따로 실행) · CI 1회 통과(헛빨간불 2회: 테스트 import 인스턴스 분리·runner 네트워크) - 버그리포트:
bug-report/bug-079-20260904.md - 계약:
docs/contracts/exercise-ref.md(신설),docs/data/app-screen-rpc-contract.md(버전 표 8→9·changes 절·즐겨찾기 v2·수동 기록 절·catalog_version의미),docs/data/rpc-catalog.md,docs/data/limits-registry.md(변경분 행),docs/architecture/data-loading-strategy.md(카탈로그 사본 모델)
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 인벤토리 고정 — 읽기 모델별 종목 이름 유무, 앱 우회(조인 14줄·폴백 22줄) 기준선, 부팅 순서 | ✅ (세션 9f3e2174) |
| Phase 1 | 서버 종목 참조 — 변경 로그·exercise_ref_json·즐겨찾기 v2·읽기 모델 6종에 참조 키·수동 기록 RPC 신설 + 앱 어댑터 미러 | ✅ PR #1271 (세션 792816d5) |
| Phase 2 | 앱 조인·id 폴백 제거 — 기준선 0, 즐겨찾기 상태 기계·기기 캐시 v4, 수동 기록 화면 RPC 전환, CASE-037 | ✅ PR #1271 (세션 792816d5) |
| Phase 3 | 카탈로그 증분 동기화 — 서버(유저별 순번·changes RPC·보존 정리·전역 트리거 폐기) + 앱(IndexedDB 저장소·부팅 사본 적재·셸 적용 유지·쓰기 뒤 변경분·보드 시작 보장), CASE-038 | ✅ PR #1271 (세션 438d4b64 서버 · eaa95ba3 앱) |
| Phase 4 | 문서·랜딩 — main 합류 재조립·전역 버전 열 드롭·번호 재배정·잠금·CI 1회·머지·Production 적용 | ✅ PR #1271 (세션 eaa95ba3) |
1. 배경
홈 주요 종목 보드(즐겨찾기)는 서버가 exercise_ids만 주고 이름은 앱이 종목 카탈로그(get_exercise_catalog, 1,072행·0.9MB)와 맞춰서 만들었다. 카탈로그는 홈 응답이 도착해 상태가 ready가 된 뒤에야 요청됐고, 사본 부팅(#1016) 동안은 기기 캐시가 있어도 붙이지 않았으며, 셸 적용은 힌트보다 낡은 카탈로그를 비웠다(EXERCISES = []). 그래서 첫 그리기의 즐겨찾기 폴백은 {id, name: id} — UUID가 화면에 닿았다. 카탈로그 무효화는 전역 문장 트리거 하나(exercises_bump_pr_overview_projection_catalog_version)라 누구의 커스텀 종목 한 건에도 전원이 다시 받았다(Production 최근 14일 중 8일).
2. 문제 제기
읽기 모델이 종목 이름을 싣지 않아 앱이 카탈로그와 조인했다
즐겨찾기·세션 상세 세트·일지 하루/월·계획 상세·종목 PR 히스토리/기록/연간·1RM 직접 입력(테이블 직접 조회)이 exercise_id만 실었다. 앱은 S.exerciseName류 조인 14줄과 name || exerciseId 폴백 22줄로 메웠고, 카탈로그가 늦으면 id가 보였다.
카탈로그는 전역 버전 하나로 통째 재다운로드였다
유저 A가 커스텀 종목을 하나 만들면 전역 버전이 +1 → 유저 B의 홈 힌트가 기기 캐시보다 커져 B도 0.9MB를 다시 받았다. 앱은 쓰기 뒤 "버전 +1"을 기대하는 코드까지 갖고 있었다.
3. 해결 방안
원칙 (오너 결정 2026-09-04)
- D1 (b) 랜딩 1회(Phase 4-4) — Phase마다 PR을 내지 않는다.
- D2 (b) 즐겨찾기
exercise_ids→items(이름 포함) 즉시 교체(v2). - D3 (b) 삭제 종목의 마지막 이름을 변경 로그에 보존 →
missing:true+ 마지막 이름. - "땜질 말고 근본"(§22) — 캐시 선적용·자리표시자 안은 기각.
접근
| 안 | 내용 | 판단 |
|---|---|---|
| 카탈로그 캐시를 사본 부팅에 먼저 붙임 | 기기 캐시가 있을 때만 UUID가 안 보임 | 기각 — 첫 부팅·전역 무효화 뒤엔 그대로, 조인 구조 유지 |
| 이름 자리표시자("종목") | 증상만 가림 | 기각 — 이름이 아닌 것을 보여줌 |
| 읽기 모델 자기 완결 + 기기 사본·변경분 | 서버가 이름을 싣고 앱은 조인·폴백 금지(게이트), 카탈로그는 유저별 순번 이후 변경분만 | 채택 — 이름의 출처가 응답 하나, 무효화 단위가 "내게 보이는 변경 1행" |
랜딩 순서: #1215(운동 기록 계층)·#1236(원본 보호)·#1237(RPE)·#1250(하단 크롬)이 같은 읽기 모델 함수를 먼저 바꿔 랜딩했으므로 이 트랙은 그 위로 옮겨 함수 원문을 두 번 다시 뽑았다(§4 "작업 중 드러난 것").
4. 적용한 내용
Phase 1 — 서버 종목 참조 (20260912030200_exercise_ref_v1)
- 변경 로그 표
exercise_catalog_changes(seq·exercise_id·owner_user_id·op·name_ko·name_en) +exercises·exercise_synonyms행 트리거(삭제 시 마지막 이름, D3 (b)). - 공용
exercise_ref_json(exercise_id, synonym_id)={exercise_id, name_ko, name_en, display_name, missing}(160바이트, synonym → name_ko → name_en, 없으면 로그의 마지막 이름 + missing). - 즐겨찾기 builder v2(
items교체, 65,536바이트), 일지 하루/월(exercise_refs·exercise_ref, 계획 카드는planned_session_card_stats_v1한 곳), 세션 상세 세트·계획 상세·PR 히스토리/기록/연간에exercise_ref(추가 전용 키, 버전 유지 #703),get_user_manual_records_v1()신설. - 앱 미러:
PrFavoritesRpcPayloadv2·ExerciseRef타입·requireExerciseRefPayload(존재하는 종목의 이름이 비었거나 id가 오면 거부).
Phase 2 — 앱 조인·폴백 제거
- 조인 14줄·폴백 22줄 → 0(기록 규격 판정용
S.exerciseById4곳만 allowlist). 즐겨찾기 상태 기계·기기 캐시 v4(items 이름 포함), 홈·기록 보드는items로 그린다. 수동 기록 화면은 RPC 응답의exercise_ref. - e2e CASE-037: 카탈로그 응답을 붙들어 둔 채 부팅해도 보드 행 이름이 UUID가 아니다(리로드 포함).
Phase 3 — 카탈로그 증분 동기화 (20260912040200_exercise_catalog_sync_v1 + 앱)
- 서버:
exercise_catalog_latest_seq_v1(user)(시스템 + 본인 커스텀의 최신 순번; 홈catalog_version값), 전역 버전 트리거·함수·get_exercise_catalog_v2_engine삭제, 홈 코어/PR 스냅샷 갱신/롤오버 재발행,get_exercise_catalogv9(latest_seq, 항목은exercise_catalog_item_json_v1한 곳),get_exercise_catalog_changes(since, limit)v1(종목마다 마지막 1건·페이지 ≤1,000·deleted·최근 2분 재전송·full_resync_required),prune_exercise_catalog_changes_v1(30일, 매일 03:40, 삭제 행은 보존), 초기 채움, 전역 버전 열 드롭. - 앱:
exerciseCatalogStore.ts(IndexedDBbarbelic-exercise-catalog, 소유자별{latestSeq, items}, 저장은 원문·읽을 때 어댑터 재검증·단조·로그아웃 삭제·구 localStorage v5 키 1회 제거),syncExerciseCatalogData(기기 사본 → 변경분 ≤8쪽 → 전체), 부팅 시 사본 적재(셸 사본과 같은 자리), 셸 적용 시 카탈로그 유지, 홈 힌트 > 기기 순번이면 유휴 시점(≤1초)에 변경분, 커스텀 생성·보관은refreshExerciseCatalog()(전역 +1 가정 폐기), 그룹 보드 시작 전ensureExerciseCatalog보장. - e2e CASE-038: 첫 부팅 전체 1회 → 커스텀 생성 = 변경분 1회·전체 0회 + 선택창 표시 → 남의 커스텀 = 내 변경분에 없음·리로드 뒤 요청 0회.
Phase 4 — 문서·랜딩
exercise-ref.md§2.3(층별 정본 표 + 유저 A/B 이야기), RPC 계약(v9·changes 절·catalog_version의미·전역 열 폐기 문장), rpc-catalog, limits-registry, data-loading-strategy.- main 합류 3회: #1215·#1236(세션
438d4b64) → Phase 1 마이그레이션을 main 마지막 정의 위에 재조립, #1237·#1250(세션eaa95ba3) → #1237이 지운exercise_set.difficulty참조 3함수 재조립, #1266(#1215 후속) → 층 필드로 바뀐 5함수 재조립. 번호 재배정20260910210000/211000→20260911010000/020000→20260912010200/020200(#1266 뒤) →20260912030200/040200(#1268 뒤).
주요 결정과 근거
- 카탈로그 무효화 단위 = "내게 보이는 변경 1행"(유저별 순번). 전역 버전은 남의 변경까지 내 재다운로드로 바꿨다.
- 기기 저장소는 별도 IndexedDB(기록 초안 DB 버전 올림 대신) — 초안·대기열 저장소의 업그레이드 차단 위험과 결합하지 않는다.
- 변경분 적용은 멱등 — 서버가 최근 2분 변경을 순번과 무관하게 다시 싣는 이유(순번은 트랜잭션 시작 시 배정돼 커밋 순서와 어긋날 수 있다).
작업 중 드러난 것
- 함수 재발행 기준은 Production 원문이 아니라 main
schema.sql마지막 정의(세션438d4b64) — 릴리스 대기 변경(#1202 탑세트)이 빠져 pgTAP 6번 실패, 대조 스크립트로 8개 중 2개 상이 확인 뒤 재조립. - #1237이 지운 열을 내 재발행이 되살릴 뻔 —
difficulty참조 3함수. 합류 뒤extract-fns.mjs로 SAME/DIFF 대조(3 DIFF) → 재조립. 다중 트랙이 같은 함수를 재발행하면 나중 랜딩 쪽이 재조립한다(메모리 함정 유지). - CASE-035 불안정(그룹 보드 멤버 시작) — 보드 시작이 메모리 카탈로그를 가정하고, 비어 있으면 보드 종목 전부를 커스텀 자동 등록으로 보냈다. 유휴 지연이 이 경쟁을 드러냈다 → 시작 전 카탈로그 보장(구조 수리). 같은 종류의 자동 등록 경로는 이 한 곳뿐(전수 확인).
- CASE-038 첫 작성: 리로드 뒤 초안 복원 팝업·초안 복구 진단 이벤트(
draft_checkpoint_recovered) → 리로드 전 운동 완료로 정리. - 변경분 RPC 커서 결함(로컬 e2e CASE-001 불안정으로 발견) — "최근 2분 재전송"이 순번 필터와 같은 페이지에 섞여 있어, 2분 안에 변경이 페이지(500)를 넘으면(DB 리셋 직후·릴리스 직후 초기 채움 1,072행) 재전송분이 페이지를 채우고
next_seq가since아래로 내려갔다 → 앱 어댑터가 계약 위반으로 거부(오류 보고 2건). 재전송분은 페이지의 남은 자리에만 채우고 커서는 순번 경로만 움직이도록 함수를 고치고 pgTAP 회귀 2건 추가(32/32). test/폴더(tests/와 별개)의 계약 버전 픽스처가 옛 키(catalog_version)를 갖고 있었다(게이트가 잡음). CASE-033→037 개명 잔해*.bak4개 삭제.- CI 1회 빨간불(사전 검증 누락) — 새 테스트가 기기 저장소를
.ts지정자로, 도메인 코드는.js지정자로 불러 CI(Node 22, tsx)에서 모듈 인스턴스가 둘로 갈렸고 테스트의 메모리 백엔드 설정이 도메인 쪽 저장소에 닿지 않아 실제 클라이언트를 열려다 실패(window is not defined). 로컬(Node 24)은 같은 인스턴스라 통과. 메모리에 적힌 함정(#1173 "CI Node 22 tsx 모듈 인스턴스 분리")을 새 테스트에 적용하지 않은 것 — 지정자를.js로 통일해 수리, CI 2회째 통과. - ci:local의 브라우저 단계는 4173을 다른 세션이 쓰면 멈춘다(종료 코드 3) → 같은 빌드를 내 포트로 띄워
E2E_APP_URL로 따로 실행(서비스 키 환경변수를 미리보기 서버에도 넘겨야/api/account/delete가 동작 — CASE-017).
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
| 홈 보드 첫 그리기의 이름 출처 | 카탈로그 조인(없으면 id) | 즐겨찾기 응답 items(이름 포함) — CASE-037 |
| 앱 카탈로그 조인 / id 폴백 | 14줄 / 22줄 | 0 / 0 (+ 규격 판정 allowlist 4) |
| 커스텀 종목 1건 뒤 재다운로드 | 전 사용자 0.9MB | 본인 변경분 1행(≈ 수 KB), 남은 0 — CASE-038 |
| 카탈로그 요청 시점 | 홈 ready 뒤 즉시·전역 버전 | 기기 사본 즉시(네트워크 0) + 힌트 > 순번일 때 유휴 변경분 |
| 셸 적용 시 카탈로그 | 힌트보다 낡으면 비움 | 유지 |
| Production 프로브(릴리스 v0.17.0, 2026-09-07) | — | exercise_catalog_changes 1,072행(순번 최대 1,072 = 초기 채움) · pr_overview_projection_control.catalog_version 열 없음 · get_exercise_catalog_changes·get_user_manual_records_v1·exercise_ref_json 존재 · PR 스냅샷 롤오버: 유저 5명 전원 릴리스 뒤 1회 성공(마지막 15:01Z, 오류 0) · 릴리스 뒤 오류 이벤트에 카탈로그·즐겨찾기 응답 계약 위반 0 |
미검증: 기기에서 첫 그리기까지의 체감 시간(시각 품질), iOS WKWebView IndexedDB 행(#887 시간 상한 8초로 관측 가능한 실패로 바꿈).
6. 이번 개선으로 향상된 것
- 종목 이름의 출처가 "읽기 모델 응답 하나"로 통일됐다 — 새 화면이 카탈로그를 기다릴 이유가 없다(게이트가 재유입을 막는다).
- 카탈로그 무효화가 유저별 1행 단위가 됐다 — 서버·데이터 요금·부팅 지연이 남의 변경에 묶이지 않는다.
- 카탈로그가 기기 사본으로 첫 화면부터 있다 — 종목 선택창·기록 규격 판정이 네트워크 없이 동작하고(오프라인 #1173 원칙과 정합), 보드 시작 같은 카탈로그 의존 동작은 보장 함수로 명시한다.
남은 것
- 다음 릴리스에서 한 릴리스 호환 제거 대상 없음(추가 전용 키).
docs/ko/data/app-screen-rpc-contract.md(한국어 미러)는 v3 시절 문서라 이 트랙에서 손대지 않았다 — 별도 정리 대상. - 관리자 패널의 카탈로그 로드는 종전 로컬 카운터 방식 그대로(변경분 미적용) — 관리자 쓰기 뒤 전체 재조회는 유지.