Skip to content
122 changes: 122 additions & 0 deletions docs/research/2026-07-22-801-search-backend-country-locale-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
title: "검색 백엔드의 country/region·locale 파라미터 계약 — #801 재검증 리서치"
date: 2026-07-22
status: final
doc: snapshot
essence: aligned
owner: kiyori
tags:
- research
- search
- backend
- i18n
- market
- meilisearch
related_issues:
- "#801 (Rust SearchQuery/EntitySearchQuery country·locale 지원 여부)"
- "#789 (front-end map)"
- "#796 / #799 (D-SEARCH vs D-BACKEND 결정)"
---

# 검색 백엔드의 country/region·locale 파라미터 계약 — #801 재검증

> **검증 기준 커밋:** `origin/dev` = **`a3d5702b`** (2026-07-22 fetch). 브랜치명만으로는 머지 후 앵커 복원이 불가하므로 커밋을 고정한다 — 이 문서 자신이 2주 만에 앵커가 수십 줄 이동함을 보인다.
> 방법: 리포 소스·마이그레이션·`packages/api-server/openapi.json`(체크인된 계약) 1차 소스만. **라이브 시스템 조회 없음**(prod 크레덴셜 미보유).

## 결론

**검색 계약에는 country/region 축이 전혀 존재하지 않으며, 유일하게 존재하는 `locale`은 필터축이 아니라 응답 표시명 폴백축이다.** posts 검색(`GET /api/v1/search`)의 `SearchQuery`는 `q/category/media_type/context/has_adopted/sort/page/limit` 8개뿐으로 country·locale 모두 미수용(`packages/api-server/src/domains/search/dto.rs:13-45`), entity 검색(`GET /api/v1/search/entities`)의 `EntitySearchQuery.locale`은 Meili 쿼리에 전달되지 않고 히트 수신 **후** `map_entity_hit`의 이름 선택에만 쓰인다(`service.rs:374-380`, `service.rs:410-424`). Meilisearch 색인 쪽도 `ENTITIES_FILTERABLE = ["kind"]` 단독이고 `POSTS_FILTERABLE`에도 country가 없으며(`services/search/index_config.rs:11-17,33`), entities 문서 빌더가 만드는 필드에 country가 아예 포함되지 않는다(`batch/entity_reindex.rs:64-74`). Market 축(`market.rs`)은 검색 핸들러에 **한 번도 배선되어 있지 않다**(`RequestMarket`은 catalog_entities·solutions 핸들러 전용). 국가 데이터 소스는 `public.brands.country_of_origin`(브랜드 설립국, default `'NA'`) 하나뿐이며 그마저 컬럼/메타데이터 이중 접근으로 갈라져 있고 검색 색인에 유입되지 않는다. → **2026-07 baseline은 CONFIRMED**(라인 드리프트만 존재), 결정은 **D-BACKEND**.

## 근거 표

