<!-- 기능 설계서. 구현된 코드를 바탕으로 역설계 (reverse-engineering) -->

# 설계서: 프론트 - 필터 시스템 (FilterSheet + SearchBar) (#280)

> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #280 · 관련 ADR: 없음
> · 구현 파일: `frontend/src/components/FilterSheet.tsx`, `frontend/src/components/SearchBar.tsx`, 호출부: `frontend/src/app/page.tsx` · 테스트: TBD (현재 없음)

## 1. 목적 (Why)
500+ 식당 데이터에서 사용자가 원하는 조건(채널·음식 장르·가격대·지역·지도 영역·내 주변)으로 빠르게 좁혀가도록, 모바일·데스크탑 모두 일관된 필터 UX 를 제공한다.

## 2. 범위 (Scope)
- **포함**
  - **검색**: `SearchBar` — 단일 입력, 엔터/제출 시 `hybrid` 모드로 `api.search` 호출.
  - **채널 필터**: 가로 스크롤 카드 (서버 사이드 — `getRestaurants({channel})`).
  - **음식 장르 필터**: 6개 카테고리 × 다중 아이템 (`CUISINE_TAXONOMY`), 카테고리만 또는 카테고리|아이템 형태.
  - **가격대 필터**: 5단계 그룹 (저렴/가성비/보통/프리미엄/럭셔리), 정규식 매칭.
  - **지역 필터**: 3-level 캐스케이드 (country → city → district), pipe-delimited region 파싱 + 자동 지도 fly-to.
  - **지도 영역 필터** (`boundsFilterOn`): 카메라 bounds 또는 내 위치 4km 반경.
  - **내 위치 토글**: Geolocation 으로 userLoc 갱신 + fly-to.
  - **데스크탑 필터바**: native `<select>` 그룹 (음식/지역) + 토글 버튼.
  - **모바일 필터바**: 홈 탭은 장르 가로 스크롤 카드, 그 외는 pill 버튼 → `FilterSheet` 바텀시트.
  - **FilterSheet**: 그룹화 옵션, "전체"(초기화) 항목, 체크 표시, 외부 클릭/X 닫기, body 스크롤 잠금.
