Files
tasteby/docs/design/280-frontend-filter
joungmin e97a36a8d9 docs/design: tasteby 18개 기능 현행화 설계서 추가
- 백엔드 12개: auth/user/restaurant/video/extract-pipeline/search/review-memo/channel/stats/daemon/cache/health
- 프론트 6개: map/restaurant-detail/filter/review-memo/admin/login
- 12개 섹션 전 항목 채움 (목적/범위/인수조건/제약/아키텍처/데이터모델/함수명세표/흐름/엣지/테스트/리스크/미해결)
- 추적성 헤더에 구현 파일 경로 명시, 테스트는 TBD (현재 없음)
- 코드 변경 없음 — 기존 구현의 설계 문서화

Refs: #266 #267 #268 #269 #270 #271 #272 #273 #274 #275 #276 #277 #278 #279 #280 #281 #282 #283
2026-06-15 10:48:50 +09:00
..

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

상태: Draft 작성: [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)

  • 빈 문자열 제출은 무시 (query.trim() 가드).
  • 제출 시 onSearch(trimmed, "hybrid") 호출.
  • isLoading=true 면 우측 회전 스피너 표시.
  • key={resetCount} 로 리셋 시 입력 초기화.

FilterSheet

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

Filter 로직 (page.tsx)

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

// 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. handleSubmitquery.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=trueuseEffectdocument.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 으로 교체 검토.