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
..

설계서: 프론트 - 지도 뷰 (#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)

  • restaurants 배열이 주어지면 좌표 기반으로 마커가 렌더링된다.
  • 줌 아웃 시 근접 마커들이 카운트가 표시된 원형 클러스터로 묶인다 (radius=60, maxZoom=16, minPoints=2).
  • 클러스터 클릭 시 getClusterExpansionZoom() 결과로 확대 (최대 18) 및 pan.
  • 개별 마커 클릭 시 InfoWindow 가 열리고 onSelectRestaurant 가 호출된다.
  • InfoWindow "상세 보기" 클릭 시 onSelectRestaurant 가 재호출되어 부모가 상세 시트를 연다.
  • selected prop 변경 시 해당 좌표로 pan 하고 zoom 16 으로 변경하며 InfoWindow 자동 오픈.
  • flyTo prop 변경 시 해당 좌표로 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-mapsAPIProvider, 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. 데이터 모델

// 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. MapContentuseEffect: 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 변경 → useEffectpanTo + setZoom(16) + setInfoTarget(selected).
  • flyTo(지역 필터, 검색, 내 위치): flyTo 변경 → useEffectpanTo + 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 통합 필요한지?
  • 좌표 동일 식당 (지점 다수) 표시 정책 미정 — 클러스터로만 표현되어 개별 식별 어려움.