- **제외 (out of scope)**
  - 클라이언트 측 전체 인덱스 (검색은 서버 위임).
  - 정렬 옵션 UI (현재 자동: 거리 → 평점 내림차순).
  - 즐겨찾기/방문 기록을 필터로 사용 (별도 탭).
  - 영상·채널 관리 (#274).

## 3. 인수조건 (Acceptance Criteria)
### SearchBar
- [x] 빈 문자열 제출은 무시 (`query.trim()` 가드).
- [x] 제출 시 `onSearch(trimmed, "hybrid")` 호출.
- [x] `isLoading=true` 면 우측 회전 스피너 표시.
- [x] `key={resetCount}` 로 리셋 시 입력 초기화.

### FilterSheet
- [x] `open=false` 면 null 반환.
- [x] `open=true` 면 body 스크롤 잠금 (`overflow=hidden`), 언마운트 시 복원.
- [x] 데스크탑(md+) 에서는 `md:hidden` 으로 숨김.
- [x] 옵션을 `group` 필드로 그룹화하여 sticky 헤더로 구분.
- [x] 맨 위 "전체" 항목 클릭 시 `onChange("")` + 닫힘.
- [x] 옵션 클릭 시 `onChange(value)` + 자동 닫힘.
- [x] 현재 `value` 와 일치하는 옵션은 brand-50 배경 + 체크 아이콘.
- [x] 백드롭 클릭 시 `onClose`.

### Filter 로직 (page.tsx)
- [x] 채널 필터 변경 시 서버에 `channel` 파라미터로 재페치.
- [x] 음식/가격/지역 필터는 클라이언트 사이드 `filteredRestaurants` 계산.
- [x] `matchCuisineFilter`: `"한식"` → `cuisine_type.startsWith("한식")`. `"한식|국밥/해장국"` → 정확 일치.
- [x] `matchPriceGroup`: 5개 정규식 중 하나로 매칭.
- [x] 지역: `country → city → district` 모두 일치해야 통과.
- [x] `boundsFilterOn` ON: 지도 bounds 있으면 box, 없으면 userLoc 기준 ~4km 반경.
- [x] 검색 결과 (`isSearchResult=true`) 면 다른 필터 모두 무시 + 거리/평점 정렬만 적용.
- [x] 결과는 항상 (거리 오름차순, 평점 내림차순) 정렬.
- [x] 검색 시 모든 필터 자동 초기화.
- [x] "내위치 ON" 시 다른 모든 필터 자동 초기화.
- [x] 지역 변경 시 해당 식당들의 centroid 로 자동 fly-to (`computeFlyTo`).

## 4. 컨텍스트 & 제약
- **런타임**: Next.js 16 (App Router), TypeScript, "use client".
- **외부 의존성**
  - `@/lib/api`: `api.search(query, mode)`, `api.getRestaurants({channel})`.
  - `@/components/Icon` (Material Symbols).
  - `@phosphor-icons/react` (홈 탭 장르 카드).
  - `@/components/FoodIcon` (커스텀 음식 아이콘).
- **데이터 컨트랙트**
  - `Restaurant.region`: `"나라|시|구"` pipe-delimited (예: `"한국|서울특별시|강남구"`).
  - `Restaurant.cuisine_type`: `"한식|국밥/해장국"` 형태 또는 카테고리만.
  - `Restaurant.price_range`: 자유 문자열 (정규식으로 그룹 매칭).
  - `FilterOption`: `{ label, value, group? }`.
- **UI/UX 제약**
  - Tailwind, brand-50/100/300/500/600/700/900 토큰.
  - 모바일 터치 영역 ≥ 44×44 px — pill 버튼 `py-1.5 px-3`, FilterSheet 옵션 `py-3` (~48px).
  - 다크모드 지원.
  - 모바일 바텀시트 max-height = 70vh, `pb-safe` (iOS 노치).
  - 데스크탑은 native `<select>` (커스텀 X) — 접근성 무료.
  - `touch-manipulation` 으로 더블탭 줌 비활성.
- **상태 동기화 규칙** (page.tsx)
  - 검색 → 필터 모두 초기화.
  - "내위치" ON → 필터(음식/가격/지역) 초기화.
  - 음식/가격/지역 ON → "내위치" 해제 (서로 배타적).
  - 지역 country 변경 → city/district 초기화 + boundsFilter 해제.
  - city 변경 → district 초기화.
- **가정**
  - 백엔드는 region 문자열을 일관된 포맷으로 저장.
  - cuisine_type 의 카테고리 부분은 `CUISINE_TAXONOMY` 와 100% 일치.
  - price_range 의 표기는 정규식 5종에 의해 거의 모두 매칭됨 (미스매치 시 미표시 — 안전 기본값).

## 5. 아키텍처 개요
- **모듈/파일 구조**
  - `SearchBar.tsx` (40 LOC): 입력 + 제출.
  - `FilterSheet.tsx` (112 LOC): 모바일 바텀시트 옵션 선택기.
  - `page.tsx` (1513 LOC, 일부):
    - 상수: `CUISINE_TAXONOMY`, `PRICE_GROUPS`.
    - 헬퍼: `matchCuisineFilter`, `matchPriceGroup`, `parseRegion`, `buildRegionTree`, `computeFlyTo`, `findRegionFromCoords`.
    - 상태: 8개 필터 상태 (`channelFilter`, `cuisineFilter`, `priceFilter`, `countryFilter`, `cityFilter`, `districtFilter`, `boundsFilterOn`, `isSearchResult`).
    - 파생: `regionTree`, `countries`, `cities`, `districts`, `filteredRestaurants` (useMemo).
    - 핸들러: `handleSearch`, `handleCountryChange`, `handleCityChange`, `handleDistrictChange`, `handleReset`, `handleMyLocation`.
    - FilterSheet 마운트 (홈/리스트 탭에서 채널/시/도/구/장르/가격).

- **I/O ↔ 순수 경계**
  - **I/O**: `api.search`, `api.getRestaurants`, `navigator.geolocation`, `window.innerWidth`, body 스타일 변경.
  - **순수**: `matchCuisineFilter`, `matchPriceGroup`, `parseRegion`, `buildRegionTree`, `computeFlyTo`, `findRegionFromCoords`, `filteredRestaurants` 계산.

```
                    ┌─────────────────────────────────────┐
                    │           page.tsx (Home)            │
                    │  filter state (8 fields)             │
                    └────────┬────────────────────┬───────┘
                             │                    │
              ┌──────────────┴────┐     ┌─────────┴───────────┐
              ▼                   ▼     ▼                     ▼
     ┌────────────────┐    ┌─────────────────┐     ┌──────────────────┐
     │  <SearchBar>   │    │  filter pills   │     │ filtered list /  │
     │  onSearch ─────┼──▶ │  (mobile)       │     │ MapView          │
     └────────────────┘    │  channel cards  │     │ (consumes        │
                           │  native select  │     │  filteredRest..) │
                           │  (desktop)      │     └──────────────────┘
                           └──┬──────────────┘
                              │ click
                              ▼
                       ┌──────────────────┐
                       │  <FilterSheet>   │
                       │  open/onChange   │
                       │  (mobile only)   │
                       └──────────────────┘

  applyFilters:
    restaurants ─▶ channel? (server) ─▶ cuisine? ─▶ price? ─▶ region? ─▶ bounds?
              ─▶ sort(distance asc, rating desc) ─▶ filteredRestaurants
```

## 6. 데이터 모델

```ts
// SearchBar
interface SearchBarProps {
  onSearch: (query: string, mode: "keyword" | "semantic" | "hybrid") => void;
  isLoading?: boolean;
}

// FilterSheet
export interface FilterOption {
  label: string;
  value: string;
  group?: string;       // 그룹 헤더로 표시 (sticky)
}
interface FilterSheetProps {
  open: boolean;
  onClose: () => void;
  title: string;
  options: FilterOption[];
  value: string;
  onChange: (value: string) => void;
}

// page.tsx 필터 상태
type FilterState = {
  channelFilter: string;     // 채널 이름. 서버에 전달
  cuisineFilter: string;     // "한식" 또는 "한식|국밥/해장국"
  priceFilter: string;       // PRICE_GROUPS.label
  countryFilter: string;     // "한국", "일본", ...
  cityFilter: string;        // "서울특별시", ...
  districtFilter: string;    // "강남구", ...
  boundsFilterOn: boolean;
  isSearchResult: boolean;   // 검색 결과 모드 (다른 필터 무시)
};

// 분류 체계
const CUISINE_TAXONOMY: { category: string; items: string[] }[] = [
  { category: "한식",   items: ["백반/한정식", "국밥/해장국", ...] },
  { category: "일식",   items: ["스시/오마카세", ...] },
  { category: "중식",   items: ["중화요리", ...] },
  { category: "양식",   items: ["파스타/이탈리안", ...] },
  { category: "아시아", items: ["베트남", ...] },
  { category: "기타",   items: ["치킨", "카페/디저트", ...] },
];

const PRICE_GROUPS: { label: string; test: (p: string) => boolean }[] = [
  { label: "저렴 (~5천원)",      test: (p) => /저렴|착한|[3-5]천원대?$|^\d천원$/.test(p) },
  { label: "가성비 (5천~1만원)", test: (p) => /가성비|만원 이하|[6-9]천원|^1만원대$|^[5-9],?\d{3}원/.test(p) },
  { label: "보통 (1~3만원)",     test: (p) => /[1-2]만원대|1-[23]만|.../.test(p) },
  { label: "프리미엄 (3~5만원)", test: (p) => /[3-4]만원대?|.../.test(p) },
  { label: "럭셔리 (5만원~)",    test: (p) => /[5-9]만원|고가|10만원|.../.test(p) },
];
```

- **경계 검증**
  - `query.trim()` 빈 문자열 차단.
  - `region` 파싱: pipe 가 없으면 country 만, "나라" 더미 값은 무시.
  - 검색 모드는 `"keyword" | "semantic" | "hybrid"` 만 (현재 hybrid 만 사용).
  - 필터 값은 모두 string (빈 문자열 = 해제).

## 7. 함수 명세 (Function Specs)

| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `SearchBar` | 검색 입력 + 제출 폼 | `(props: SearchBarProps) => JSX` | onSearch, isLoading | JSX | 빈 쿼리 무시 | 단순 |
| `handleSubmit` (SearchBar) | submit 이벤트 → onSearch | `(e: FormEvent) => void` | event | void | trim 후 빈 문자열이면 미호출 | 단순 |
| `FilterSheet` | 모바일 옵션 선택 바텀시트 | `(props: FilterSheetProps) => JSX \| null` | props | JSX/null | open=false → null | **복잡** (body lock effect, 그룹화) |
| `handleSelect` (FilterSheet) | 옵션 선택 → onChange + onClose | `(v: string) => void` | value | void | — | 단순 |
| `matchCuisineFilter` | 식당 cuisine 이 필터에 매치되는지 | `(cuisineType: string\|null, filter: string) => boolean` | 식당 타입, 필터 값 | bool | null 입력 시 false | 단순 |
| `matchPriceGroup` | 식당 price 가 그룹 정규식에 매치되는지 | `(priceRange: string\|null, group: string) => boolean` | 가격 문자열, 그룹 라벨 | bool | null 입력/그룹 미발견 시 false | 단순 |
| `parseRegion` | "나라\|시\|구" 파싱 | `(region: string\|null) => {country,city,district}\|null` | region | 객체/null | null 입력 시 null | 단순 |
| `buildRegionTree` | 식당들로부터 3-level 트리 구성 | `(restaurants: Restaurant[]) => Map<string,Map<string,Set<string>>>` | 식당 배열 | 중첩 Map | "나라" 더미 제외 | 단순 |
| `computeFlyTo` | 식당 집합 → centroid + spread→zoom | `(rests: Restaurant[]) => FlyTo\|null` | 식당 배열 | FlyTo | 빈 배열 → null | 단순 |
| `findRegionFromCoords` | 사용자 좌표 → 최근접 country/city | `(lat, lng, rests) => {country,city}\|null` | 좌표·식당 | 객체/null | 매칭 없으면 null | 단순 |
| `handleSearch` | 검색 → 결과 세팅 + 필터 리셋 + fly-to | `async (q, mode) => void` | query, mode | void | API 에러 catch | **복잡** (여러 상태 동시 갱신) |
| `handleCountryChange` | 나라 변경 → city/district 리셋 + fly-to | `(country: string) => void` | country | void | "" 이면 flyTo=null | 단순 |
| `handleCityChange` | 시/도 변경 → district 리셋 + fly-to | `(city: string) => void` | city | void | "" 이면 country 레벨 fly-to | 단순 |
| `handleDistrictChange` | 구/군 변경 → fly-to | `(district: string) => void` | district | void | "" 이면 city 레벨 fly-to | 단순 |
| `handleReset` | 모든 필터 + 결과 초기화 | `() => void` | — | void | API 에러 catch | 단순 |
| `filteredRestaurants` | 모든 필터 적용 + 정렬 | useMemo | 8개 필터 + restaurants + userLoc | Restaurant[] | 검색 결과면 다른 필터 무시 | **복잡** (다중 조건 + 정렬) |

> 복잡 표시 함수는 fn-도큐먼트 분리 후보. 특히 `filteredRestaurants` 정책은 비즈니스 규칙 문서화 가치 있음.

## 8. 흐름 / 알고리즘

### 8.1 검색 흐름
1. 사용자가 `SearchBar` 에 입력 → 엔터.
2. `handleSubmit` 가 `query.trim()` 검증 → `onSearch(trimmed, "hybrid")`.
3. `handleSearch`:
   - `setLoading(true)`.
   - `await api.search(query, "hybrid")`.
   - `setRestaurants(results)`, `setIsSearchResult(true)`.
   - 모든 필터 초기화 (channel, cuisine, price, country, city, district, bounds).
   - `computeFlyTo(results)` 로 지도 이동.
4. `filteredRestaurants` 가 검색 모드 분기를 타고 정렬만 적용.

### 8.2 필터 적용 흐름 (클라이언트)
```
for each r in restaurants:
  if channelFilter && !r.channels.includes(channelFilter): continue
  if cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter): continue
  if priceFilter && !matchPriceGroup(r.price_range, priceFilter): continue
  if countryFilter:
    p = parseRegion(r.region)
    if !p || p.country !== countryFilter: continue
    if cityFilter && p.city !== cityFilter: continue
    if districtFilter && p.district !== districtFilter: continue
  if boundsFilterOn:
    if mapBounds: box test (north/south/east/west)
    else: radius² ≤ 0.0013 (~4km, 위경도 제곱 합)
  pass

sort by (distance to userLoc asc, rating desc)
```

### 8.3 채널 필터 흐름 (서버)
1. 채널 카드 클릭 → `setChannelFilter(ch.channel_name)` (또는 동일 클릭 시 "").
2. `useEffect([channelFilter])` 가 `api.getRestaurants({ channel })` 재호출.
3. `restaurants` 가 채널-매칭 데이터로 교체.
4. 이후 클라이언트 필터 체인 적용.

### 8.4 지역 캐스케이드 + fly-to
1. `regionTree = buildRegionTree(restaurants)` (useMemo).
2. `countries`, `cities`, `districts` 가 현재 선택에 따라 파생.
3. 사용자가 `country` 선택 → `handleCountryChange`:
   - cityFilter, districtFilter 리셋.
   - boundsFilter 해제.
   - 해당 country 식당들의 centroid + spread → `regionFlyTo`.
4. 모바일 홈 탭은 pill 버튼 → `setOpenSheet("country")` → `<FilterSheet>` 마운트.
5. FilterSheet 옵션 선택 → `handleSelect("한국")` → `onChange("한국")` (= `handleCountryChange`) + 시트 닫힘.

### 8.5 내 위치 토글
1. 클릭 → `boundsFilterOn = !boundsFilterOn`.
2. ON 시: 다른 필터 모두 초기화 + Geolocation → `userLoc`, `regionFlyTo({lat,lng,zoom:15})`.
3. OFF 시: 단순 토글, 다른 필터 변화 없음.
4. Geolocation 실패 → 기본 좌표 `(37.498, 127.0276)` 강남역 부근.

### 8.6 FilterSheet 동작
1. `open=true` → `useEffect` 가 `document.body.style.overflow = "hidden"`.
2. options 를 `group` 필드로 reduce → 그룹 헤더 (sticky) + 옵션 리스트.
3. 옵션 클릭 → `handleSelect(v)` → onChange + onClose.
4. 백드롭/X 클릭 → onClose.
5. 언마운트 시 body 스크롤 복원.

## 9. 엣지케이스 & 에러 처리

| 경계 | 처리 |
|------|------|
| 빈 검색어 | `query.trim()` 가드, onSearch 미호출 |
| 검색 API 실패 | `try/catch` 로 console.error, loading=false 보장 (finally) |
| 채널 필터 ON 상태에서 검색 | 검색 진입 시 channelFilter="" 로 강제 초기화 |
| `region` 가 null | parseRegion → null → 지역 필터 적용 시 결과 0 |
| `region.country = "나라"` (더미) | regionTree 빌드/findRegionFromCoords 에서 제외 |
| `cuisine_type` null | matchCuisineFilter → false |
| `price_range` 비정형 | 5개 정규식 모두 매칭 실패 → false (해당 식당 미표시) |
| Geolocation 거부 | `() => {}` 빈 콜백 + 기본 좌표 사용 |
| Geolocation 5초 타임아웃 | timeout 옵션, 실패 콜백 동일 |
| mapBounds null + boundsFilterOn ON | userLoc 기준 radius² ≤ 0.0013 (~4km) 적용 |
| filteredRestaurants 빈 배열 | RestaurantList 가 "표시할 식당이 없습니다" 표시 |
| FilterSheet body lock 해제 누락 | `useEffect cleanup` 에서 `overflow=""` 복원 |
| 데스크탑에서 FilterSheet 노출 | `md:hidden` 으로 차단 (그러나 데스크탑은 native select 사용) |
| 동일 채널 재클릭 | toggle off (channelFilter="") |
| `regionFlyTo` null | MapView 의 effect 가 early-return |
| 검색 모드 + 사용자가 필터 클릭 | 현재 정책: 사용자가 필터를 직접 변경하지 않는 한 검색 결과 유지 (isSearchResult 가 true 인 동안). 필터 클릭 자체로는 isSearchResult 해제 안 됨 — 잠재 UX 이슈 (미해결 질문 참조). |

## 10. 테스트 계획
- **현재 자동화 테스트 없음 (TBD)** — 수동 QA + 향후 RTL+Jest 후보:
  - [Unit·예상] `matchCuisineFilter("한식|국밥/해장국", "한식")` → true.
  - [Unit·예상] `matchCuisineFilter("일식|라멘", "한식")` → false.
  - [Unit·예상] `matchPriceGroup("8천원대", "가성비 (5천~1만원)")` → true.
  - [Unit·예상] `parseRegion("한국|서울|강남구")` → `{country:"한국", city:"서울", district:"강남구"}`.
  - [Unit·예상] `parseRegion(null)` → null.
  - [Unit·예상] `buildRegionTree` 가 "나라" 더미를 제외하는지.
  - [Unit·예상] `computeFlyTo` 가 spread 분기별로 적정 zoom 반환.
  - [Integration·예상] FilterSheet: 옵션 클릭 → onChange + onClose 호출 검증.
  - [Integration·예상] SearchBar: 엔터 → onSearch("query","hybrid") 호출.
- **수동 QA**
  - 채널 클릭 → 서버 재페치 → 결과 변경 확인.
  - 음식 필터 → 클라이언트 필터링 동작 (네트워크 호출 없음).
  - 가격 필터 → 정규식 매칭 정확성 (다양한 표현 샘플로).
  - 지역 캐스케이드: 나라 변경 → 시 옵션 갱신 → 구 옵션 갱신.
  - 모바일 FilterSheet: 백드롭 클릭 닫힘, body 스크롤 잠금 확인.
  - "내위치 ON" → 다른 필터 자동 해제.
  - 검색 → 필터 자동 해제.
  - 리셋 (홈 더블탭) → 초기 상태 복원.
- **모킹/드라이런**
  - `api.search`, `api.getRestaurants` MSW mock.
  - `navigator.geolocation` mock 으로 success/error 분기.
  - `window.matchMedia` 모바일/데스크탑 분기.

## 11. 리스크 & 대안 검토
- **선택 1**: 클라이언트 사이드 필터 (채널 제외).
  - 장점: 즉시 반응, 서버 호출 절감, 정렬·다중 조건 결합 유연.
  - 단점: 500+ 데이터 전제 (브라우저 메모리에 다 들고 있어야), 더 큰 카탈로그로 확장 시 한계.
  - **대안**: 모든 필터를 서버 쿼리 파라미터화. 트레이드오프: 즉시성↓, 확장성↑.
- **선택 2**: 가격대 정규식 매칭.
  - 장점: 자유 텍스트도 흡수.
  - 단점: 오탐/미스 매치 가능, 유지보수 비용 (새 표기 추가 시 정규식 갱신).
  - **대안**: 백엔드가 `price_min`, `price_max` 정수 컬럼을 정규화해 제공 → 클라는 비교만. ADR 후보.
- **선택 3**: 모바일은 pill+BottomSheet, 데스크탑은 native select.
  - 장점: 플랫폼 친화적, 접근성 무료 (select).
  - 단점: 코드 이원화, 디자인 일관성 약함.
  - **대안**: 양쪽 모두 커스텀 콤보박스 (Radix Select) — 일관성 ↑, 번들 ↑.
- **선택 4**: pipe-delimited region 문자열.
  - 장점: DB 한 컬럼으로 처리 가능.
  - 단점: 파싱 의존, i18n 약함, 깊이 변경 어려움.
  - **대안**: country/city/district 정규화 테이블 + FK. 변경 시 ADR.
- **되돌리기 어려운 결정**: `CUISINE_TAXONOMY` 고정 (백엔드 cuisine_type 표기와 결합). 변경 시 데이터 마이그레이션 필요.

## 12. 미해결 질문 (Open Questions)
- 검색 결과 상태에서 필터를 다시 적용하면 검색 모드를 자동 해제할지, 검색 결과 내 재필터링할지?
- semantic / keyword 검색 모드 토글 UI 가 필요한지 (현재 hybrid 고정)?
- 가격 정규식이 놓치는 케이스의 모니터링·로깅 방안?
- 다국가 (일본/유럽) 데이터 비중이 늘면 지역 트리 깊이/표기가 달라질 가능성 — 데이터 모델 변경 필요한지?
- "내위치" 반경 4km 의 근거 — 도시 vs 시골 데이터 밀도 차이 무시?
- FilterSheet 의 키보드 접근성 (Tab/ESC) 추가 필요?
- 다중 선택 (예: 한식 OR 일식) 지원 필요? 현재는 모두 단일 선택.
- 리셋 후 채널 카드 가로 스크롤 위치도 좌측 초기화해야 하는지 (현재 그대로 유지).
- `findRegionFromCoords` 의 centroid 거리 산식이 유클리드 (평면) — 적도/극지에서 왜곡. Haversine 으로 교체 검토.