| 주장 | file:line | 확인 |
| --- | --- | --- |
| posts `SearchQuery`에 country/region/locale 필드 없음 (q/category/media_type/context/has_adopted/sort/page/limit뿐) | `packages/api-server/src/domains/search/dto.rs:13-45` | ✅ 확인 (baseline `14-45` → 현재 `13-45`, 필드 구성 동일) |
| `SearchQuery`에 `deny_unknown_fields` 없음 → axum `Query` 추출 시 `locale`/`country` 키가 조용히 **버려짐** | `dto.rs:13`(derive 목록), `domains/search/handlers.rs:42` | ✅ 확인 (파생은 `Deserialize, IntoParams, ToSchema`뿐, serde 기본=미지정 필드 무시) |
| posts 검색의 Meili 필터 구성에 country 분기 없음 (category/media_type/context/has_adopted 4개만) | `domains/search/service.rs:45-60` | ✅ 확인 |
| `EntitySearchQuery.locale`은 "응답 표시명 로케일(ko\|en, 미지정 시 ko→en 폴백)"으로 doc-comment에 명시 | `dto.rs:250-263` | ✅ 확인 (**baseline과 라인 완전 일치**) |
| `locale`이 Meili 필터/쿼리에 전달되지 않음 — `advanced_search`에 넘기는 filters는 `kind_filter(kind)` 결과뿐, sort·facet 인자는 빈 벡터 | `service.rs:410-415`, `service.rs:366-371` | ✅ 확인 |
| `locale`은 히트 수신 후 `pick_localized(name_en, name_ko, locale)` 이름 선택 + `enrich_entity_hits` 표시명에만 사용 | `service.rs:374-380`, `service.rs:422-424` | ✅ 확인 |
| `ENTITIES_FILTERABLE = ["kind"]` 단독 | `packages/api-server/src/services/search/index_config.rs:33` | ✅ 확인 (baseline "11-26 블록" → 현재 33행 단독 상수) |
| `POSTS_FILTERABLE`에 country/market 없음 (category_codes/context/has_adopted_solution/media_type/status) | `index_config.rs:11-17` | ✅ 확인 |
| Meili `localizedAttributes`는 **색인 설정 시점 정적 값**이지 요청별 필터가 아님 (posts=kor/eng, entities=kor/eng/jpn) | `index_config.rs:83-96`, `index_config.rs:154-167` | ✅ 확인 — 형태소 분석용 언어 힌트일 뿐 검색 결과 대상 집합을 좁히지 않음 |
| entities Meili 문서에 country 필드 자체가 없음 (id/entity_id/kind/name_ko/name_en/name_ja/profile_image_url/logo_image_url/aliases) | `packages/api-server/src/batch/entity_reindex.rs:64-74` | ✅ 확인 — 색인 설정을 고쳐도 **색인할 데이터가 없음** |
| 체크인된 OpenAPI 계약: `/api/v1/search`=q,category,media_type,context,has_adopted,sort,page,limit / `/search/entities`=q,type,locale,limit / `/search/similar`=q,entity_type,limit / `/search/recent`=limit → **어느 엔드포인트도 country/market 미수용** | `packages/api-server/openapi.json` (paths `/api/v1/search*`) | ✅ 확인 |
| `country_of_origin` 컬럼은 `public.brands`에 실재 (text NOT NULL DEFAULT 'NA') | `supabase/migrations/20260518152600_entity_enrichment_pipeline.sql:24` | ✅ 확인 (baseline과 동일 라인) |
| 해당 컬럼 의미 = "Country where the brand was founded; NA if unknown" (**설립국 = 공급자 origin**) | `supabase/migrations/20260518152600_entity_enrichment_pipeline.sql:71-72` | ✅ 확인 |
| 리포 전체 마이그레이션 중 country를 언급하는 파일은 이 1건뿐 — artists/groups/posts에 country 컬럼 없음 | `supabase/migrations/` grep `country` | ✅ 확인 (`grep -rln country supabase/migrations/` → 1 파일) |
| SeaORM `brands` 엔티티가 `country_of_origin`을 매핑하지 않음 | `packages/api-server/src/entities/brands.rs:7-35` | ✅ 확인 (id/name_ko/name_en/name/logo_image_url/primary_instagram_account_id/metadata/aliases/created_at/updated_at) |
| ⚠️ 신규: 같은 country 값이 **두 경로로 갈라져 접근**됨 — 관리자 gap 조회는 실제 컬럼(`c.country_of_origin`), 브랜드 상세는 metadata JSON(`b.metadata ->> 'country_of_origin'`) | `packages/api-server/src/domains/admin/catalog_gaps.rs:755,772`, `packages/api-server/src/domains/catalog_entities/service.rs:127-131` | ✅ 확인 — 데이터 준비도 판단 시 핵심 리스크 |
| Market 축 정의: ISO 3166-1 alpha-2 + INTL, `SUPPORTED=[KR,JP,US,INTL]`, `LIVE=[KR]`, fallback=INTL, **locale과 직교**임이 주석에 명시 | `packages/api-server/src/market.rs:1-11` | ✅ 확인 |
| Market 결정 = `?market=` 쿼리 > INTL, `RequestMarket` axum extractor | `market.rs:41-49`, `market.rs:92-116` | ✅ 확인 |
| **`RequestMarket`이 검색 핸들러에 전혀 배선되지 않음** — 사용처는 catalog_entities·solutions 핸들러뿐 | `packages/api-server/src/domains/catalog_entities/handlers.rs:80`, `packages/api-server/src/domains/solutions/handlers.rs:135,163` (그리고 `domains/search/handlers.rs`에는 부재) | ✅ 확인 — market 축의 실제 적용점은 affiliate variant 픽(`market.rs:70-86`) |
| locale 어휘: `SUPPORTED_LOCALES = ["ko","en","ja"]`, `DEFAULT_LOCALE="ko"` — 언어축이며 국가축 아님 | `packages/api-server/src/i18n.rs:8,11` | ✅ 확인 |
| FE(entities): `searchEntities`는 Orval 생성 클라이언트 경유 → mutator가 **모든 요청에 `locale`을 전역 주입** | `packages/web/lib/api/mutator/custom-instance.ts:15-22,37`, `packages/web/lib/hooks/useEntitySearch.ts:41-49`, `packages/web/lib/hooks/useCatalogEntitySearch.ts:25-29` | ✅ 확인 — locale은 실제로 전선에 실려 감(단, 위 근거대로 필터엔 미참여) |
| FE(posts): `useSearch`는 Orval을 **쓰지 않고** `@decoded/shared/api/search`의 수기 `fetch` 사용 → mutator 미경유, locale/country 주입 없음 | `packages/web/lib/hooks/useSearch.ts:12-20,93-106`, `packages/shared/api/search.ts:28-42,74-81` | ✅ 확인 — `buildSearchParams`가 세팅하는 키는 q/category/media_type/context/has_adopted/sort/page/limit 8개뿐 |
| FE에 market 파라미터를 검색에 실어 보내는 코드 없음 (`packages/web/lib/market.ts`는 어휘 정의·통화 매핑 전용) | `packages/web/lib/market.ts:1-30` (파일 전체. 테스트 외 import 0건) | ✅ 확인 |

