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:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user