- 백엔드 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
설계서: 프론트 - 필터 시스템 (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)
SearchBar
- 빈 문자열 제출은 무시 (
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모두 일치해야 통과. boundsFilterOnON: 지도 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계산.
- I/O:
┌─────────────────────────────────────┐
│ 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 검색 흐름
- 사용자가
SearchBar에 입력 → 엔터. handleSubmit가query.trim()검증 →onSearch(trimmed, "hybrid").handleSearch:setLoading(true).await api.search(query, "hybrid").setRestaurants(results),setIsSearchResult(true).- 모든 필터 초기화 (channel, cuisine, price, country, city, district, bounds).
computeFlyTo(results)로 지도 이동.
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 채널 필터 흐름 (서버)
- 채널 카드 클릭 →
setChannelFilter(ch.channel_name)(또는 동일 클릭 시 ""). useEffect([channelFilter])가api.getRestaurants({ channel })재호출.restaurants가 채널-매칭 데이터로 교체.- 이후 클라이언트 필터 체인 적용.
8.4 지역 캐스케이드 + fly-to
regionTree = buildRegionTree(restaurants)(useMemo).countries,cities,districts가 현재 선택에 따라 파생.- 사용자가
country선택 →handleCountryChange:- cityFilter, districtFilter 리셋.
- boundsFilter 해제.
- 해당 country 식당들의 centroid + spread →
regionFlyTo.
- 모바일 홈 탭은 pill 버튼 →
setOpenSheet("country")→<FilterSheet>마운트. - FilterSheet 옵션 선택 →
handleSelect("한국")→onChange("한국")(=handleCountryChange) + 시트 닫힘.
8.5 내 위치 토글
- 클릭 →
boundsFilterOn = !boundsFilterOn. - ON 시: 다른 필터 모두 초기화 + Geolocation →
userLoc,regionFlyTo({lat,lng,zoom:15}). - OFF 시: 단순 토글, 다른 필터 변화 없음.
- Geolocation 실패 → 기본 좌표
(37.498, 127.0276)강남역 부근.
8.6 FilterSheet 동작
open=true→useEffect가document.body.style.overflow = "hidden".- options 를
group필드로 reduce → 그룹 헤더 (sticky) + 옵션 리스트. - 옵션 클릭 →
handleSelect(v)→ onChange + onClose. - 백드롭/X 클릭 → onClose.
- 언마운트 시 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") 호출.
- [Unit·예상]
- 수동 QA
- 채널 클릭 → 서버 재페치 → 결과 변경 확인.
- 음식 필터 → 클라이언트 필터링 동작 (네트워크 호출 없음).
- 가격 필터 → 정규식 매칭 정확성 (다양한 표현 샘플로).
- 지역 캐스케이드: 나라 변경 → 시 옵션 갱신 → 구 옵션 갱신.
- 모바일 FilterSheet: 백드롭 클릭 닫힘, body 스크롤 잠금 확인.
- "내위치 ON" → 다른 필터 자동 해제.
- 검색 → 필터 자동 해제.
- 리셋 (홈 더블탭) → 초기 상태 복원.
- 모킹/드라이런
api.search,api.getRestaurantsMSW mock.navigator.geolocationmock 으로 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 으로 교체 검토.