Skip to content

종목 검색 결과 정렬 — 가나다순에 묻히던 '풀업'에서 완전 일치·내 최근·인기 등급·사용자 수 점수까지 (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.tsEXERCISE_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건) · pgTAP exercise_search_ranking_v1.test.sql(16단언, migration-smoke) · 한도 3층 잠금(limitsRegistry.test.mjs) · 카탈로그 계약 v8(screenRpcContracts.test.mjs)
  • 버그리포트: 없음(기능 트랙)
  • 계약: exercise-search.md '결과 순서' 절 신설 · app-screen-rpc-contract.md get_exercise_search_signals_v1 절 · workout-screen-props.md·desktop-screens-props.md exerciseSearchSignals

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 smallint 1~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 계약 유지).
  • lgExerciseCatalogForWorkoutpopularityTier 동반(피커 카탈로그 투영).
  • 단위 9건: 실측 순서(풀업·프레스·로우·벤치·ohp)·무신호 폴백·브라우즈·점수 수치·등급 범위·안정 정렬.

Phase 2 — 앱 연결

  • 화면 RPC 등록 5곳(타입·supabase 타입·계약·어댑터·예산) + 계약 문서 절.
  • 적재: appController가 로그인 완료 뒤 소유자당 1회 BarbelicApi.loadExerciseSearchSignalsexerciseSearchSignalsFromRows(Map 투영). 실패 시 null·경고 로그, 소유자 바뀌면 비움.
  • 전달: appController → 모바일 WorkoutFlowBindingWorkoutFlow → 종목 추가 피커 / 데스크톱 DesktopSessionScreenUiDesktopPlanEditorDkpPicker. 데스크톱 브라우즈는 즐겨찾기가 있으면 즐겨찾기 우선 유지.
  • 관리자 전체 종목 페이지: 가나다순 정렬 제거 → 엔진 순서(개인 신호 없이 등급만).

주요 결정과 그 근거

  • 신호는 별도 채널: 카탈로그는 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_v1contract_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 몫) 통과
CImigration-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회).
  • 등급표는 초안 — 오너가 이름과 점수만 말해 주면 관리자 페이지에서 바로 고친다.