From ce3e34938cf16fb0ca0343320860733e7107405f Mon Sep 17 00:00:00 2001 From: joungmin Date: Tue, 16 Jun 2026 10:38:57 +0900 Subject: [PATCH] =?UTF-8?q?fix(map):=20NaverMapView=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=ED=99=94=20=E2=80=94=20divRef=20=ED=95=AD=EC=83=81=20=EB=A7=88?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=20+=20ResizeObserver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이전: 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 --- CHANGELOG.md | 7 ++ frontend/src/components/NaverMapView.tsx | 129 ++++++++++++++--------- 2 files changed, 86 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c13fb..3b476f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/frontend/src/components/NaverMapView.tsx b/frontend/src/components/NaverMapView.tsx index 33790cf..2748067 100644 --- a/frontend/src/components/NaverMapView.tsx +++ b/frontend/src/components/NaverMapView.tsx @@ -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) => 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) => 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(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(`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(null); const [bounds, setBounds] = useState(null); const [zoom, setZoom] = useState(13); + const [initError, setInitError] = useState(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
{error} — 새로고침 후에도 같으면 Google Map으로 폴백됩니다.
; - if (!ready) return
네이버 지도 로딩 중…
; - return (
-
+ {/* 지도 컨테이너 — 항상 마운트, 명시적 크기 보장 */} +
- {/* 마커 overlay (좌표→픽셀 변환 + absolute positioned div) */} + {/* 로딩/에러 overlay */} + {(error || initError) && ( +
+ {error ?? initError} — 새로고침 또는 GoogleMap로 fallback +
+ )} + {!ready && !error && ( +
+ 네이버 지도 로딩 중… +
+ )} + + {/* 마커 overlay */} {clusters.map((feature) => { const [lng, lat] = feature.geometry.coordinates; const pt = toScreen(lat, lng); @@ -211,7 +241,6 @@ export default function NaverMapView({ ); })} - {/* 내 위치 버튼 */} {onMyLocation && (