## Drift since 2026-07 baseline

baseline의 **주장은 모두 유효**하며, 변경은 라인 위치와 표현 범위뿐이다.

1. **`index_config.rs` — 가장 큰 드리프트.** baseline은 "`ENTITIES_FILTERABLE = ["kind"]` 단독, POSTS에도 country 없음 → `index_config.rs:11-26`"이라고 한 블록으로 묶었으나, 현재 트리에서는 상수가 분리·확장되어 `POSTS_FILTERABLE`이 `11-17`(5개 항목), `SOLUTIONS_FILTERABLE`이 `21-28`, `ENTITIES_SEARCHABLE`/`ENTITIES_FILTERABLE`이 `32-33`에 있다. `ENTITIES_FILTERABLE`의 값 자체는 `["kind"]`로 불변.
2. **`dto.rs` `SearchQuery` — 1행 상향 이동.** baseline `14-45` → 현재 `13-45`(구조체 선언이 derive 줄부터 시작). 필드 집합은 완전 동일.
3. **`service.rs` `map_entity_hit` — 소폭 이동.** baseline `374-387` → 현재 함수 시그니처 `374`, 이름 픽 `379-380`, 호출부 `410-424`. 의미 변화 없음.
4. **드리프트 없음(주목할 점):** `EntitySearchQuery`는 `dto.rs:250-263`으로 baseline과 **완전히 동일**하고, 마이그레이션 `…:24`도 동일하다. 이 근-제로 드리프트가 이번 판정을 "drifted"가 아닌 **CONFIRMED**로 만드는 근거다.
5. **baseline에 없던 신규 발견 2건:** (a) 브랜드 상세 조회가 컬럼이 아니라 `metadata` JSON에서 country를 읽는 이중 경로(`catalog_entities/service.rs:127-131` vs `admin/catalog_gaps.rs:755`), (b) FE posts 검색이 Orval 클라이언트를 우회하는 별도 수기 fetch 경로(`packages/shared/api/search.ts`)라서 mutator의 전역 locale 주입 대상이 아님. 둘 다 baseline이 다루지 않은 축이다.

## 설계 함정 — origin ≠ market ≠ locale

세 개의 서로 다른 축이 "국가처럼 보이는 문자열"을 공유하기 때문에 혼동되기 쉽다. 코드는 이미 셋을 분리해 두었다.

