diff --git a/docs/architecture/explore-facets-sort-contract.md b/docs/architecture/explore-facets-sort-contract.md new file mode 100644 index 000000000..30b44c94a --- /dev/null +++ b/docs/architecture/explore-facets-sort-contract.md @@ -0,0 +1,176 @@ +# Explore Facets & Sort — Backend Contract (#842) + +> **Status:** Proposal (philippe 비준 대기) · **Part of** #825 (PRD) / map #789 · **Unblocks** #851 +> **Owner:** contract/design = 본 문서 · Rust/infra 구현 = philippe +> **Scope:** 계약·설계만 (FE 실배선 = #851 graduated, BE 구현 = 후속 슬라이스) + +--- + +## TL;DR — load-bearing 사실 (스코프를 다시 잡는다) + +이 슬라이스는 "meilisearch faceting 프로젝트"가 아니다. **인덱스는 이미 대부분 구성돼 있다.** + +1. **meili `posts` 인덱스는 이미 sortable/filterable 설정 완료** (`services/search/index_config.rs`): + - `POSTS_SORTABLE = [created_at, solution_count, view_count]` → **`solution_count` 정렬 가능** + - `POSTS_FILTERABLE = [category_codes, context, has_adopted_solution, media_type, status]` → **facet 축 이미 filterable(=meili facetable)** +2. **`solution_count` 정렬은 이미 `/search` 엔드포인트에서 동작** (`domains/search/service.rs:66`, `"solution_count" => solution_count:desc`). +3. **facet distribution 갭은 작다** — 인덱스 설정이 아니라 *배선* 2줄: + - `services/search/meilisearch.rs::advanced_search`가 `with_facets(...)`를 **호출하지 않음** + - `domains/search/service.rs:153-159`가 `Facets`를 **`None`으로 하드코딩** +4. **진짜 설계 결정은 하나뿐**: **Explore browse 그리드(DB 경로)**가 어떻게 facets + `solution_count` 정렬을 얻느냐. → Part B (philippe 비준). + +--- + +## 1. 현재 현실 (grounded) + +Explore는 **쿼리 유무로 갈리는 이중 경로**다 (`web/lib/hooks/useExploreData.ts`): + +| | **Browse 모드** (query 없음) | **Search 모드** (query 있음) | +|---|---|---| +| FE 진입 | `useInfinitePosts` (`useExploreData.ts:104-115`) | `search()` (`useExploreData.ts:117-135`) | +| BE 엔드포인트 | `GET /api/v1/posts` → DB `list_posts` (`domains/posts/service.rs:1477`) | `GET /api/v1/search` → meili (`domains/search/service.rs:29`) | +| sort 지원 | `recent` / `popular` / `trending` (`posts/dto.rs:249`, DB 컬럼) | `recent` / `popular` / `solution_count` / `relevant` (`search/service.rs:63-68`, meili) | +| facets | **없음** — browse 모드에선 client 집계도 `{}` (`useExploreData.ts:138-140`) | client-side 집계, **로드된 페이지 window만** (`buildFacets`, `useExploreData.ts:57-75`) — 서버 facetDistribution 아님 | + +### 지금 깨져 있는/부정직한 지점 + +- **`solution_count` 정렬이 browse에서 조용히 강등**: `useExploreData.ts:110-114`가 `solution_count`를 매핑에서 누락 → `"recent"`로 폴백. FE `SortDropdown.tsx`는 `"Most Solutions"`(`solution_count`)를 노출하지만 browse 모드에선 무효. (interim 정직화 = #843, graduated = #851) +- **두 개의 sort 컨트롤이 불일치**: `SortDropdown.tsx` = `{recent, popular, solution_count}` vs `ExploreSortControls.tsx` = `{trending, recent, popular}`. `solution_count`와 `trending`이 서로 다른 컨트롤에만 존재. +- **facets가 client-side·window-limited**: 무한스크롤로 로드된 항목만 집계 → 전역 분포가 아니고, browse 모드에선 아예 비어 있음. + +### meili `posts` 문서가 이미 담는 필드 (`batch/search_reindex.rs` + `compute_search_fields`) + +`category_codes`, `context`, `media_type`, `has_adopted_solution`, `status`, `solution_count`, `spot_count`, `view_count`, `created_at`, `artist_name`, `group_name`, `title`, `image_url`, `id`. + +--- + +## 2. Part A — FE-facing 응답 계약 (지금 확정 가능 · #851 언블록) ✅ + +> 이 절은 backend 구현과 **무관**하다. #851은 이 shape에 대고 지금 빌드할 수 있다. + +### 2.1 Sort 파라미터 계약 + +`sort` 쿼리 파라미터에 **`solution_count`를 1급 옵션으로 승격**. 정본 vocab: + +``` +sort ∈ { relevant, recent, popular, trending, solution_count } +``` + +- `relevant`: 검색 관련도 (search 모드 전용, browse에선 `recent`로 취급) +- `recent`: 최신순 (`created_at:desc`) +- `popular`: 조회수 (`view_count:desc`) +- `trending`: `trending_score:desc` (**browse/DB 전용** — meili 문서엔 `trending_score` 없음, §3.3 참조) +- `solution_count`: 채택 solution 수 (`solution_count:desc`) + +> 미지원 조합은 **에러가 아니라 문서화된 폴백**: search 모드의 `trending` → `relevant`, browse 모드의 `relevant` → `recent`. FE(#843)는 현재 모드에서 미지원인 옵션을 **숨긴다**(정직화). + +### 2.2 Facets 응답 계약 (이미 존재, 이 shape로 고정) + +`SearchResponse.facets` = `Facets` (`domains/search/dto.rs:126-140`). **이 shape를 계약으로 확정**: + +```jsonc +// GET /api/v1/search 및 (graduated 후) GET /api/v1/posts 응답의 facets 필드 +"facets": { + "category": { "fashion": 45, "living": 12 }, // Option> + "context": { "airport": 8, "stage": 5 }, // Option> + "media_type": { "drama": 20, "mv": 7 } // Option> +} +``` + +- **키 = facet 값, 값 = 개수(u32)**. 축이 비면 필드 생략(`skip_serializing_if`). +- **노출 축은 `POSTS_FILTERABLE` 부분집합**: `category`(=`category_codes`) / `context` / `media_type`. (`has_adopted_solution`·`status`는 boolean/internal이라 facet UI 축 아님.) + +### 2.3 Facet **count semantics** — 반드시 고정 (#851 UX 좌우) 📌 + +meili `facetDistribution`은 **현재 필터 컨텍스트 반영(post-filter)**. 계약 채택: + +- **post-filter counts (권장)**: 적용된 다른 필터를 반영해 개수 갱신 (예: `category=fashion` 선택 시 `context` 개수가 fashion 내로 좁혀짐). +- FE(#851)는 "필터링하면 개수가 갱신된다"를 전제로 UI 설계. +- (대안 = 전역 개수. 채택 안 함 — 하지만 **명시적 결정 사항**이므로 philippe 확정 필요.) + +--- + +## 3. Part B — Backend 구현 옵션 (philippe 비준) ✋ + +핵심 질문: **Explore browse 그리드가 어떻게 facets + `solution_count` 정렬을 얻는가?** (search 모드는 meili라 이미 대부분 확보.) + +### Option 1 — browse를 meili empty-query로 태운다 (통합) + +- browse도 `/search`(빈 `q`)로 라우팅. meili는 빈 쿼리 + 필터/정렬/facet 지원. +- **장점**: `solution_count` 정렬·facetDistribution·모든 필터 축을 인덱스 설정 그대로 획득(추가 인프라 0). facet count가 자동으로 post-filter. +- **단점 / parity 리스크**: 현행 DB `list_posts`의 기능이 meili 문서에 **없음** — + - `trending_score` 정렬 (meili 미색인) + - `has_magazine` / `include_magazine_items` (DB 조인, 홈 매거진 카드) + - `viewer_id` 기반 비공개 decode 노출 규칙 (#746) + - `artist_id` / `group_id` (warehouse FK 필터) + → 이들을 meili 문서/필터로 이관하거나 이원화해야 함. **큰 이관.** + +### Option 2 — browse(DB)에 집계·정렬 추가 (경로 유지) + +- `list_posts`(SeaORM)에 `solution_count` 정렬 컬럼 + facet 개수 `GROUP BY` 쿼리 추가. +- **장점**: `list_posts`의 기존 시맨틱(#746·매거진·FK 필터·trending) 전부 보존. +- **단점**: SQL 증가. facet 개수 쿼리 N개(축별) 또는 단일 집계 쿼리 설계 필요. meili facet 인프라 재사용 못 함. + +### Option 3 — 하이브리드 (그리드 DB + facet-only meili) + +- 그리드는 DB, facet 개수만 별도 meili facet-only 호출로 획득, `solution_count` 정렬은 DB에 추가. +- **단점**: 두 소스 정합성/일관성 관리 부담. 비권장. + +### 권장 (제안, philippe 확정) + +- **`solution_count` 정렬**: Option 2 방향 — browse(DB) `list_posts`에 `solution_count` 정렬 추가. FE `SortDropdown`이 이미 노출 중이고 실수요 확인됨(#851 AC). +- **facet distribution**: 우선 **search 모드부터** 저비용 배선(§3.1)으로 착지 → browse facet은 Option 1 vs 2를 데이터 규모/레이턴시 보고 후속 결정. #851은 §2 계약에 대고 진행하되 browse facet은 graduated 2단계로 분리 가능. + +### 3.1 저비용 착지 — search 모드 facet 배선 (인덱스 변경 0) + +인덱스가 이미 filterable이므로 다음만 하면 search facets가 즉시 산다: + +1. `meilisearch.rs::advanced_search`에 `facets: Vec<&str>` 인자 추가 → `search.with_facets(Selectors::Some(&["category_codes","context","media_type"]))`. +2. 결과 JSON의 `facetDistribution`을 스레딩. +3. `search/service.rs:153-159`에서 `Facets`를 `facetDistribution`으로 populate (하드코딩 `None` 제거). `category_codes` → 계약상 `category`로 키 매핑. + +### 3.2 `solution_count` 정렬 착지 + +- **search 모드**: 이미 동작 — 추가 작업 없음. +- **browse 모드**: `posts/dto.rs` sort vocab에 `solution_count` 추가 + `list_posts`에 정렬 분기 추가. (posts 테이블에 solution 수 집계 컬럼/조인 필요 — solution_count 소스 확인은 philippe.) + +### 3.3 `trending` 축 주의 + +`trending`은 **DB 전용**(`Column::TrendingScore`, `posts/service.rs:1610`). meili 문서엔 `trending_score` 미색인. Option 1 채택 시 `trending_score`를 meili 문서+`POSTS_SORTABLE`에 추가해야 parity. 계약 vocab(§2.1)은 `trending`을 browse 전용으로 명시. + +--- + +## 4. #851 언블록을 위한 추가 계약 항목 — `style_tags` 서버 필터 ⚠️ + +#851의 세 번째 AC("editorial 칩 → `style_tags` 서버 필터 배선")는 **현재 인프라로 불가**: + +- `posts.style_tags` 컬럼은 **존재**(JSONB, AI 추출 fire-and-forget, #395 — `posts/dto.rs:453`)하지만 +- meili `posts` 문서에 **미색인** + `POSTS_FILTERABLE`에 **없음**. + +→ 서버 필터로 만들려면: (a) `style_tags`를 meili 문서에 색인 + `POSTS_FILTERABLE` 추가, (b) 택소노미 vocab 확정 = **#849(Style DNA SSOT) 의존**. 이 항목은 **#842 헤드라인(sort+facet) 밖**이며, #851 착지 전 별도 계약/구현 필요. #849 SSOT 확정 후 다뤄야 함. + +--- + +## 5. Acceptance criteria 매핑 (#842) + +| AC | 상태 | +|---|---| +| (a) `solution_count` 정렬 + facet distribution의 OpenAPI 계약 shape **합의** | §2.1·§2.2 제안 — **philippe 비준 대기** ✋ | +| (b) meilisearch faceting 설정 계획 **확정** (filterable/facet 축) | §3.1 (인덱스 이미 filterable) + count semantics §2.3 제안 — **philippe 비준 대기** ✋ | +| (c) FE 소비 facets 응답 계약 **문서화** (graduated 언블록) | §2 — **완료** ✅ | + +**본 문서는 제안이다.** AC (a)·(b)는 philippe 확정이 필요하므로 **#842는 OPEN 유지**. FE(#851)는 (c)에 대고 착수 가능. + +--- + +## 6. philippe에게 확인할 열린 질문 + +1. browse facet: **Option 1(meili 통합)** vs **Option 2(DB 집계)** — 데이터 규모/레이턴시 기준 선택? +2. facet count = **post-filter**(§2.3 권장) 확정? +3. browse `solution_count` 정렬의 소스 — posts에 집계 컬럼 있나, 조인으로 계산하나? +4. `style_tags` 서버 필터(§4)는 #851에 포함할지, #849 이후 별도 슬라이스로 뺄지? + +## 관련 + +- PRD #825 · map #789 · graduated FE #851(blocked by #842) · interim FE #843 · Style DNA SSOT #849 +- 코드: `services/search/index_config.rs` · `domains/search/{service,dto}.rs` · `services/search/meilisearch.rs` · `domains/posts/service.rs::list_posts` · `web/lib/hooks/useExploreData.ts` · `web/lib/components/explore/{SortDropdown,ExploreSortControls}.tsx`