Files
joungmin 80b553ec19 docs: 현행화 17개 설계서 Approved + 후속 이슈 백로그 등록
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 (백로그)
2026-06-15 11:08:18 +09:00

241 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 기능 설계서. 구현된 코드를 바탕으로 역설계 (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 통합 필요한지?
- 좌표 동일 식당 (지점 다수) 표시 정책 미정 — 클러스터로만 표현되어 개별 식별 어려움.