Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions docs/architecture/explore-facets-sort-contract.md
Original file line number Diff line number Diff line change
@@ -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<Map<string, u32>>
"context": { "airport": 8, "stage": 5 }, // Option<Map<string, u32>>
"media_type": { "drama": 20, "mv": 7 } // Option<Map<string, u32>>
}
```

- **키 = 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`
Loading