| 축 | 의미 | 정본 | 값 | 현재 적용점 |
| --- | --- | --- | --- | --- |
| **origin** | 브랜드가 **설립된** 나라 (공급자 속성) | `public.brands.country_of_origin` (`…entity_enrichment_pipeline.sql:24,71-72`) | ISO alpha-2 추정 + `'NA'` 기본값 | 관리자 catalog gap 화면, 브랜드 상세 응답. **검색·색인에는 전혀 미유입** |
| **market** | **사용자가 구매하는 시장** (커머스/배송 축) | `packages/api-server/src/market.rs:1-11` | `KR/JP/US/INTL`, live=`KR` | affiliate variant 픽(`market.rs:70-86`), catalog_entities·solutions 핸들러. **검색 핸들러엔 미배선** |
| **locale** | 표시 **언어** 축 (market과 직교임이 `market.rs:2`에 명시) | `packages/api-server/src/i18n.rs:8,11` | `ko/en/ja`, 기본 `ko` | 응답 표시명 폴백(`pick_localized`), Meili localizedAttributes 색인 힌트 |

**함정 요약:** "한국 사용자에게 한국 브랜드를"이라는 요구를 `country_of_origin`으로 구현하면 **설립국 필터**가 되어, KR 시장에 정상 배송되는 해외 브랜드가 사라지고 KR 설립이지만 KR 미배송인 브랜드가 남는다. 사용자 시장 축은 `market`이며, 그 축의 데이터는 브랜드가 아니라 **variant(offer) 레벨**에 붙어 있다(`market.rs:61-66` `VariantOffer{market, affiliate_url}`). 또한 `locale=ko`를 국가 필터로 재해석하는 것도 금지 — `market.rs:2`가 직교성을 명시적으로 선언했고, 현재 `locale`은 결과 집합이 아니라 표시명만 바꾼다(`service.rs:422-424`).

## 결정 영향 (#796 · #799): D-SEARCH vs D-BACKEND

주어진 판정 규칙 — *"백엔드 계약이 이미 지원 → D-SEARCH(프론트가 파라미터만 실어 보내면 됨) / 계약에 없음 → D-BACKEND(백엔드 선행 작업 필요)"* — 을 위 증거에 적용하면 결론은 **D-BACKEND**다.

> ⚠️ **시제 주의: #796·#799는 이미 CLOSED다**(각각 2026-07-09T08:33Z · 10:11Z). 즉 본 문서는 **미결 결정의 입력이 아니라 이미 내려진 결정의 사후 확인**이다 — #801의 *07-09 조사 코멘트*가 그 결정의 입력이었고(#796 해소 코멘트가 "#801로 언블록 후 grilled"라고 적고 있다), 이 07-22 재검증은 그 판단이 현재 트리에서도 유효한지를 확인한 것이다. 아래 4단 선행 작업은 "결정 대기 항목"이 아니라 **확정된 D-BACKEND의 실행 목록**으로 읽을 것.

country/region 축을 검색에 도입하려면 **프론트에서 파라미터를 추가하는 것만으로는 아무 효과가 없고**, 최소 다음 4단이 백엔드에 선행되어야 한다.

1. **축 정의 확정** — origin이 아니라 market 축이어야 한다면, `RequestMarket`(`market.rs:92-116`)을 `domains/search/handlers.rs`에 배선(현재 부재). origin 축이라면 `country_of_origin`의 컬럼/metadata 이중화(`catalog_entities/service.rs:127-131` vs `admin/catalog_gaps.rs:755`) 정리가 선행.
2. **DTO 확장** — `SearchQuery`(`dto.rs:13-45`) / `EntitySearchQuery`(`dto.rs:250-263`)에 필드 추가. 현재는 미지정 키가 조용히 버려져 **FE가 보내도 무증상 무시**된다.
3. **색인 문서 확장** — `entity_reindex.rs:64-74`의 문서 빌더에 country 필드 추가 + `ENTITIES_FILTERABLE`(`index_config.rs:33`)에 등재. Meilisearch에서 `filterableAttributes`에 없는 속성으로 필터를 걸면 요청 자체가 거부되므로, 순서는 **문서 필드 → filterable 등록 → 재색인 → 쿼리** 고정.
4. **데이터 적재** — brands 외 artists/groups/posts에는 country 컬럼이 아예 없다(마이그레이션 grep 1건). 사람 중심 검색(아티스트/그룹) 국가 필터는 스키마 신설이 필요하다.

