카탈로그 synonym 컷오버 — 종목 이름 3층 트리 (2026-08-21)
- 기간: 2026-08-21 ~ 08-22 (세션 2개 — 설계·Phase 0~2 / Phase 2 랜딩·Phase 3 마감·후속 트랙)
- 랜딩: PR #526(Phase 0) · #541(Phase 1) · #544(Phase 2) · #550(Phase 3) + 후속 #568(병합·매핑 점검), 마이그레이션 6건(
20260821060000·140000·140100·200000·250000·380000) 전부 Production 적용 - 정책: exercise-model 2.0.0 (
docs/policies/exercise-model/, aliases 계약 →synonym_tree) - 계획서·판정지: Claude artifact b71ea98d(계획/진행판) · d518783d(별칭 판정지)
1. 배경
종목 카탈로그의 이름 체계는 "대표 명칭 1개 + 평평한 별칭 배열(exercises.aliases)"이었다. 별칭은 검색 매칭 재료일 뿐이라, 유저가 Power Clean을 검색해 고르면 화면에는 대표명 하이 클린이 떴다. 크로스핏 출신 유저에게 이건 "내가 고른 이름이 아니다"로 읽힌다.
오너가 같은 날 아침 이 문제를 제기하면서 설계 토론이 시작됐고, 단계적으로 다음이 확정됐다: ① 정체성은 BRID 하나(별칭마다 BRID를 발행하지 않는다 — 떼어내기는 provenance+remap으로) ② 유저별 선호 저장은 기각(시스템 복잡도) ③ 별칭을 두 층으로 가른다 — synonym(동등한 표기: 하이 클린 ↔ 파워 클린)과 교정 별칭(문법·표기 교정: back squats → back squat) ④ 3층 트리: 종목 → synonym(하나가 default) → 교정 별칭은 synonym 소속 ⑤ "땜질하지 말고 새 방향으로 완전히 컷오프".
2. 문제 제기
별칭이 정체성도 표기도 아닌 어정쩡한 층이었다
exercises.aliases는 검색 입력에만 쓰였고 locale·provenance·priority 관계도 아니었다 (정책 1.0 감사표의 "부분 준수"). 동등 표기와 오타 교정이 한 배열에 섞여 있어 "고른 표기"를 기록에 남길 자리가 없었다.
별칭 배열을 만지는 경로가 서버·클라이언트에 흩어져 있었다
읽기: 카탈로그 payload(get_exercise_catalog)·검색 3표면·SQL 매칭(임포트 스코어링· 컴플렉스 파서·관리자 매핑 검색). 쓰기: 관리자 별칭 편집(PostgREST 직접 UPDATE)· 종목 생성 엔진·미매핑 placeholder 생성(임포트 엔진). 한 컬럼을 바꾸려면 전부를 같이 옮겨야 했다.
전수 별칭 3,080건 중 무엇이 synonym인지 아무도 몰랐다
승격 판정 없이 구조만 바꾸면 트리는 비어 있는 껍데기다.
3. 해결 방안
원칙
- 정체성 불변: 종목 BRID/UUID 하나. synonym은 종목의 id를 공유하고 자체 정체성이 없다. 통계·PR·기록 사실의 의미는 전혀 바뀌지 않는다.
- default synonym = 종목 이름의 거울:
exercises.name_ko/name_en과 트리거로 동기화. 이름 불변식은 영구 존속, 별칭 미러는 이행기에만. - 기록은 표기를 참조만 한다:
session_exercises.synonym_id(nullable, 표시 전용). 서버는 참조를 나르고, 표시 해석은 클라이언트가 카탈로그 트리의 id로 한다. - 커스텀 가드는 synonym 공간까지: "파워 클린"으로 커스텀을 만들 수 없다(하이 클린의 synonym). 교정 별칭은 표기가 아니라 검색 재료라 막지 않는다.
- 페이즈 분리: 구조(Phase 0) → 읽기(Phase 1) → 쓰기(Phase 2) → 구층 은퇴(Phase 3, 비가역). 각 페이즈는 독립 PR + Production 적용 + 격리 스택 리플레이.
접근
별칭 3,080건은 4단 기계 체(이름 파생·어순·프로바이더 키·변형 접기)로 92%를 교정으로 자동 판별하고, 잔여 173행만 오너 판정지에 올렸다. 오너 확정: 승격 18건(하이 클린·하이 스내치는 default까지 교체), 병합 6쌍(별도 트랙).
4. 적용한 내용
Phase 0 — 구조 신설 (#526, 060000)
exercise_synonyms(id·exercise_id·name_ko·name_en·is_default·aliases, RLS·부분 유니크)
- 전 종목 default synonym 시드(713=713, 드리프트 0) + 기존 별칭 배열 전량을 default 밑으로 이주 + 동기화 트리거 v1(이름·별칭 미러).
Phase 1 — 읽기 컷오버 + 승격 (#541, 140000·140100)
get_exercise_catalog v4(래퍼+엔진 스쿼시, synonyms 탑재, aliases=default 투영 — 클라이언트 예산 2×48 불변), 검색 3표면(매칭 synonym이 주 라벨 + 같은-종목 배지), get_admin_mapping_page 검색 문서 synonym화(exercise_synonym_search_text_v1), 트리거 v2(UPDATE는 이름만 — 큐레이션을 구 배열이 덮는 사고 구조 차단), 승격 18건 + default 교체 2건 데이터 마이그레이션.
Phase 2 — 쓰기 컷오버 (#544, 200000)
session_exercises.synonym_id(FK set null·노트 엔트리 금지·부분 인덱스) + 검증 헬퍼 validated_entry_synonym_id_v1(오소속·비UUID 22023 fail-closed) + save_workout_v4_engine· update_completed_session_v4_engine 전문 교체 + get_session_detail v6 + 클라이언트 왕복 (피커 선택 → 초안 → DTO → 저장 → 상세 → 편집) + 관리자 별칭 편집이 default synonym 행을 씀 + create_custom_exercise 가드 확장.
Phase 3 — 구층 은퇴 + 정책 2.0.0 (#550, 250000)
드롭 직전까지 구 컬럼을 만지던 라이브 경로 4곳을 같은 파일에서 먼저 재배선한 뒤 드롭: 트리거 v3(이름만, update of name_ko, name_en 재선언) · create_catalog_exercise_engine (별칭을 default synonym에, 멱등 비교·응답 aliases 키도 synonym 기준) · refresh_wodup_complex_interpretations_v1(계획 밖 발견 — 컴플렉스 이름 후보를 트리 에서) · import_wodup_batch_to_canonical_engine(계획 밖 발견 — placeholder 프로바이더 별칭을 default synonym 행에). 이후 alter table exercises drop column aliases + postcheck (컬럼 부재·트리거 컬럼 목록·라이브 함수 잔존 참조 0). 클라이언트는 EXERCISE_SELECT_COLUMNS 에서 aliases 제거(PostgREST 400 방지). 정책 exercise-model 2.0.0: /contract/aliases가 breaking_paths라 major — versions/2.0.0 불변 스냅샷·지문·changelog·계약 테스트.
주요 결정과 그 근거
- synonym에 BRID를 주지 않는다 — 정체성이 둘이면 통계·PR이 갈라진다. 떼어내기는 synonym 서브트리 이동 + 재배정(인입=source_name 원문, 앱내=synonym 참조, 무참조=잔류).
- default 교체(하이 클린·하이 스내치) — 오너가 "승격이어야" 정정 확인. 파워 클린·파워 스내치는 동등 synonym으로 유지.
- 광역 키워드(Row×8·Bike×4·Run류×19)는 교정 유지·승격 금지 — 종목 경계를 흐린다.
- 정책 2.0.0의
requires_full_replay/migration_paths— 게이트가 breaking에 요구하는 필드. replay의 실체는 별칭 데이터를 트리로 옮긴 마이그레이션 5건(시드·읽기·승격·쓰기· 은퇴)이며 기록 사실의 재해석은 없다.
작업 중 드러난 것
- 라이브 잔존 참조는 정적 스캔이 잡았다.
schema.sql은 baseline+append 스냅샷이라 함수별 "마지막 정의"만 라이브다(+이후 drop 여부). 계획에 없던 컴플렉스 파서·임포트 엔진 2본이 여기서 나왔다 — 놓쳤다면 컬럼 드롭 뒤 첫 인입/파서 실행에서 42703으로 죽었다. - 트리거의
update of <col>목록은 pg_depend로 컬럼에 묶인다 — 드롭 전에 트리거를 재선언해야 한다. - 마이그레이션 번호 선점 하루 8회(040000→060000, 110000→…→140000, 150000→200000 도약, 그 뒤 #538·#545·#548·#549가 150000·160000·210000(→230000으로 랜딩)·220000). 랜딩 직전
gh pr list --json files로 열린 PR의 migrations 경로를 스캔하고 도약 클레임한다. 재머지 때schema.sql은 손으로 풀지 않고 마이그레이션 연결로 재생성한다. 게이트를 파이프(| tail)로 감싸면 exit code가 가려진다 — 한 번 중복 번호 상태로 푸시한 니어미스. - 같은 함수를 두 PR이 재선언하면 머지 순서가 의미를 정한다(양파). #548이 임포트 엔진을 재선언(이름 160바이트 상한)하길래 Phase 3는 #548 본문 위에 재조립했고, 순서는 #548 → Phase 3로 고정했다(역순이면 #548 본문이 드롭된 컬럼을 INSERT).
- 공유 체크아웃은 다른 세션이 동시에 조작한다 — git 작업은 워크트리에서, Production push(읽기 전용)만 메인 체크아웃에서.
- Docker Desktop AF_UNIX 크래시 재발 — 기록된 두 폴더 동시 rename 우회로 즉시 복구.
5. 적용 결과
- Production: 마이그레이션 5건 적용,
check:remote-schema실패 0 (각 페이즈 직후 실측). - 종목 713 = default synonym 713, 승격 synonym 18, default 교체 2.
- 검증: 격리 스택 전량 리플레이(28개) · 런타임 실측(구조·트리거 v3·엔진 응답/멱등· 컴플렉스 파서·임포트 엔진·카탈로그 v4) · pgTAP 34파일 537 PASS · npm test 1,644/1,644 ·
npm run check전 게이트 그린 · 정책 계약 테스트 5/5 · 스냅샷 불변 게이트 통과. - 정책 exercise-model 1.0.1 → 2.0.0 (감사표 "별칭·검색" 부분 준수 → 준수).
6. 이번 개선으로 향상된 것
유저가 고른 표기가 그대로 남는다
피커에서 파워 클린을 고르면 초안·저장·세션 상세·편집까지 파워 클린으로 보이고, 통계는 하이 클린(같은 BRID)에 귀속된다.
검색이 "왜 이게 나왔는지"를 보여준다
매칭된 synonym이 주 라벨로, 같은-종목 배지가 대표명을 가리킨다.
별칭의 정본이 하나다
관리자 편집·종목 생성·placeholder 생성·SQL 매칭 경로 전부가 exercise_synonyms를 읽고 쓴다. 구 컬럼은 없다 — 두 곳이 엇갈릴 수 없다.
계약이 기계 판독으로 잠겼다
정책 2.0.0 스냅샷·지문·계약 테스트가 aliases 계약을 고정한다. 되돌리려면 major를 다시 올려야 한다.
7. 후속 트랙 — 종목 병합 6쌍 + 매핑 점검 2건 (2026-08-22)
캠페인 판정지에서 "별도 트랙"으로 미뤄 둔 두 항목을 오너 지시로 바로 이어 실행했다 (PR #568, 마이그레이션 20260821380000_catalog_merge_pairs.sql, Production 적용).
오너 결정
- 병합 1~5는 전량 병합. 병합 6(줄넘기→싱글언더)은 부분 편입 — 횟수 기록이 있는 것만 싱글언더로 옮기고, 자유기록으로 남은 것은 줄넘기에 그대로 둔다(두 종목 모두 유지).
- D1:
Lunge는 별개 종목(lunge)이므로 로우 런지(요가 아사나)에서 뗀다. D2: 카프 프레스의Calf Raise는 카프 레이즈의 것이다. - 관리자 synonym 트리 편집기 UI는 다음에 별도 트랙으로.
실측이 먼저
Production에 임의 질의 경로가 없으므로 raise로 끝나는 일회용 진단 마이그레이션(기록되지 않는다)으로 참조를 전수 실측했다. 결과: 흡수 종목 5개 중 실제 참조는 **밀리터리 프레스 세션 엔트리 1건(9세트, 유저 1명)**과 **WodUp 매핑 1건(wodup:global:162 → 인클라인 덤벨 프레스)**뿐이었고, 줄넘기·싱글언더는 기록이 0건(둘 다 이미 reps 종목)이라 부분 편입은 데이터상 no-op였다. D2는 별칭 외에 wodup:global:762(provider name "Calf Raise", bodyweight)가 카프 프레스에 매핑돼 있었다(그 매핑으로 들어온 기록 0건).
절차 — 시스템↔시스템 병합의 첫 실행
기존 remap_wodup_user_customs_to_canonical_v1(#453, WodUp 커스텀→정식)의 사실 이전 절차를 그대로 따르되 대상이 정식 종목 쌍이라는 점만 다르다. 함수 재선언 없는 데이터 전용 마이그레이션 한 장으로:
- 잠금 순서(인입 워커 → 세션/플랜 집계 루트 → 즐겨찾기 상태)를 지키고
- 세션 엔트리·플랜 세트·수동 PR·즐겨찾기(dedupe/densify)·외부 매핑·해석 구성요소· 아키타입 대표·역할/강도표준을 메인으로 재작성(기대 행수 대조, 완료 세션 revision 발행),
- 흡수 종목의 synonym 행을 메인의 비-default synonym으로 이동(교정 별칭 동반; 메인 이름·메인 기존 별칭·자기 이름과 중복 제거, 메인 별칭에서 흡수 이름 제거 — 교차 별칭 상태가 트리로 정리된다),
- 잔존 참조 0을 확인한 뒤 흡수 종목 행을 삭제(cascade가 파생 프로젝션을 걷는다),
- 영향 유저의 통계를 enqueue + 인라인 처리 + 무결성 검증(미정착이면 raise → 전체 롤백),
- postcheck는 손댄 대상만 단언한다.
줄넘기 쌍은 reps > 0 세트를 가진 엔트리만 싱글언더로 옮기는 규칙으로 박고 두 종목의 교차 이름(Single Under ↔ Jump Rope)만 서로의 별칭에서 뗐다. D1/D2는 별칭 제거 + wodup:global:762 매핑을 카프 레이즈로 재지정.
검증
- 격리 스택에 **픽스처(유저·완료 세션 2·엔트리 3·세트 4·즐겨찾기 3·플랜 1)**를 심고 적용: 밀리터리 엔트리→OHP, 줄넘기 reps 엔트리→싱글언더, duration-only 엔트리 잔류, 플랜→포즈 클린, 즐겨찾기 이동 1/dedupe 1, 세션 revision +1, synonym 이동 5, 매핑 162 재지정, OHP 통계 행 1/흡수 0, 큐 settled, 카탈로그 708종목(713−5) + OHP synonyms 3.
- 35개 전량 리플레이(빈 DB) EXIT=0 · pgTAP 36파일 555 PASS · npm test 1,685/1,685 · 게이트 전부 통과.
- 동반 수정:
adminImportOnBehalf.test.mjs가 매니페스트 최신 번호를== 330000으로 고정 단언하고 있어>= 330000하한으로 완화(후속 마이그레이션마다 깨지던 값).
번호
하루 9번째 선점 — 처음 350000으로 잡았으나 통계 트랙 #567이 같은 번호를 스택해 380000으로 재클레임했다. HQ 세션이 이름으로 닿지 않아 착수 신고는 세션 트랜스크립트에 남겼다(HQ 프로토콜상 트랜스크립트가 정본).
남은 것
- 관리자 전면 synonym 트리 편집기 UI — Phase 2에서 기능 동등성만 유지했고(별칭 편집이 default synonym 행을 씀), 비-default synonym 생성·default 교체·별칭 이동은 아직 마이그레이션으로만 가능하다. 오너 결정(08-22): 다음에 별도 트랙.