perf(map): NaverMapView SDK 네이티브 마커 + InfoWindow

- 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 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-06-16 16:58:03 +09:00
parent a4de9ba87b
commit 8de8696424
2 changed files with 116 additions and 66 deletions

View File

@@ -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 + 회색 배경(시각적 로딩 표시)

View File

@@ -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<string, unknown>) => NaverMapInstance;
Event: { addListener: (target: unknown, type: string, fn: (...args: unknown[]) => void) => unknown };
Marker: new (opts: Record<string, unknown>) => NaverMarker;
InfoWindow: new (opts: Record<string, unknown>) => 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<string, unknown>) => void;
refresh: (noEffect?: boolean) => void;
setSize?: (size: unknown) => void;
};
type NaverMarker = {
setMap: (map: NaverMapInstance | null) => void;
setIcon: (icon: Record<string, unknown>) => 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 `<div style="width:28px;height:28px;border-radius:9999px;background:#f59e0b;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,.25);"></div>`;
}
// SVG data URL — 클러스터(숫자)
function clusterIconHtml(count: number, size: number): string {
return `<div style="width:${size}px;height:${size}px;border-radius:9999px;background:rgba(245,158,11,.92);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:${size > 44 ? 14 : 12}px;border:2px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);">${count}</div>`;
}
export default function NaverMapView({
restaurants,
selected,
@@ -100,12 +121,14 @@ export default function NaverMapView({
const { ready, error } = useNaverMaps();
const divRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<NaverMapInstance | null>(null);
const markersRef = useRef<NaverMarker[]>([]);
const infoWindowRef = useRef<NaverInfoWindow | null>(null);
const [bounds, setBounds] = useState<MapBounds | null>(null);
const [zoom, setZoom] = useState(13);
const [initError, setInitError] = useState<string | null>(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(
`<div style="background:#fff;border-radius:8px;padding:8px 12px;box-shadow:0 4px 12px rgba(0,0,0,.18);font-size:13px;font-weight:600;color:#111;white-space:nowrap;">${escapeHtml(r.name)}</div>`
);
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 (
<div className="relative w-full h-full">
{/* 지도 컨테이너 — 항상 마운트, 명시적 크기 보장 */}
<div
ref={divRef}
className="absolute inset-0"
style={{ width: "100%", height: "100%", backgroundColor: "#e5e7eb" }}
/>
{/* 로딩/에러 overlay */}
{(error || initError) && (
<div className="absolute inset-0 flex items-center justify-center text-xs text-red-600 bg-white/80 pointer-events-none">
{error ?? initError} GoogleMap로 fallback
{error ?? initError}
</div>
)}
{!ready && !error && (
@@ -198,54 +280,11 @@ export default function NaverMapView({
</div>
)}
{/* 마커 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 (
<button
key={`c-${cluster_id}`}
onClick={() => {
const z = Math.min(getExpansionZoom(cluster_id), 18);
const m = mapRef.current;
if (!m || !window.naver?.maps) return;
m.panTo(new window.naver.maps.LatLng(lat, lng));
m.setZoom(z, true);
}}
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-500/90 text-white font-semibold shadow-lg ring-2 ring-white"
style={{ left: pt.x, top: pt.y, width: size, height: size, fontSize: size > 44 ? 14 : 12 }}
>
{point_count}
</button>
);
}
const r = (feature.properties as RestaurantProps).restaurant;
const cuisineIcon = getCuisineIcon(r.cuisine_type);
const isSel = selected?.id === r.id;
return (
<button
key={r.id}
onClick={() => onSelectRestaurant?.(r)}
className={`absolute -translate-x-1/2 -translate-y-full rounded-full shadow-md ring-2 ring-white transition-transform ${isSel ? "scale-125 z-10" : ""}`}
style={{ left: pt.x, top: pt.y, width: 32, height: 32, background: "#f59e0b", color: "#78350f" }}
title={r.name}
>
<Icon name={cuisineIcon} size={18} />
</button>
);
})}
{onMyLocation && (
<button
onClick={onMyLocation}
aria-label="내 위치"
className="absolute right-3 bottom-3 size-11 rounded-full bg-white shadow-lg flex items-center justify-center"
className="absolute right-3 bottom-3 size-11 rounded-full bg-white shadow-lg flex items-center justify-center z-10"
>
<Icon name="my-location" size={22} />
</button>
@@ -253,3 +292,7 @@ export default function NaverMapView({
</div>
);
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch] as string));
}