fix(map): NaverMapView 안정화 — divRef 항상 마운트 + ResizeObserver

- 이전: ready 가드로 첫 렌더 시 ref 누락 가능 → SDK가 div 못 잡음
- 명시적 width/height + 회색 배경(시각적 로딩 표시)
- ResizeObserver + rAF로 컨테이너 0×0 → 정상 크기 시 m.refresh
- try/catch + initError로 init 실패 가시화

🤖 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 10:38:57 +09:00
parent 5199475d67
commit ce3e34938c
2 changed files with 86 additions and 50 deletions

View File

@@ -6,6 +6,13 @@
## 2026-06-16
### 🗺️ NaverMapView 안정화 + 재활성 (v0.1.57)
- divRef 항상 마운트 (이전: ready 가드로 첫 렌더 ref 누락 가능)
- 명시적 width/height + 회색 배경(시각적 로딩 표시)
- ResizeObserver + requestAnimationFrame으로 컨테이너 0×0 → 정상 크기 시 refresh
- try/catch + initError state로 init 실패 가시화
- Naver 키 재활성
### ⏪ NaverMap 임시 비활성, 한국도 GoogleMap fallback (v0.1.55)
- NaverMapView 골격이 실 운영에서 지도/마커 렌더 실패 (정확한 원인 추후 진단)
- NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 빈 값으로 dispatcher가 GoogleMap fallback (회귀 0)

View File