### 비용 등급이 다른 이웃 항목 — `entity_id` filterable

country 축과 같은 바구니에 담지 말 것. `entity_id`는 **이미 Meili 문서에 실려 있다**(`packages/api-server/src/batch/entity_reindex.rs:66` — `"entity_id": id.to_string()`) — 빠진 것은 `ENTITIES_FILTERABLE`(`index_config.rs:33` = `["kind"]`) 등재뿐이다. 즉 **설정 1줄 + 재색인**이면 해제되며, 스키마 신설도 데이터 적재도 필요 없다. country(4단계, 스키마+적재 포함)와 티어를 같이 매기면 값싼 언블록이 비싼 항목 뒤에 줄 서게 된다.

**부수 결론(프론트 관점, #789):** entity 검색 typeahead는 이미 `locale`을 전선에 싣고 있고(`custom-instance.ts:37`) 백엔드가 이를 표시명 폴백으로 처리하므로, **다국어 표시명 관련 작업은 D-SEARCH로 프론트에서 마무리 가능**하다. 반면 posts 검색은 Orval 우회 경로(`packages/shared/api/search.ts`)라 locale조차 실리지 않으므로, 표시명 i18n을 posts 검색까지 넓히려면 그 자체로 별도 FE 배선 작업이 필요하다.

## 남은 갭 (NOT-IN-CODE — 본 세션 미실행)

아래는 **소스만으로 확정할 수 없는 런타임/데이터 사실**이다. 본 세션은 prod 크레덴셜이 없고 라이브 접근이 금지되어 **명령만 기록하고 실행하지 않았다.** 실행은 크레덴셜 보유 세션에서 수행할 것.

1. **라이브 Meilisearch의 실제 `filterableAttributes` 확인** — `index_config.rs`는 "부팅 시 설정하려는 값"이지 "현재 인덱스에 적용된 값"이 아니다. 과거 수동 설정이 남아 있을 가능성 배제 불가.
```
# 실행하지 말 것 (기록용)
curl -s -H "Authorization: Bearer $MEILI_MASTER_KEY" "$MEILI_HOST/indexes/entities/settings"
curl -s -H "Authorization: Bearer $MEILI_MASTER_KEY" "$MEILI_HOST/indexes/posts/settings"
```
2. **`brands.country_of_origin` 실적재율 확인** — 기본값이 `'NA'`이므로 컬럼 존재 ≠ 데이터 존재. 커버리지가 낮으면 필터 도입 시 결과가 붕괴한다. 컬럼과 metadata 두 경로의 값 불일치도 함께 봐야 한다.
```sql
-- 실행하지 말 것 (기록용)
SELECT country_of_origin, count(*) FROM public.brands GROUP BY 1 ORDER BY 2 DESC;
SELECT count(*) FILTER (WHERE country_of_origin <> 'NA') AS col_filled,
count(*) FILTER (WHERE metadata ->> 'country_of_origin' IS NOT NULL) AS meta_filled,
count(*) FILTER (WHERE country_of_origin <> 'NA'
AND metadata ->> 'country_of_origin' IS NOT NULL
AND country_of_origin <> metadata ->> 'country_of_origin') AS mismatched,
count(*) AS total
FROM public.brands;
```
3. **#796 / #799 본문 대조** — 본 세션은 두 이슈 본문을 읽지 않았다. 위 D-BACKEND 판정은 태스크가 제시한 매핑 규칙에 증거를 적용한 결과이므로, 이슈가 규정한 결정 항목 표현과 1:1로 맞추는 확인이 남아 있다.
4. **`market` 축의 variant 데이터 커버리지** — market 기반 검색 필터를 택할 경우 `VariantOffer`(`market.rs:61-66`) 수준의 market 값이 얼마나 적재됐는지가 실현 가능성을 좌우한다. 본 리서치 범위(검색 계약) 밖이라 미조사.
Loading
Loading