종목 검색 결과 정렬 — 가나다순에 묻히던 '풀업'에서 완전 일치·내 최근·인기 등급·사용자 수 점수까지 (2026-09-02)
- 기간: 2026-09-02 (세션 1개,
b79a42e2-809b-4a4e-a9cf-b42623852f19). 오너 보고: "풀업이라고 치면 이런것들(네거티브 풀업…)이 먼저 나오고, 정작 '풀업'은 저 아래쪽에 있단말야 — 1. 더 딱 들어맞는 애들이 위에, 2. 최근에 했던거를 위에, 3. 사람들이 보편적으로 더 많이하는거를 위에" - 랜딩: PR #1115(Phase 0~2,
780ae845). 마이그레이션 1건20260902140000(exercise_search_ranking_v1) — Production 적용은 릴리스 레인(main → production PR, 앱 배포와 한 몸). 새 pg_cron 잡 1개(매시 23분) - 설계서: 이슈 #1101 본문 + 댓글 3건(분석·점수식 확정안·등급표, "예상 효과·개선사항" 절 포함)
- 정본:
docs/contracts/exercise-search.md'결과 순서' 절 · 엔진exerciseSynonymSearch.ts의EXERCISE_SEARCH_RANKING·scoreExerciseSynonymRow·rankExerciseSynonymRows· 서버get_exercise_search_signals_v1·refresh_exercise_usage_stats_v1·exercises.popularity_tier· 한도limits-registry.md'검색 정렬 신호' 행 - 도구: 세션 스크래치패드 시뮬레이션 스크립트(sim.mjs·sim2.mjs — Production 카탈로그 795종에 점수식을 돌려 순서 비교, 커밋 안 함) · Production dry-run(DO-raise 롤백)
- 게이트:
exerciseSearchRanking.test.mjs(실측 순서 9건) ·exerciseSearchSignalsAdapter.test.mjs(어댑터·투영 3건) · pgTAPexercise_search_ranking_v1.test.sql(16단언, migration-smoke) · 한도 3층 잠금(limitsRegistry.test.mjs) · 카탈로그 계약 v8(screenRpcContracts.test.mjs) - 버그리포트: 없음(기능 트랙)
- 계약:
exercise-search.md'결과 순서' 절 신설 ·app-screen-rpc-contract.mdget_exercise_search_signals_v1절 ·workout-screen-props.md·desktop-screens-props.mdexerciseSearchSignals
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 서버: exercises.popularity_tier(1~5) + 등급표 백필 · exercise_usage_stats + 갱신 함수 + pg_cron 매시 · get_exercise_search_signals_v1() · 카탈로그 응답 v8 · 관리자 등급 편집 · pgTAP | ✅ PR #1115 |
| Phase 1 | 엔진: 완전 일치 최상단 + 점수식 정렬(빈 검색창 포함) · 단위 9건 · 계약 문서 | ✅ PR #1115 |
| Phase 2 | 앱: 신호 RPC 계약·어댑터·예산 · 로그인 후 소유자당 1회 적재 · 모바일/데스크톱 피커 배선 · 관리자 전체 종목 페이지 엔진 순서 | ✅ PR #1115 |
| Phase 3 | 세션 검색 탭·보드 검색·리포트 검색에 신호 정렬 | ⬜ 오너 결정 D4: 선택 — 미착수 |
1. 배경
검색 엔진(#673, 08-24)은 synonym 합집합에서 매칭된 행을 골라내기만 했고 순서는 카탈로그가 내려준 순서(sort_order → 가나다)였다. 계약 문서에도 "행 순서 = 카탈로그 순서"라고 적혀 있었다. 오너가 09-02 관리자 전체 종목 페이지에서 "풀업"을 쳐 보니 네거티브 풀업·뉴트럴그립 풀업이 먼저 나오고 풀업은 26번째쯤이었다(그 페이지는 이름 가나다순으로 따로 정렬). 운동 중 종목 추가 피커는 카탈로그 순서라 풀업이 먼저였지만, 어느 화면도 "내가 최근에 한 것"이나 "사람들이 많이 하는 것"을 반영하지 않았다.
2. 문제 제기
검색 결과에 줄 세우기가 없었다
filterExerciseSynonymRows는 매칭 여부만 판정. 관리자 페이지는 그 위에 가나다순을 덧씌워 "풀업"이 "네거티브 풀업"보다 아래로 갔다.
'내 최근'·'전체 인기' 데이터는 있는데 검색까지 안 왔다
Production 실측(09-02): user_exercise_stats 515행/5명(사용자×종목 마지막 수행일 보유), 완료 세션이 있는 종목 426/카탈로그 983, 상위 = 백스쿼트(4명·312세션)·벤치프레스·러닝·딥스·풀업(3명·118). 카탈로그 응답은 catalog_version 정적 캐시라 사용자별 값을 실을 수 없었고, 종목별 사용자 수 집계 표는 없었다.
"짧은 이름이 더 딱 맞는다"는 비율 방식은 실측에서 틀렸다
오너가 처음 제안한 "검색어 길이 ÷ 표기 길이"를 카탈로그 795종에 돌려 보니 아무도 안 하는 핀 프레스(0.75)가 벤치프레스·숄더프레스(0.60) 위로, L풀업(0.67)이 2명·123세션의 스트릭트 풀업(0.33) 위로 올라왔다. 비율은 값이 촘촘해 동률이 거의 없어 최근·인기 신호가 작동할 자리도 없었다.
3. 해결 방안
원칙 (오너 결정, 2026-09-02)
- D1 완전 일치는 맨 위 고정, 그 아래는 비율이 아니라 최신성·인기·실제 사용자 수의 선형합(계수는 세션이 정함). "길이가 짧다고 검색결과에 더 적합한건 아닌거 같네."
- D2 최신성 = 마지막 수행일 기준(기존 표로 즉시 가능). 채택.
- D3 빈 검색창 목록도 같은 점수식. 채택.
- D4 적용 범위 = 종목 추가 피커(모바일·데스크톱) + 관리자 전체 종목 페이지 먼저, 나머지 표면은 선택.
- D5 사용자 수 집계는 1시간 주기. 채택.
- 인기 등급표: 오너 요청으로 세션이 전 종목 795개를 1~5점으로 분류(5점 36 · 4점 79 · 3점 162 · 2점 273 · 1점 245), 관리자 페이지에서 고칠 수 있게.
접근
| 대안 | 판정 |
|---|---|
| 엔진 점수식 정렬 + 별도 신호 RPC + 사용자 수 집계 표(pg_cron) | 채택 — 카탈로그 캐시 계약 불변, 신호 없어도 등급만으로 동작 |
| 카탈로그 RPC에 최근·인기 동봉 | 기각 — 정적 캐시에 사용자별 값이 섞임 |
| 클라이언트에 있는 데이터(홈 최근 세션 8개·PR 개요)로 계산 | 기각 — 너무 얇거나 탭 전용 대용량 |
| 인기를 호출 때마다 즉석 집계 | 기각 — 사용자×종목 행이 늘면 피커 열 때마다 전량 집계 |
| 비율(질의 길이 ÷ 표기 길이) 정렬 | 기각 — 위 실측 |
점수식: 0.5×최신성(0.5^(경과일/30)) + 0.3×(등급-1)/4 + 0.2×log(1+사용자수)/log(1+최대). 계수 0.4/0.4/0.2로 바꿔도 상위 순서가 같아 0.5/0.3/0.2를 택했다. 사용자 수를 로그 눈금으로 둔 이유는 초기 5명 규모에서 1명 vs 3명 차이가 순위를 뒤집지 않게 하기 위해서다.
4. 적용한 내용
Phase 0 — 서버 (20260902140000)
exercises.popularity_tier smallint1~5(CHECK) + 등급표 백필 UPDATE(795행 VALUES, 없는 id는 건너뜀). 문장 트리거로catalog_version자동 증가.exercise_usage_stats(exercise_id·user_count·session_count·refreshed_at) +refresh_exercise_usage_stats_v1()(특권 워커 전용,user_exercise_stats를 종목별로 합쳐 덮어쓰고 사라진 종목 행 삭제) +cron.schedule('barbelic-exercise-usage-stats-refresh', '23 * * * *'). 마이그레이션 안에서 최초 1회 실행.get_exercise_search_signals_v1()(문,auth.uid()1회): mine ≤512(최신순 절삭 + 플래그)·usage ≤4,096(사용자 수 내림차순 절삭 + 플래그)·600KB 천장.get_exercise_catalog재발행: 항목popularity_tier, contract_version 7 → 8.- 클라: 카탈로그 계약 v8(어댑터는 1~5 정수 검사, 구 캐시 이음새로 부재 허용)·정규화
popularityTier·관리자 종목 상세 '인기 등급' 행 + 연필 편집(선택지 5개와 설명)·편집 저장 경로. - pgTAP 16단언. 한도 장부 행 + 3층 잠금.
Phase 1 — 엔진
filterExerciseSynonymRows(rows, query, signals?)→rankExerciseSynonymRows: 완전 일치(접힌 질의 == 표기/별칭) → 점수 → 원래 순(안정 정렬). 빈 질의도 동일.- 등급 점수는 행 생성 시 사전 계산(
popularityScore) — 키 입력 경로는 행당 상수 산술 + 정렬 1회(BUG-021 계약 유지). lgExerciseCatalogForWorkout에popularityTier동반(피커 카탈로그 투영).- 단위 9건: 실측 순서(풀업·프레스·로우·벤치·ohp)·무신호 폴백·브라우즈·점수 수치·등급 범위·안정 정렬.
Phase 2 — 앱 연결
- 화면 RPC 등록 5곳(타입·supabase 타입·계약·어댑터·예산) + 계약 문서 절.
- 적재:
appController가 로그인 완료 뒤 소유자당 1회BarbelicApi.loadExerciseSearchSignals→exerciseSearchSignalsFromRows(Map 투영). 실패 시 null·경고 로그, 소유자 바뀌면 비움. - 전달: appController → 모바일
WorkoutFlowBinding→WorkoutFlow→ 종목 추가 피커 / 데스크톱DesktopSessionScreen→UiDesktopPlanEditor→DkpPicker. 데스크톱 브라우즈는 즐겨찾기가 있으면 즐겨찾기 우선 유지. - 관리자 전체 종목 페이지: 가나다순 정렬 제거 → 엔진 순서(개인 신호 없이 등급만).
주요 결정과 그 근거
- 신호는 별도 채널: 카탈로그는
catalog_version캐시라 사용자별 값이 들어가면 캐시를 매번 버려야 한다. - 집계는 주기 작업: 저장 경로에 트리거를 얹으면 저장이 느려지고 실패 지점이 늘어난다. 원본 표가 이미 정확하니 주기 집계가 안전하고, "사람이 늘어야 바뀌는 느린 숫자"라 1시간 지연이 체감되지 않는다.
- 절삭은 거부가 아님(한도 장부 원칙 1): 정렬 신호는 일부만 있어도 쓸 수 있다.
작업 중 드러난 것
- 오너의 첫 스크린샷은 관리자 전체 종목 페이지(이름 가나다순)였고, 운동 중 피커는 카탈로그
sort_order순이라 이미 풀업이 먼저였다. 그래도 최근·인기 정렬은 모든 화면에 유효. scripts/check-test-manifest.mjs --update는 감사 전용이다 — 실행하면 manifest 전체를 재생성하고 pending-changes를 비운다. 기능 PR에서는 되돌리고, 등재된 기존 테스트 수정만 pending-changes에 신고한다(새 테스트 파일은 신고 불요).- 마이그레이션 사후 검사에서
pg_get_functiondef로 함수 본문을 읽으면 lint가 "동적 치환"으로 거부한다 —pg_proc.prosrc like로 대체. - Production 사용자 5명은 크로스핏·역도 성향이 강해 등급표(일반 대중 기준)와 실제 사용(하이 클린·스내치 풀·머슬업)이 어긋난다 — 그래서 사용자 수 항을 따로 두고 등급은 0.3 가중치로만.
- 카탈로그 계약 버전 올림은
ScreenRpcContractVersion유니언(1|…|8)도 함께 늘려야 한다 — 안 늘리면 payload 타입이 never로 무너진다. react-hooks/exhaustive-deps억제 주석은 baseline 심사 대상이라 새로 넣으면 게이트가 막는다 — ref로 최신 값을 읽는 방식으로 우회.- 카탈로그 계약 버전을 올리면 pgTAP도 본다: 로컬
npm run check는 pgTAP을 못 돌리므로,exercise_catalog_synonym_aliases·record_input_logic_consumption_v1의contract_version = 7단언이 CI migration-smoke에서야 드러났다(잠금 반납 → 수정 → 재줄서기 1회). 다음에 버전을 올릴 땐grep -rn "contract_version" supabase/tests/database를 먼저. - 재번호 도구는 "브랜치가 만진 파일 속 번호"를 전부 바꾼다 — 내 임시 번호(20260902100000)가 마침 #1110의 실제 번호와 같아, 내가 편집한 계약 문서 안의 #1100 주석 두 줄까지 140000으로 바뀌었다. 재번호 뒤
git diff -U0로 바뀐 토큰을 훑어 남의 참조는 되돌릴 것. 임시 번호는 그날 다른 트랙이 쓸 법하지 않은 시각(예: 235900)으로.
5. 적용 결과
| 항목 | 결과 |
|---|---|
| "풀업" 결과 1위 | 관리자 페이지 26번째쯤 → 1번째(완전 일치, 단위 테스트 고정) |
| "프레스" 상위 | 핀 프레스(1등급·0명) 1위 → 인클라인 바벨 벤치프레스(7일 전)·푸쉬프레스(2일 전) → 오버헤드/숄더프레스(5등급) · 핀 프레스 최하위 |
| 등급 백필 | Production dry-run: 550행 갱신(5점 36 · 4점 79 · 3점 162 · 2점 273 · 1점 523 — 비활성·외부 포함), catalog_version 639 → 640 |
| 사용자 수 집계 | dry-run 417행, 상위 백스쿼트 4명 → 벤치프레스·러닝·딥스·풀업 3명 |
| 로컬 게이트 | npm run check 단위 2,307 → 2,334(신설 12 + 리베이스로 들어온 #1110 몫) 통과 |
| CI | migration-smoke(pgTAP 16단언 + 기존 카탈로그 v8 단언 2건)·remote-schema 대조 — PR #1115 2차 런 초록(1차는 옛 v7 pgTAP 단언으로 빨강) |
| 미검증 | 실기기 체감 순서(자동 증거 없음 — §14) · e2e 없음(서버 통계 시드 필요) · 신호 갱신 주기: 저장 후 다음 로그인(소유자당 1회 적재)까지 최신성이 이전 값 — 후속에서 저장 후 재적재 여부 판단 |
6. 이번 개선으로 향상된 것
검색어와 딱 맞는 종목이 항상 맨 위
표기든 별칭이든 완전 일치는 점수와 무관하게 1위. "ohp"·"벤치"도 동일.
한두 글자만 쳐도 내가 하던 종목이 위로
최신성 항(30일 반감)이 0.5 가중치라 최근 한 종목이 같은 등급의 다른 종목보다 위로 온다.
처음 보는 종목군에서도 일반적인 것부터
인기 등급(고정) + 실제 사용자 수(1시간 집계)가 남은 순서를 정한다.
구조적으로 남는 것
- 정렬 규칙이 계약 문서 한 절과 엔진 한 곳(
EXERCISE_SEARCH_RANKING)에 산다 — 표면별 제각각 정렬 금지. - 신호 채널(
get_exercise_search_signals_v1)이 생겨 이후 추천·즐겨찾기 자동 제안에 재사용 가능. - 인기 등급은 관리자 페이지에서 언제든 고칠 수 있다.
남은 것
- Phase 3(선택): 세션 검색 탭·보드 종목 검색·리포트/PR 검색(
exerciseSearch.ts)에 신호 정렬. - 저장 직후 최신성 재적재(지금은 로그인당 1회).
- 등급표는 초안 — 오너가 이름과 점수만 말해 주면 관리자 페이지에서 바로 고친다.