Reviewer 결과 17 PASS / 1 REJECT (#267 admin 권한 critical). - 17개 설계서를 Draft → Approved로 갱신 - #267(backend-user)은 critical 결함으로 06-Reviewer 유지 - 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 124건 백로그 반영 (critical 3 / major 46 / minor 75) - docs/README.md에 18개 설계서 인덱스 추가 - CHANGELOG.md 2026-06-15 섹션 추가 Refs: #266 #268-#283 (현행화 완료) #267 (대기) #289-#305 (백로그)
17 KiB
17 KiB
설계서: 프론트 - 지도 뷰 (#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_COLORS8색 팔레트 순환). - InfoWindow: 식당명, 별점, 카테고리, 주소, 가격, 전화, "상세 보기" 버튼.
- 카메라 이벤트:
idle→ bounds·zoom 추적,onCameraChanged→ 150ms 디바운스 후 부모로 bounds 전달. flyToprop 으로 지역/검색/내 위치로 지도 이동,selected변경 시 자동 pan & zoom 16.- "내 위치" 버튼 (
onMyLocation) 및 채널 색상 범례 (좌하단). - 폐업/임시휴업 상태 시각 표시 (취소선, 회색조).
- 제외 (out of scope)
- 지도 위 직접 편집 (마커 드래그, 추가 등).
- 경로 검색·길 안내·StreetView.
- 식당 검색 / 필터 로직 자체 (#280 필터 시스템 참조).
- 식당 상세 시트의 본문 (#279 참조).
- 지오코딩 / 주소 → 좌표 변환 (백엔드 책임).
3. 인수조건 (Acceptance Criteria)
restaurants배열이 주어지면 좌표 기반으로 마커가 렌더링된다.- 줌 아웃 시 근접 마커들이 카운트가 표시된 원형 클러스터로 묶인다 (
radius=60,maxZoom=16,minPoints=2). - 클러스터 클릭 시
getClusterExpansionZoom()결과로 확대 (최대 18) 및 pan. - 개별 마커 클릭 시 InfoWindow 가 열리고
onSelectRestaurant가 호출된다. - InfoWindow "상세 보기" 클릭 시
onSelectRestaurant가 재호출되어 부모가 상세 시트를 연다. selectedprop 변경 시 해당 좌표로 pan 하고 zoom 16 으로 변경하며 InfoWindow 자동 오픈.flyToprop 변경 시 해당 좌표로 pan,zoom이 지정되면 변경.- 카메라 이동(
idle) 시 bounds·zoom 이 갱신되어 클러스터가 재계산된다. onBoundsChanged는 150ms 디바운스 후 호출된다.- 채널이 2개 이상 있는 데이터셋에서 채널별 색상이 일관되게 부여되고 좌하단 범례가 표시된다.
activeChannel이 지정되면 해당 채널 색상으로 마커 렌더, 범례도 단일 채널만 노출.business_status === "CLOSED_PERMANENTLY"면 마커가 회색·취소선, opacity 0.5.onMyLocation콜백이 제공되면 우상단 버튼 노출 (44px 미만이지만 36×36 + 충분한 패딩).- 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-iconsgetCuisineIcon— 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"기본 / "내주변" 시 지도.
- Tailwind,
- 성능
useMemo로 supercluster 인덱스, points, channelColors 캐시.setTimeout150ms 디바운스로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 점군 변환·클러스터링 — 입력만으로 결정적.
- I/O: Google Maps API 호출 (
┌────────────────────────┐
│ app/page.tsx (Home) │
│ state: restaurants, │
│ selected, mapBounds, │
│ regionFlyTo, ... │
└──────────┬─────────────┘
│ props
▼
┌─────────────────────────────────────┐
│ <MapView restaurants selected │
│ onSelectRestaurant onBoundsChanged│
│ flyTo onMyLocation activeChannel> │
│ └─ APIProvider (Google Maps SDK) │
│ └─ <Map onCameraChanged> │
│ └─ <MapContent useMap()> │
│ ├─ useSupercluster() │
│ ├─ clusters[] = getClus.. │
│ ├─ AdvancedMarker (cluster│
│ │ | individual) │
│ └─ InfoWindow │
└─────────────────────────────────────┘
│
▼
user click marker → onSelectRestaurant(r)
map idle → bounds/zoom 갱신 → clusters 재계산
onCameraChanged → 150ms debounce → onBoundsChanged
6. 데이터 모델
// 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<RestaurantProps>;
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<string, ChannelColor> |
식당 배열 | 채널명→색상 객체 | 채널 없으면 빈 객체 | 단순 |
getClusterSize |
클러스터 카운트→픽셀 크기 (36/42/48/54) | (count: number) => number |
양의 정수 | 픽셀 | 음수/NaN 시 36 | 단순 |
useSupercluster |
supercluster 인덱스 + 조회 헬퍼 캐시 | (restaurants: Restaurant[]) => { getClusters, getExpansionZoom, index } |
식당 배열 | 인덱스/함수들 | getClusterExpansionZoom 예외 시 17 반환 |
복잡 (메모이즈+ref) |
MapContent |
지도 내부 — 카메라 이벤트, 마커/InfoWindow 렌더 | (props: Omit<MapViewProps,"onMyLocation"|"onBoundsChanged">) => 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-<name>.md분리 후보. 현재는 본 문서로 일괄 관리.
8. 흐름 / 알고리즘
8.1 초기 마운트
Home마운트 → 데스크탑이면viewMode = "map". Geolocation 으로userLoc갱신.api.getRestaurants({ limit: 500 })→restaurants세팅.<MapView>마운트 →<APIProvider>가 Google SDK 로드.<Map>의defaultCenter = SEOUL_CENTER,defaultZoom = 13으로 초기 카메라 결정.MapContent내useEffect:map.addListener("idle", ...)등록. 초기 bounds·zoom 즉시 1회 세팅.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 호출 검증.
- [Unit·예상]
- 수동 QA
- 데스크탑 768px+ 진입 시 지도 모드 기본.
- 클러스터 클릭 → 확대 → 개별 마커 분리.
- 식당 목록 클릭 → 지도가 해당 좌표로 이동·줌 16.
- 영역 필터 ON → 카메라 이동 시 100ms 후 식당 수 변경.
- 채널 필터 적용 → 단일 채널 색상만 범례 표시.
- 폐업 식당 — 라벨 회색·취소선 확인.
- 모바일 "내주변" 탭 — 지도 전체, 좌상단 "내 주변 N개" 배지.
- 모킹/드라이런
- Google Maps SDK: jsdom 환경 한계로 e2e (Playwright) 권장.
- Geolocation:
navigator.geolocationmock 으로 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 통합 필요한지?
- 좌표 동일 식당 (지점 다수) 표시 정책 미정 — 클러스터로만 표현되어 개별 식별 어려움.