# 설계서: 프론트 - 지도 뷰 (#278) > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-15 > **추적성** — Redmine: #278 · 관련 ADR: 없음 > · 구현 파일: `frontend/src/components/MapView.tsx`, `frontend/src/app/page.tsx` · 테스트: TBD (현재 없음) ## 1. 목적 (Why) YouTube 영상에서 추출된 식당들을 사용자의 위치 컨텍스트와 함께 지도 위에 시각화해, "내 주변·관심 지역의 맛집"을 한눈에 탐색할 수 있게 한다. ## 2. 범위 (Scope) - **포함** - Google Maps (vis.gl/react-google-maps) 기반 지도 렌더링. - Supercluster 를 이용한 마커 클러스터링 (줌 레벨/영역 기반). - 개별 마커: 식당 이름 라벨 + 음식 카테고리 아이콘 + 채널 색상 (`CHANNEL_COLORS` 8색 팔레트 순환). - InfoWindow: 식당명, 별점, 카테고리, 주소, 가격, 전화, "상세 보기" 버튼. - 카메라 이벤트: `idle` → bounds·zoom 추적, `onCameraChanged` → 150ms 디바운스 후 부모로 bounds 전달. - `flyTo` prop 으로 지역/검색/내 위치로 지도 이동, `selected` 변경 시 자동 pan & zoom 16. - "내 위치" 버튼 (`onMyLocation`) 및 채널 색상 범례 (좌하단). - 폐업/임시휴업 상태 시각 표시 (취소선, 회색조). - **제외 (out of scope)** - 지도 위 직접 편집 (마커 드래그, 추가 등). - 경로 검색·길 안내·StreetView. - 식당 검색 / 필터 로직 자체 (#280 필터 시스템 참조). - 식당 상세 시트의 본문 (#279 참조). - 지오코딩 / 주소 → 좌표 변환 (백엔드 책임). ## 3. 인수조건 (Acceptance Criteria) - [x] `restaurants` 배열이 주어지면 좌표 기반으로 마커가 렌더링된다. - [x] 줌 아웃 시 근접 마커들이 카운트가 표시된 원형 클러스터로 묶인다 (`radius=60`, `maxZoom=16`, `minPoints=2`). - [x] 클러스터 클릭 시 `getClusterExpansionZoom()` 결과로 확대 (최대 18) 및 pan. - [x] 개별 마커 클릭 시 InfoWindow 가 열리고 `onSelectRestaurant` 가 호출된다. - [x] InfoWindow "상세 보기" 클릭 시 `onSelectRestaurant` 가 재호출되어 부모가 상세 시트를 연다. - [x] `selected` prop 변경 시 해당 좌표로 pan 하고 zoom 16 으로 변경하며 InfoWindow 자동 오픈. - [x] `flyTo` prop 변경 시 해당 좌표로 pan, `zoom` 이 지정되면 변경. - [x] 카메라 이동(`idle`) 시 bounds·zoom 이 갱신되어 클러스터가 재계산된다. - [x] `onBoundsChanged` 는 150ms 디바운스 후 호출된다. - [x] 채널이 2개 이상 있는 데이터셋에서 채널별 색상이 일관되게 부여되고 좌하단 범례가 표시된다. - [x] `activeChannel` 이 지정되면 해당 채널 색상으로 마커 렌더, 범례도 단일 채널만 노출. - [x] `business_status === "CLOSED_PERMANENTLY"` 면 마커가 회색·취소선, opacity 0.5. - [x] `onMyLocation` 콜백이 제공되면 우상단 버튼 노출 (44px 미만이지만 36×36 + 충분한 패딩). - [x] API 키 부재(`NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` 미설정) 시 빈 키로 APIProvider 가 초기화된다 (지도 미로드). ## 4. 컨텍스트 & 제약 - **런타임**: Next.js 16 (App Router) / TypeScript / "use client" 컴포넌트. - **외부 의존성** - `@vis.gl/react-google-maps` — `APIProvider`, `Map`, `AdvancedMarker`, `InfoWindow`, `useMap`. - `supercluster` — 점군 클러스터링. - Google Maps JS API (mapId=`tasteby-map`, colorScheme=`LIGHT`). - `@/lib/cuisine-icons` `getCuisineIcon` — Material Symbols 코드. - `@/components/Icon` — Material Symbols Rounded 렌더러. - **데이터 컨트랙트**: `Restaurant` (`/lib/api.ts`) — `id, name, latitude, longitude, channels[]?, business_status, rating, rating_count, cuisine_type, address, price_range, phone`. - **UI/UX 제약** - Tailwind, `brand-*` 색상 토큰 (오렌지 #E8720C 컬러 직접 사용 포함). - 모바일 터치 영역 가이드 44×44 px — 단, 지도 위 컨트롤(내 위치 36×36, 마커 라벨)은 일반 룰 예외로 작게 유지 (정보 밀도 우선). - 모바일 "내주변" 탭은 지도 전용 (목록 없음, BottomSheet 로 상세). - 데스크탑 기본 `viewMode = "map"` (768px 이상), 모바일은 `"list"` 기본 / "내주변" 시 지도. - **성능** - `useMemo` 로 supercluster 인덱스, points, channelColors 캐시. - `setTimeout` 150ms 디바운스로 `onBoundsChanged` 호출 최소화. - `idle` 리스너 cleanup 등록 (`google.maps.event.removeListener`). - **가정** - 좌표는 백엔드에서 정제되어 (lat ∈ [-90,90], lng ∈ [-180,180]) 들어온다. - 1회 페치 결과는 최대 500개 (`limit: 500`, page.tsx). - Google Maps API 키는 `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` 환경변수. ## 5. 아키텍처 개요 - **모듈/파일 구조** - `MapView.tsx` (404 LOC) - 상수: `SEOUL_CENTER`, `API_KEY`, `CHANNEL_COLORS[8]`. - 헬퍼: `getChannelColorMap()`, `getClusterSize()`. - 훅: `useSupercluster()` — index, getClusters, getExpansionZoom. - 컴포넌트: `MapContent` (지도 내부 — `useMap` 컨텍스트 필요), `MapView` (default export, `APIProvider` 래퍼). - `app/page.tsx` (소비자) - 상태: `restaurants`, `selected`, `mapBounds`, `regionFlyTo`, `channelFilter`, `viewMode`, `mobileTab`, `userLoc`. - 핸들러: `handleBoundsChanged`, `handleSelectRestaurant`, `handleMyLocation`, `computeFlyTo`, `findRegionFromCoords`. - **I/O ↔ 순수 로직 경계** - **I/O**: Google Maps API 호출 (`map.panTo`, `setZoom`, `getBounds`), Geolocation API, `setTimeout`/`addListener`. - **순수**: `getChannelColorMap`, `getClusterSize`, `computeFlyTo`, `findRegionFromCoords`, supercluster 점군 변환·클러스터링 — 입력만으로 결정적. ``` ┌────────────────────────┐ │ app/page.tsx (Home) │ │ state: restaurants, │ │ selected, mapBounds, │ │ regionFlyTo, ... │ └──────────┬─────────────┘ │ props ▼ ┌─────────────────────────────────────┐ │ │ │ └─ APIProvider (Google Maps SDK) │ │ └─ │ │ └─ │ │ ├─ useSupercluster() │ │ ├─ clusters[] = getClus.. │ │ ├─ AdvancedMarker (cluster│ │ │ | individual) │ │ └─ InfoWindow │ └─────────────────────────────────────┘ │ ▼ user click marker → onSelectRestaurant(r) map idle → bounds/zoom 갱신 → clusters 재계산 onCameraChanged → 150ms debounce → onBoundsChanged ``` ## 6. 데이터 모델 ```ts // props interface MapViewProps { restaurants: Restaurant[]; // 좌표 포함, 필수 selected?: Restaurant | null; // 선택된 식당 — 자동 pan/zoom onSelectRestaurant?: (r: Restaurant) => void; // 마커/상세보기 클릭 onBoundsChanged?: (b: MapBounds) => void; // 150ms 디바운스 flyTo?: FlyTo | null; // 외부에서 지도 이동 요청 onMyLocation?: () => void; // 우상단 버튼 콜백 activeChannel?: string; // 채널 필터 active 표시 } export interface MapBounds { north: number; south: number; east: number; west: number; } export interface FlyTo { lat: number; lng: number; zoom?: number; } // 내부 type RestaurantProps = { restaurant: Restaurant }; type RestaurantFeature = Supercluster.PointFeature; type ChannelColor = { bg: string; text: string; border: string; arrow: string }; ``` - **경계 검증** - `r.latitude`, `r.longitude` 필수 (NaN 입력 시 supercluster 가 무시). - `flyTo.zoom` 미지정 시 기존 줌 유지. - `restaurants` 가 빈 배열이면 클러스터·마커 모두 렌더링되지 않으나 지도 자체는 정상 표시. - `API_KEY` 빈 문자열 허용 (Google SDK 측에서 에러 표시). ## 7. 함수 명세 (Function Specs) | 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | |------|-----------|----------------|------|------|-----------|-------| | `getChannelColorMap` | 등장 채널을 8색 팔레트에 순환 매핑 | `(restaurants: Restaurant[]) => Record` | 식당 배열 | 채널명→색상 객체 | 채널 없으면 빈 객체 | 단순 | | `getClusterSize` | 클러스터 카운트→픽셀 크기 (36/42/48/54) | `(count: number) => number` | 양의 정수 | 픽셀 | 음수/NaN 시 36 | 단순 | | `useSupercluster` | supercluster 인덱스 + 조회 헬퍼 캐시 | `(restaurants: Restaurant[]) => { getClusters, getExpansionZoom, index }` | 식당 배열 | 인덱스/함수들 | `getClusterExpansionZoom` 예외 시 17 반환 | **복잡** (메모이즈+ref) | | `MapContent` | 지도 내부 — 카메라 이벤트, 마커/InfoWindow 렌더 | `(props: Omit) => JSX` | props | JSX | `useMap()` null 가드 | **복잡** (3개 useEffect, 상태 4개) | | `MapView` | 외부 래퍼 — APIProvider, 채널 범례, 내 위치 버튼 | `(props: MapViewProps) => JSX` | props | JSX | API 키 부재 시 빈 지도 | **복잡** (디바운스 타이머) | | `handleMarkerClick` | 마커 클릭 → InfoWindow + 부모 알림 | `(r: Restaurant) => void` | 식당 | void | — | 단순 | | `handleClusterClick` | 클러스터 클릭 → 확장 줌으로 이동 | `(clusterId, lng, lat) => void` | id, 좌표 | void | map null 시 no-op | 단순 | | `handleCameraChanged` | onCameraChanged 디바운스 후 onBoundsChanged 호출 | `(ev: CameraChangedEvent) => void` | bounds 이벤트 | void | onBoundsChanged 미제공 시 no-op | 단순 | | `computeFlyTo` (page.tsx) | 식당 집합 → 중심점·줌 산출 | `(rests: Restaurant[]) => FlyTo \| null` | 식당 배열 | FlyTo | 빈 배열 → null | 단순 | | `findRegionFromCoords` (page.tsx) | 좌표 → 최근접 country/city 추정 | `(lat, lng, rests) => {country,city} \| null` | 좌표·식당 | 지역 | 매칭 없으면 null | 단순 | > 복잡 표시 함수는 향후 `fn-.md` 분리 후보. 현재는 본 문서로 일괄 관리. ## 8. 흐름 / 알고리즘 ### 8.1 초기 마운트 1. `Home` 마운트 → 데스크탑이면 `viewMode = "map"`. Geolocation 으로 `userLoc` 갱신. 2. `api.getRestaurants({ limit: 500 })` → `restaurants` 세팅. 3. `` 마운트 → `` 가 Google SDK 로드. 4. `` 의 `defaultCenter = SEOUL_CENTER`, `defaultZoom = 13` 으로 초기 카메라 결정. 5. `MapContent` 내 `useEffect`: `map.addListener("idle", ...)` 등록. 초기 bounds·zoom 즉시 1회 세팅. 6. `useSupercluster` 가 points→ index 빌드. `clusters = getClusters(bounds, floor(zoom))`. ### 8.2 사용자 상호작용 - **마커 클릭**: `handleMarkerClick(r)` → `setInfoTarget(r)` → `onSelectRestaurant(r)` → 부모 `setSelected, setShowDetail(true)`. - **클러스터 클릭**: `handleClusterClick(cluster_id, lng, lat)` → `getExpansionZoom(id)` → `map.panTo` + `setZoom(min(expansion,18))` → 다음 `idle` 에서 클러스터 재계산. - **카메라 이동**: `onCameraChanged` → 150ms 디바운스 → `onBoundsChanged({north,south,east,west})` → 부모가 `setMapBounds` → "내위치" 필터 적용 시 `filteredRestaurants` 재계산. - **외부 선택**(목록 클릭): `selected` 변경 → `useEffect` 가 `panTo + setZoom(16) + setInfoTarget(selected)`. - **flyTo**(지역 필터, 검색, 내 위치): `flyTo` 변경 → `useEffect` 가 `panTo + setZoom(flyTo.zoom)`. ### 8.3 클러스터링 알고리즘 - supercluster: 점들을 KD-tree로 인덱싱 → 줌 레벨별 그리드 (radius=60px) 내 점들을 클러스터로 병합. - `getClusters([west,south,east,north], floor(zoom))` → `Cluster` (cluster=true, point_count) 또는 `Point` (cluster=false, restaurant) feature 반환. - 클러스터는 점수에 따라 크기 (36/42/48/54px) 결정. ### 8.4 채널 색상 부여 - `getChannelColorMap`: 등장 채널을 Set 으로 수집 → 8색 팔레트 순환 매핑. - 개별 마커는 `activeChannel ∈ r.channels` 이면 해당 채널, 아니면 `r.channels[0]` 색상. - `selected` 마커는 파란색 (#2563eb) 오버라이드. ## 9. 엣지케이스 & 에러 처리 | 경계 | 처리 | |------|------| | 빈 식당 배열 | 마커·클러스터 0개, 지도만 표시. 범례 미노출. | | `r.channels` undefined | 기본 색상 `CHANNEL_COLORS[0]` (amber). | | `getClusterExpansionZoom` 예외 (id 만료) | catch → 17 반환. | | `flyTo` null | `useEffect` 가 early-return (`!map \|\| !flyTo`). | | `selected` null | pan/zoom 실행 안 함. `infoTarget` 은 사용자가 닫기 전까지 유지. | | `onBoundsChanged` 미제공 | `handleCameraChanged` early-return — 디바운스도 등록 안 함. | | 폐업 (`CLOSED_PERMANENTLY`) | bg=`#f3f4f6`, text=`#9ca3af`, opacity=0.5, text-decoration=line-through. | | 임시휴업 (`CLOSED_TEMPORARILY`) | InfoWindow 에 노란 뱃지만 표시 (마커 자체는 정상). | | API 키 부재 | APIProvider 가 빈 키로 초기화 — 지도가 로드되지 않고 콘솔 경고. UI 깨지지 않음. | | 모바일 InfoWindow 가독성 | InfoWindow 에 `colorScheme: "light"` 명시로 다크모드에서도 흰 배경 보장. | | 카메라 idle 리스너 누수 | 컴포넌트 언마운트 시 `google.maps.event.removeListener(listener)` 호출. | | 디바운스 타이머 누수 | `boundsTimerRef.current` 가 다음 호출 시 clear, 컴포넌트 언마운트 미처리 (허용 — 콜백만 비활성). | ## 10. 테스트 계획 - **현재 자동화 테스트 없음 (TBD)** — 수동 QA 시나리오: - [Unit·예상] `getChannelColorMap`: 채널 8개·9개 입력 시 색상 순환 매핑 검증. - [Unit·예상] `getClusterSize`: 0, 9, 10, 49, 50, 99, 100 입력별 36/36/42/42/48/48/54 검증. - [Unit·예상] `computeFlyTo`: 분산도(spread) 임계값별 zoom 계산. - [Integration·예상] React Testing Library + Google Maps mock 으로 마커 렌더링 개수, 클러스터 카운트, onSelectRestaurant 호출 검증. - **수동 QA** - 데스크탑 768px+ 진입 시 지도 모드 기본. - 클러스터 클릭 → 확대 → 개별 마커 분리. - 식당 목록 클릭 → 지도가 해당 좌표로 이동·줌 16. - 영역 필터 ON → 카메라 이동 시 100ms 후 식당 수 변경. - 채널 필터 적용 → 단일 채널 색상만 범례 표시. - 폐업 식당 — 라벨 회색·취소선 확인. - 모바일 "내주변" 탭 — 지도 전체, 좌상단 "내 주변 N개" 배지. - **모킹/드라이런** - Google Maps SDK: jsdom 환경 한계로 e2e (Playwright) 권장. - Geolocation: `navigator.geolocation` mock 으로 success/error 분기 검증. ## 11. 리스크 & 대안 검토 - **선택**: `@vis.gl/react-google-maps` + supercluster. - 장점: React 공식 권장, 선언적 마커/이벤트, 트리쉐이킹 가능. - 단점: vendor lock-in (Google), 키 비용. - **대안 검토** - **Mapbox GL JS / MapLibre**: 더 빠른 벡터 타일, GL 클러스터 빌트인. 단, 한국 지도 디테일이 Google 만 못함. - **Naver Maps / Kakao Maps**: 국내 디테일 강점. 단, 해외 식당(일본·유럽) 미지원. - **자체 Canvas/Deck.gl**: 풀 컨트롤. 단, 개발 비용·유지보수 부담 큼. - **트레이드오프**: 글로벌 식당 데이터를 다루는 서비스 특성상 Google Maps 유지. 단, Places/Geocoding 호출은 백엔드에서만 처리해 키 노출/비용 통제. - **되돌리기 어려운 결정**: mapId, AdvancedMarker 의존 — 추후 변경 시 마커 렌더링 전반 재작성 필요. ADR 후보. ## 12. 미해결 질문 (Open Questions) - 500개 limit 이상 데이터를 보여주려면 viewport 기반 페이지네이션 (bounds-aware 페치)로 전환 필요? - supercluster `radius=60`/`maxZoom=16` 값은 어떤 데이터셋 규모를 가정? 채택 근거 ADR 필요. - `activeChannel` 외 채널은 회색으로 dim 처리할 것인지 (현재는 색상 그대로 유지)? - InfoWindow 모바일 가독성 — BottomSheet 와 중복 UI 인데, 모바일에서는 InfoWindow 를 끄는 게 옳은지? - 다크모드 시 mapId 별도 (다크 스타일) 적용 여부 — 현재 `colorScheme="LIGHT"` 고정. - "내 위치" 버튼이 36×36 으로 44px 가이드라인 미달 — 패딩 확대 또는 BottomNav 통합 필요한지? - 좌표 동일 식당 (지점 다수) 표시 정책 미정 — 클러스터로만 표현되어 개별 식별 어려움.