종목 검색 계약 (synonym 단위)
2026-08-24 오너 확정(이슈 #673). 종목 검색이 들어가는 모든 표면이 따르는 규칙. 엔진 정본: src/react/exerciseSynonymSearch.ts.
규칙
- 구조: 종목 1 → synonym N → alias N. synonym = 동등 표기(밀리터리 프레스 ↔ 바벨 오버헤드 프레스), alias = 철자·줄임말 수준의 작은 변형(
ohp,o.h.p, 붙여쓰기). 종목의 정체성은 UUID(BRID) 하나 — 통계·집계는 전부 이 키. - 검색 모집단 = 모든 종목의 모든 synonym 합집합. 매칭된 synonym은 각각 독립된 결과 행이다. "프레스" →
바벨 오버헤드 프레스·밀리터리 프레스·오버헤드 프레스가 전부 행으로 나온다(같은 UUID). - synonym은 서로 동등 — default(대표)는 검색에서 특별 취급이 없다. default는 표기가 하나만 필요한 자리에서만 tie-breaker: 빈 질의 브라우즈 목록(종목당 1행), 집계 라벨 등.
- alias 매칭은 소유 synonym 행으로 귀속되고, 표기(이름) 매칭이 우선한다: 같은 종목에서 표기가 걸리면 alias 경유 행은 띄우지 않는다 — "밀리터리 프레스" 입력엔 밀리터리 프레스 행 하나만, 같은 종목이라는 사실은 뒷단(BRID)만 안다(08-25 오너). "o.h.p"처럼 표기 매칭이 없는 질의만 alias로
오버헤드 프레스행이 뜬다. - 행 선택 = exerciseId(BRID) + synonymId. 유저가 검색해 고른 표기가 그 엔트리의 표기로 저장·표시된다 — 운동 진행/계획/기록/세션 조회 전 구간. 같은 종목을 날마다 다른 표기로 썼어도 각 엔트리는 자기 표기를 유지하고, 집계는 BRID로 합산된다.
- 매칭은 단순 두 줄이다 — 행 생성 시 1회 접어둔(NFKC·소문자·비문자 제거) 문자열에 대해, 표기(이름·영문)는 공백 단위 토큰 AND 부분 문자열(어순 자유 — "저크 스플릿"도 "스플릿 저크"에 맞는다, 08-25 오너), alias도 같은 규칙(토큰 AND 부분 문자열) — 이슈 #1140(오너 2026-09-02 "별칭도 똑같이 검색할 수 있어야지"). 08-25의 "alias는 전체 질의 접두만" 규칙은 폐지: 그 규칙 아래서는 '푸시업'이 별칭 "디클라인 푸시업"에 안 걸려 검색 누락이 더 큰 문제였고, 창 렌더 30행(#1056)이라 결과가 늘어도 버벅임이 없다. "ov"로 별칭 "Barbell Bent Over Row"의 바벨 로우가 뜨는 것은 이제 정상이다. 토큰 분리는 공백만: "o.h.p" 같은 구두점 표기는 한 토큰으로 남는다. 철자·줄임말·붙여쓰기 변형은 로직이 아니라 alias 데이터가 담당한다(08-25 단순화 — 키 입력당 정규화·토큰 폴백 제거). 접기의 마지막 단계는 한글 자모 분해(겹모음·겹받침은 두벌식 타건 순서로: ㅘ=ㅗㅏ·ㄺ=ㄹㄱ) — IME 조합 중간 상태("오버헤드 프렛")가 목표 표기("오버헤드 프레스")의 접두가 되어 조합 중에 "결과 없음"이 번쩍이지 않는다(08-25 오너 보고). 분해도 행 생성 1회·질의 1회뿐, 키 입력 경로는 여전히 includes 하나다.
결과 순서 (2026-09-02 오너 확정, 이슈 #1101)
매칭된 행은 완전 일치 먼저 → 점수 내림차순 → 카탈로그 순으로 줄을 세운다. 빈 질의 브라우즈 목록(종목당 default 1행)도 같은 점수식이다(D3).
점수 = 0.5 × 최신성 + 0.3 × 인기 등급 + 0.2 × 실제 사용자 수 (전부 0~1)| 항목 | 정의 | 데이터 |
|---|---|---|
| 완전 일치 | 접힌 질의가 표기(한/영)나 별칭과 글자 그대로 같을 때. 별칭도 동등("ohp" → 오버헤드 프레스, "벤치" → 바벨 벤치프레스). 빈 질의는 해당 없음 | 행 생성 시 접어둔 nameTerms·aliasTerms |
| 최신성 | 내가 마지막으로 한 날부터 30일마다 반감 — 3일 전 0.93 · 30일 전 0.5 · 90일 전 0.13 · 한 번도 안 함 0 | get_exercise_search_signals_v1().mine (user_exercise_stats.last_trained_at, 저장 후 1분 안에 갱신) |
| 인기 등급 | 1~5 고정 등급을 0~1로((등급-1)/4). 5 = 누구나 아는 종목, 1 = 대부분 모르는 종목. 관리자만 고친다 | 카탈로그 항목 popularity_tier(v8) |
| 실제 사용자 수 | 그 종목을 완료 세션에서 한 번이라도 한 사용자 수를 로그 눈금으로(log(1+n)/log(1+최대)) — 초기에 1명 vs 3명이 순위를 뒤집지 않게 | get_exercise_search_signals_v1().usage (exercise_usage_stats, pg_cron 매시) |
- 신호(최신성·사용자 수)가 아직 없으면 인기 등급만으로 줄을 세운다 — 검색은 항상 동작한다.
- "짧은 이름일수록 딱 맞는다"(질의 길이 ÷ 표기 길이) 방식은 실측에서 기각됐다: 아무도 안 하는 핀 프레스·L풀업이 벤치프레스·스트릭트 풀업 위로 올라왔다(09-02 시뮬레이션, 이슈 #1101).
- 계수·반감기는
EXERCISE_SEARCH_RANKING한 곳, 실측 순서는tests/react/exerciseSearchRanking.test.mjs가 고정한다. - 성능 계약(아래 BUG-021)은 유지된다: 행당 일정한 산술 + 정렬 1회. 접기·자모 분해는 여전히 행 생성 1회·질의 1회.
문자열 매칭
질의와 표기가 "같은 글자"인지 판정하는 규칙 전체. 모든 검색 표면이 같은 접기를 공유한다(엔진 foldSynonymSearchText · 리포트/PR 검색 compactExerciseSearchText).
접기 파이프라인 (행 생성 1회 · 질의 1회)
- NFKC 정규화 — 전각/호환 문자를 통일한다. 주의: 낱자 호환 자모(ㄷ U+3137)는 이 단계에서 조합형 자모(U+1103)로 변한다.
- ko-KR 소문자화 — 영문 대소문자 무시.
- 비문자 제거 — 공백·구두점을 지운다(붙여쓰기 무시). 질의는 이보다 앞서 공백 기준 토큰으로 나뉜다.
- 한글 자모 분해 — 음절을 초·중·종 호환 자모로 편다(프레스 → ㅍㅡㄹㅔㅅㅡ). 겹모음·겹받침은 두벌식 타건 순서로(ㅘ=ㅗㅏ·ㄺ=ㄹㄱ), 조합형 자모 블록(U+1100~)도 같은 호환 자모로 합류시킨다 — 음절·호환·조합형 3계가 한 코드포인트계로 끝나야 한다(NFKC가 낱자를 조합형으로 흘려보내는 것이 "오버헤ㄷ" 0건 결함의 원인이었다).
매칭 규칙
- 표기(이름·영문) — 질의를 공백 단위 토큰으로 나눠, 모든 토큰이 접힌 표기에 부분 문자열로 맞으면 통과(AND·어순 자유). "저크 스플릿" → "스플릿 저크" ✓.
- alias — 표기와 같은 규칙(토큰 AND 부분 문자열, 어순 자유). '푸시업' → 별칭 "디클라인 푸시업" ✓, "over row" → 별칭 "Barbell Bent Over Row" ✓. (08-25 접두 전용 규칙은 이슈 #1140에서 폐지.) 토큰 분리가 공백만인 이유는 "o.h.p" 같은 구두점 표기를 한 토큰으로 남기기 위해서다.
- 자모 분해 덕에 한글 IME 조합 중간 상태가 항상 목표 표기의 접두가 된다: "프렛"=ㅍㅡㄹㅔㅅ ⊂ "프레스", "저크 슾"의 "슾"=ㅅㅡㅍ ⊂ "스플릿". 타이핑 중 "결과 없음"이 번쩍이지 않는 근거.
렌더 시점·마운트 (성능 계약, BUG-021)
- 접기·자모 분해는 행 생성 1회 + 질의 1회. 키 입력 경로는 사전 계산 문자열 스캔뿐 — 키 입력마다 로케일 정규화·"똑똑한 매칭"을 들이면 계약 위반.
- 리스트 반영은
useDebouncedSearchQuery(ui/shared): 입력창 값은 즉시, 리스트는 스페이스로 끝나면 즉시 · 엔터 즉시(IME 조합 중 제외) · 그 외 100ms 무입력 뒤(오너 기준 08-25). - 큰 리스트는
useListWindow(ui/shared): 처음 60행만 마운트, 센티널 노출 시 60씩 확장, 질의 변경 시 렌더 단계에서 60 복귀. - 역인덱스는 기각(행 ~1,000·스캔 0.3ms — 수십만 행부터 의미). 수리 연쇄 전체: bug-021 · 작업 기록.
엔진 API
ts
buildExerciseSynonymRows(items) // 카탈로그 → synonym 행 합집합 (synonym 미탑재 항목은 default 1행 합성)
filterExerciseSynonymRows(rows, query, signals?) // 빈 질의 = 종목당 default 1행 · 질의 = 매칭 행 각각 — 완전 일치 → 점수 → 카탈로그 순
scoreExerciseSynonymRow(row, signals?) // 점수식 한 행분(0~1) — signals 없으면 인기 등급 항만
pickedExerciseFromSynonymRow(row) // onPick 인자: 원본 항목 + 행 표기 + synonymId(비-default만)표면 유형 두 가지
- 피커·검색 결과 표면(종목을 골라 입력하거나 결과 목록을 보는 자리): 매칭된 synonym이 각각 독립 행으로 나온다.
filterExerciseSynonymRows사용. - 필터 표면(볼륨 선택·달력 종목 필터·그룹 담기 등 행 = 종목이고 표기 하나가 필요한 자리): 행은 종목 단위를 유지하고 매칭 모집단만 synonym 합집합으로 확장한다(tie-breaker의 정당한 용례).
createExerciseSynonymMatcher사용.
표면별 적용 현황 (이슈 #673 트랙)
| 표면 | 상태 |
|---|---|
| 데스크톱 계획/기록 피커 (DkpPicker·DkpMovePick) | ✅ Phase 2 (#690) |
| 모바일 피커 (WorkoutPicker·WorkoutExtras) | ✅ Phase 3 (#692) |
| alias 데이터 배관 (카탈로그 RPC synonym별 aliases, 상한 4→10) | ✅ Phase 4 (#694, 680000) |
계획 표기 보존 (당시 planned_sets.synonym_id — 이슈 #1215(2026-09-04) 이후 계획도 session_exercise_part.synonym_id) | Phase 5 (#699, 710000) |
| 리포트/PR 공용 검색 (exerciseSearch.ts — 대표 우선 1행 규칙 폐기, synonym 행) | Phase 6 |
| 데스크톱 수동 PR 모달·PR 상세 드롭다운 | Phase 6 |
| 데스크톱 볼륨 선택·즐겨찾기 그룹·통합 검색·세션 달력 필터 (매처) | Phase 6 |
| 모바일 세션 검색 탭 (SetSearch — synonym 행) | Phase 6 |