@@ -5,33 +5,29 @@ import Supercluster from "supercluster";
import type { Restaurant } from "@/lib/api";
import { getCuisineIcon } from "@/lib/cuisine-icons";
import Icon from "@/components/Icon";
import type { MapBounds, FlyTo, MapViewProps } from "@/components/MapView.types";
import type { MapBounds, MapViewProps } from "@/components/MapView.types";
// ---- naver maps v3 타입 최소 정의 (full 타입은 @types/navermaps 없음) ----
declare global {
interface Window {
naver?: {
maps: NaverMaps;
};
naver?: { maps: NaverMaps };
}
}
type NaverMaps = {
LatLng: new (lat: number, lng: number) => unknown;
Map: new (el: HTMLElement, opts: Record<string, unknown>) => NaverMapInstance;
Event: { addListener: (target: unknown, type: string, fn: (...args: unknown[]) => void) => unknown };
Position?: unknown;
MapTypeControlStyle?: unknown;
ZoomControlStyle?: unknown;
Size: new (w: number, h: number) => unknown;
};
type NaverMapInstance = {
setCenter: (latlng: unknown) => void;
setZoom: (zoom: number, useEffect?: boolean) => void;
getCenter: () => { lat: () => number; lng: () => number; x: number; y: number };
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 } };
panTo: (latlng: unknown, opts?: Record<string, unknown>) => void;
destroy?: () => void;
refresh: (noEffect?: boolean) => void;
setSize?: (size: unknown) => void;
};
const NAVER_CLIENT_ID = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "";
@@ -41,10 +37,7 @@ function useNaverMaps(): { ready: boolean; error: string | null } {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!NAVER_CLIENT_ID) {
setError("NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 미설정");
return;
}
if (!NAVER_CLIENT_ID) { setError("NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 미설정"); return; }
if (window.naver?.maps) { setReady(true); return; }
const existing = document.querySelector<HTMLScriptElement>(`script[data-naver-maps]`);
if (existing) {
@@ -52,7 +45,6 @@ function useNaverMaps(): { ready: boolean; error: string | null } {
return;
}
const s = document.createElement("script");
// NCLOUD 신 정책: 파라미터는 ncpKeyId (옛 ncpClientId는 NAVER Developers용).
s.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${NAVER_CLIENT_ID}`;
s.async = true;
s.dataset.naverMaps = "1";
@@ -110,38 +102,60 @@ export default function NaverMapView({
const mapRef = useRef<NaverMapInstance | 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) 지도 인스턴스 1회 생성
// 지도 1회 생성 — divRef 항상 마운트되므로 ready 시점에 안정적으로 잡힘
useEffect(() => {
if (!ready || !divRef.current || mapRef.current) return;
const n = window.naver!.maps;
const initLat = flyTo?.lat ?? selected?.latitude ?? 37.5665;
const initLng = flyTo?.lng ?? selected?.longitude ?? 126.978;
const initZoom = flyTo?.zoom ?? 13;
const m = new n.Map(divRef.current, {
center: new n.LatLng(initLat, initLng),
zoom: initZoom,
logoControl: false,
mapDataControl: false,
scaleControl: false,
zoomControl: false,
});
mapRef.current = m;
const sync = () => {
const b = m.getBounds();
const ne = b.getNE(), sw = b.getSW();
const nb: MapBounds = { north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() };
setBounds(nb);
setZoom(m.getZoom());
onBoundsChanged?.(nb);
};
sync();
n.Event.addListener(m, "bounds_changed", sync);
n.Event.addListener(m, "zoom_changed", sync);
try {
const n = window.naver!.maps;
const initLat = flyTo?.lat ?? selected?.latitude ?? 37.5665;
const initLng = flyTo?.lng ?? selected?.longitude ?? 126.978;
const initZoom = flyTo?.zoom ?? 13;
const m = new n.Map(divRef.current, {
center: new n.LatLng(initLat, initLng),
zoom: initZoom,
logoControl: false,
mapDataControl: false,
scaleControl: false,
zoomControl: false,
});
mapRef.current = m;
// 컨테이너 크기 변경(예: flex layout 안에서 첫 마운트 시 0×0 → 실제 크기) → refresh
const ro = new ResizeObserver(() => {
try { m.refresh(true); } catch { /* noop */ }
});
ro.observe(divRef.current);
const sync = () => {
try {
const b = m.getBounds();
const ne = b.getNE(), sw = b.getSW();
const nb: MapBounds = { north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() };
setBounds(nb);
setZoom(m.getZoom());
onBoundsChanged?.(nb);
} catch (e) {
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);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[NaverMap] init failed", e);
setInitError(msg);
}
}, [ready, flyTo, selected, onBoundsChanged]);
// 2) flyTo 변경 반영
// flyTo 변경 반영
useEffect(() => {
const m = mapRef.current;
if (!m || !flyTo || !window.naver?.maps) return;
@@ -154,22 +168,38 @@ export default function NaverMapView({
return getClusters(bounds, zoom);
}, [bounds, zoom, getClusters]);
// 3) 좌표 → 화면 픽셀 변환 (네이버 projection)
const toScreen = useCallback((lat: number, lng: number) => {
const m = mapRef.current;
if (!m || !window.naver?.maps) return null;
const p = m.getProjection().fromCoordToOffset(new window.naver.maps.LatLng(lat, lng));
return p;
try {
return m.getProjection().fromCoordToOffset(new window.naver.maps.LatLng(lat, lng));
} catch {
return null;
}
}, []);
if (error) return <div className="w-full h-full flex items-center justify-center text-xs text-red-500">{error} Google Map으로 .</div>;
if (!ready) return <div className="w-full h-full flex items-center justify-center text-xs text-gray-400"> </div>;
return (
<div className="relative w-full h-full">
<div ref={divRef} className="absolute inset-0" />
{/* 지도 컨테이너 — 항상 마운트, 명시적 크기 보장 */}
<div
ref={divRef}
className="absolute inset-0"
style={{ width: "100%", height: "100%", backgroundColor: "#e5e7eb" }}
/>
{/* 마커 overlay (좌표→픽셀 변환 + absolute positioned div) */}
{/* 로딩/에러 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
</div>
)}
{!ready && !error && (
<div className="absolute inset-0 flex items-center justify-center text-xs text-gray-500 bg-white/80 pointer-events-none">
</div>
)}
{/* 마커 overlay */}
{clusters.map((feature) => {
const [lng, lat] = feature.geometry.coordinates;
const pt = toScreen(lat, lng);
@@ -211,7 +241,6 @@ export default function NaverMapView({
);
})}
{/* 내 위치 버튼 */}
{onMyLocation && (
<button
onClick={onMyLocation}