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 (백로그)
241 lines
17 KiB
Markdown
241 lines
17 KiB
Markdown
<!-- 기능 설계서. 구현된 코드를 바탕으로 역설계 (reverse-engineering) -->
|
||
|
||
# 설계서: 프론트 - 지도 뷰 (#278)
|
||
|
||
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||
> **작성**: [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
|
||
▼
|
||
┌─────────────────────────────────────┐
|
||
│ <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. 데이터 모델
|
||
|
||
```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<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 초기 마운트
|
||
1. `Home` 마운트 → 데스크탑이면 `viewMode = "map"`. Geolocation 으로 `userLoc` 갱신.
|
||
2. `api.getRestaurants({ limit: 500 })` → `restaurants` 세팅.
|
||
3. `<MapView>` 마운트 → `<APIProvider>` 가 Google SDK 로드.
|
||
4. `<Map>` 의 `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 통합 필요한지?
|
||
- 좌표 동일 식당 (지점 다수) 표시 정책 미정 — 클러스터로만 표현되어 개별 식별 어려움.
|