From 8de8696424b6be936ef8f7860fd124b00d3c8b96 Mon Sep 17 00:00:00 2001 From: joungmin Date: Tue, 16 Jun 2026 16:58:03 +0900 Subject: [PATCH] =?UTF-8?q?perf(map):=20NaverMapView=20SDK=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=ED=8B=B0=EB=B8=8C=20=EB=A7=88=EC=BB=A4=20+=20InfoWind?= =?UTF-8?q?ow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - React absolute div overlay → naver.maps.Marker 네이티브 교체 - 줌/팬 시 SDK GPU 최적화로 랙 해소 - 식당명 InfoWindow (마커 클릭 시 표시) - bounds_changed → idle 이벤트로 sync 빈도 감소 - 클러스터도 네이티브 마커 (HTML 콘텐츠 숫자) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 7 + frontend/src/components/NaverMapView.tsx | 175 ++++++++++++++--------- 2 files changed, 116 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b476f3..64a0b89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ ## 2026-06-16 +### ⚡ NaverMapView SDK 네이티브 마커 + InfoWindow (v0.1.59) +- 마커를 React `absolute div` overlay → `naver.maps.Marker` 네이티브로 교체 + - 줌/팬 시 SDK가 GPU 최적화, 매 frame React 리렌더링 없음 → 랙 해소 +- 식당명 InfoWindow 추가 (마커 클릭 시 표시) +- bounds_changed → idle 이벤트로 sync (줌/팬 중 발화 빈도 ↓) +- 클러스터도 네이티브 마커 (HTML 콘텐츠로 숫자 표시) + ### 🗺️ NaverMapView 안정화 + 재활성 (v0.1.57) - divRef 항상 마운트 (이전: ready 가드로 첫 렌더 ref 누락 가능) - 명시적 width/height + 회색 배경(시각적 로딩 표시) diff --git a/frontend/src/components/NaverMapView.tsx b/frontend/src/components/NaverMapView.tsx index 2748067..cf481a6 100644 --- a/frontend/src/components/NaverMapView.tsx +++ b/frontend/src/components/NaverMapView.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Supercluster from "supercluster"; import type { Restaurant } from "@/lib/api"; -import { getCuisineIcon } from "@/lib/cuisine-icons"; import Icon from "@/components/Icon"; import type { MapBounds, MapViewProps } from "@/components/MapView.types"; @@ -12,22 +11,35 @@ declare global { naver?: { maps: NaverMaps }; } } +type LatLng = { lat: () => number; lng: () => number }; type NaverMaps = { - LatLng: new (lat: number, lng: number) => unknown; + LatLng: new (lat: number, lng: number) => LatLng; Map: new (el: HTMLElement, opts: Record) => NaverMapInstance; - Event: { addListener: (target: unknown, type: string, fn: (...args: unknown[]) => void) => unknown }; + Marker: new (opts: Record) => NaverMarker; + InfoWindow: new (opts: Record) => NaverInfoWindow; + Event: { addListener: (target: unknown, type: string, fn: (...args: unknown[]) => void) => unknown; removeListener: (handler: unknown) => void }; Size: new (w: number, h: number) => unknown; + Point: new (x: number, y: number) => unknown; }; type NaverMapInstance = { setCenter: (latlng: unknown) => void; setZoom: (zoom: number, useEffect?: boolean) => void; - getCenter: () => { lat: () => number; lng: () => number }; getZoom: () => number; - getBounds: () => { getNE: () => { lat: () => number; lng: () => number }; getSW: () => { lat: () => number; lng: () => number } }; - getProjection: () => { fromCoordToOffset: (latlng: unknown) => { x: number; y: number } }; + getBounds: () => { getNE: () => LatLng; getSW: () => LatLng }; panTo: (latlng: unknown, opts?: Record) => void; refresh: (noEffect?: boolean) => void; - setSize?: (size: unknown) => void; +}; +type NaverMarker = { + setMap: (map: NaverMapInstance | null) => void; + setIcon: (icon: Record) => void; + setPosition: (latlng: unknown) => void; + getPosition: () => LatLng; +}; +type NaverInfoWindow = { + open: (map: NaverMapInstance, marker: NaverMarker) => void; + close: () => void; + setContent: (content: string) => void; + getMap: () => NaverMapInstance | null; }; const NAVER_CLIENT_ID = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || ""; @@ -89,6 +101,15 @@ function getClusterSize(count: number): number { return 54; } +// SVG data URL — 단일 마커(주황 핀) +function markerIconHtml(): string { + return `
`; +} +// SVG data URL — 클러스터(숫자) +function clusterIconHtml(count: number, size: number): string { + return `
${count}
`; +} + export default function NaverMapView({ restaurants, selected, @@ -100,12 +121,14 @@ export default function NaverMapView({ const { ready, error } = useNaverMaps(); const divRef = useRef(null); const mapRef = useRef(null); + const markersRef = useRef([]); + const infoWindowRef = useRef(null); const [bounds, setBounds] = useState(null); const [zoom, setZoom] = useState(13); const [initError, setInitError] = useState(null); const { getClusters, getExpansionZoom } = useSupercluster(restaurants); - // 지도 1회 생성 — divRef 항상 마운트되므로 ready 시점에 안정적으로 잡힘 + // 지도 1회 생성 useEffect(() => { if (!ready || !divRef.current || mapRef.current) return; try { @@ -122,13 +145,20 @@ export default function NaverMapView({ zoomControl: false, }); mapRef.current = m; + infoWindowRef.current = new n.InfoWindow({ + borderWidth: 0, + anchorSize: new n.Size(10, 10), + pixelOffset: new n.Point(0, -8), + backgroundColor: "transparent", + disableAnchor: false, + }); - // 컨테이너 크기 변경(예: flex layout 안에서 첫 마운트 시 0×0 → 실제 크기) → refresh const ro = new ResizeObserver(() => { try { m.refresh(true); } catch { /* noop */ } }); ro.observe(divRef.current); + // bounds_changed가 줌/팬 끝나는 시점에 한 번만 emit (SDK가 throttle) const sync = () => { try { const b = m.getBounds(); @@ -141,13 +171,12 @@ export default function NaverMapView({ console.warn("[NaverMap] sync failed", e); } }; - // 컨테이너 크기가 정해진 다음 sync (rAF로 다음 프레임) requestAnimationFrame(() => { try { m.refresh(true); } catch { /* noop */ } sync(); }); - n.Event.addListener(m, "bounds_changed", sync); - n.Event.addListener(m, "zoom_changed", sync); + // idle = 줌/팬 끝났을 때 한 번 (bounds_changed보다 적게 발화 → 성능) + n.Event.addListener(m, "idle", sync); } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error("[NaverMap] init failed", e); @@ -163,34 +192,87 @@ export default function NaverMapView({ if (flyTo.zoom) m.setZoom(flyTo.zoom, true); }, [flyTo]); + // 클러스터 계산 (bounds/zoom 변경 시) const clusters = useMemo(() => { if (!bounds) return []; return getClusters(bounds, zoom); }, [bounds, zoom, getClusters]); - const toScreen = useCallback((lat: number, lng: number) => { + // 마커를 SDK 네이티브로 그림 — clusters 바뀌면 기존 마커 모두 제거 후 새로 생성 + useEffect(() => { const m = mapRef.current; - if (!m || !window.naver?.maps) return null; - try { - return m.getProjection().fromCoordToOffset(new window.naver.maps.LatLng(lat, lng)); - } catch { - return null; + const naver = window.naver?.maps; + if (!m || !naver) return; + + // 기존 마커 제거 + for (const mk of markersRef.current) mk.setMap(null); + markersRef.current = []; + + for (const feature of clusters) { + const [lng, lat] = feature.geometry.coordinates; + const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster; + if (isCluster) { + const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties; + const size = getClusterSize(point_count); + const marker = new naver.Marker({ + position: new naver.LatLng(lat, lng), + map: m, + icon: { + content: clusterIconHtml(point_count, size), + anchor: new naver.Point(size / 2, size / 2), + }, + }); + naver.Event.addListener(marker, "click", () => { + const z = Math.min(getExpansionZoom(cluster_id), 18); + m.panTo(new naver.LatLng(lat, lng)); + m.setZoom(z, true); + }); + markersRef.current.push(marker); + } else { + const r = (feature.properties as RestaurantProps).restaurant; + const marker = new naver.Marker({ + position: new naver.LatLng(lat, lng), + map: m, + title: r.name, + icon: { + content: markerIconHtml(), + anchor: new naver.Point(14, 14), + }, + }); + naver.Event.addListener(marker, "click", () => { + const iw = infoWindowRef.current; + if (iw && m) { + iw.setContent( + `
${escapeHtml(r.name)}
` + ); + iw.open(m, marker); + } + onSelectRestaurant?.(r); + }); + markersRef.current.push(marker); + } } + }, [clusters, getExpansionZoom, onSelectRestaurant]); + + // 컴포넌트 unmount 시 마커 정리 + useEffect(() => { + return () => { + for (const mk of markersRef.current) mk.setMap(null); + markersRef.current = []; + infoWindowRef.current?.close(); + }; }, []); return (
- {/* 지도 컨테이너 — 항상 마운트, 명시적 크기 보장 */}
- - {/* 로딩/에러 overlay */} {(error || initError) && (
- {error ?? initError} — 새로고침 또는 GoogleMap로 fallback + {error ?? initError}
)} {!ready && !error && ( @@ -198,54 +280,11 @@ export default function NaverMapView({ 네이버 지도 로딩 중…
)} - - {/* 마커 overlay */} - {clusters.map((feature) => { - const [lng, lat] = feature.geometry.coordinates; - const pt = toScreen(lat, lng); - if (!pt) return null; - const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster; - if (isCluster) { - const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties; - const size = getClusterSize(point_count); - return ( - - ); - } - const r = (feature.properties as RestaurantProps).restaurant; - const cuisineIcon = getCuisineIcon(r.cuisine_type); - const isSel = selected?.id === r.id; - return ( - - ); - })} - {onMyLocation && ( @@ -253,3 +292,7 @@ export default function NaverMapView({
); } + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch] as string)); +}