네이버 지도 마커 + fitBounds
언제 — naver.maps v3 에 마커를 여러 개 찍고 카메라를 자동으로 맞출 때. 증상이 있으면 바로 간다:
| 증상 | 레시피 |
|---|---|
| “상위 N개”인데 지도엔 N개보다 적게 뜬다 | R2 |
latitude! 단언이 붙는다 / NaN 좌표가 샌다 |
R3 |
| 마커가 하나일 때 카메라가 아예 안 움직인다 | R5 |
| 같은 건물에 두 곳이라 줌이 최대까지 파고든다 | R6 |
| margin 을 줬는데 여백이 안 생긴다 | R7 |
| 오른쪽·아래 가장자리 마커의 라벨이 잘린다 | R8 |
| 숨겨진 탭에서 열면 줌이 엉뚱하다 / 지도가 회색 | R9 |
| 목록이 바뀌었는데 옛 마커가 남아 있다 | R10 |
| 목록이 자주 바뀌어 깜빡인다 | R11 |
| 마커가 수백 개라 무겁다 | R12 |
R1·R4 는 나머지가 딛고 서는 기반이다.
재료
declare const MAX_MARKERS: number
declare function createMarkerContent(p: Place): string
R1. bounds 는 마커가 아니라 데이터에서 계산한다
마커는 렌더링 산출물이지 좌표의 source of truth 가 아니다.
// ✗ 좌표를 marker 에 넣었다가 도로 꺼낸다
const bounds = new naver.maps.LatLngBounds(
markers[0].getPosition() as naver.maps.LatLng,
markers[0].getPosition() as naver.maps.LatLng
)
// ✓ positions 를 먼저 만들고 marker 와 bounds 양쪽이 그걸 쓴다
const positions = targets.map((p) => new naver.maps.LatLng(p.latitude, p.longitude))
Marker.getPosition() 의 선언 타입이 Coord(= LatLng | Point)라 캐스팅이 강제된다. 이 캐스팅은 정보 손실을 되돌리는 작업이고, 애초에 손실시키지 않으면 필요 없다. 부수 효과로 map.getProjection() 이나 Marker 인스턴스 없이 bounds 계산을 단위 테스트할 수 있게 된다.
R2. filter 먼저, slice 나중
// ✗ 앞 N개 중 좌표 없는 항목이 섞이면 실제 표시 개수가 N보다 적다
places.slice(0, MAX_MARKERS).filter(hasCoords)
// ✓
places.filter(hasCoords).slice(0, MAX_MARKERS)
“상위 N개”의 N 은 표시 가능한 것 중 N개를 뜻한다. 순서가 바뀌면 뒤에 유효 데이터가 남아 있는데도 지도가 비어 보인다.
R3. truthiness 대신 타입 가드
// ✗ 0 을 탈락시키고, narrowing 이 안 되어 뒤에서 ! 가 붙는다
.filter((p) => p.latitude && p.longitude) // → p.latitude!, p.longitude!
// ✓
type Located = Place & { latitude: number; longitude: number }
const isLocated = (p: Place): p is Located =>
Number.isFinite(p.latitude) && Number.isFinite(p.longitude)
.filter(isLocated) // 이후 p.latitude 는 number
Number.isFinite 가 != null 보다 나은 이유는 API 가 NaN 이나 문자열 "" 를 흘려보내는 경우까지 한 번에 막히기 때문이다 — new LatLng(NaN, NaN) 은 예외를 던지지 않고 조용히 지도를 망가뜨린다.
한국 좌표계에서 0 은 안 나온다는 건 사실이지만, 이 필터가 만드는 진짜 이득은 ! 제거다.
R4. degenerate bounds seeding
LatLngBounds 는 빈 상태로 만들 수 없다. 그래서 면적 0 인 bounds 를 만들고 extend 로 키운다.
const bounds = positions.reduce(
(acc, p) => acc.extend(p),
new naver.maps.LatLngBounds(positions[0], positions[0])
)
extend는 mutable accumulator 다. 자기를 변형하고 자기를 반환한다.- 첫 좌표가 seed 와 루프에서 두 번 들어가도 idempotent 라 무해하다.
- sw/ne 순서를 직접 계산하다 뒤집는 실수를 원천 차단한다.
마커가 수백 개면 extend 반복 대신 숫자로 min/max 를 구해 한 번에 만드는 게 싸다:
const lats = targets.map((p) => p.latitude)
const lngs = targets.map((p) => p.longitude)
const bounds = new naver.maps.LatLngBounds(
new naver.maps.LatLng(Math.min(...lats), Math.min(...lngs)),
new naver.maps.LatLng(Math.max(...lats), Math.max(...lngs))
)
함정 — Math.min(...arr) 는 배열이 만 단위가 되면 스택을 넘긴다(그 규모면 reduce). 날짜변경선(±180°)을 걸치는 데이터는 min/max 방식이 지구를 반대로 감싼다(국내 서비스면 무시).
R5. 개수별 카메라 분기 (0 / 1 / N)
// ✗ 하나일 때 카메라가 아예 안 움직인다
if (markers.length > 1) { map.fitBounds(bounds, PADDING) }
한 곳뿐이면 지도는 초기 중심에 그대로 있고 마커는 화면 밖일 수 있다. 셋 다 처리한다:
if (positions.length === 0) {
// 아무것도 안 한다 / 빈 상태 UI 로 넘긴다. 카메라는 건드리지 않는다.
} else if (positions.length === 1) {
map.setCenter(positions[0])
map.setZoom(SINGLE_MARKER_ZOOM) // 15~17 정도
} else {
map.fitBounds(bounds, BOUNDS_MARGIN)
}
0 개일 때 기본 좌표로 되돌릴지 사용자가 보던 위치를 유지할지는 제품 결정이다. 다만 말없이 아무 일도 안 일어나는 것과 의도적으로 유지하는 것은 코드에서 구분되어야 한다.
R6. 줌 오버슈트 클램프
가장 자주 터진다. 같은 건물·같은 블록에 두 곳이면 bounds 면적이 거의 0 이라 fitBounds 가 최대 줌까지 파고들어 건물 하나만 화면에 남는다.
A. 지도 레벨에서 상한 (권장) — 사용자 수동 줌인까지 같이 막히는 게 단점.
map.setOptions({ maxZoom: MAX_AUTO_ZOOM }) // 예: 17
map.fitBounds(bounds, BOUNDS_MARGIN)
B. 사후 클램프 — 동기적으로 적용되지 않는 빌드가 있다. 값이 안 먹으면 한 틱 뒤로 미룬다.
map.fitBounds(bounds, BOUNDS_MARGIN)
if (map.getZoom() > MAX_AUTO_ZOOM) map.setZoom(MAX_AUTO_ZOOM)
naver.maps.Event.once(map, 'idle', () => {
if (map.getZoom() > MAX_AUTO_ZOOM) map.setZoom(MAX_AUTO_ZOOM)
})
C. bounds 에 최소 span — 겹친 마커가 화면 중앙에 모이는 대신 주변 맥락이 남는다.
const MIN_SPAN = 0.004 // 약 400m
const sw = bounds.getSW(), ne = bounds.getNE()
const latPad = Math.max(0, MIN_SPAN - (ne.lat() - sw.lat())) / 2
const lngPad = Math.max(0, MIN_SPAN - (ne.lng() - sw.lng())) / 2
if (latPad || lngPad) {
bounds.extend(new naver.maps.LatLng(sw.lat() - latPad, sw.lng() - lngPad))
bounds.extend(new naver.maps.LatLng(ne.lat() + latPad, ne.lng() + lngPad))
}
R7. fitBounds 두 번째 인자 확인
map.fitBounds(bounds, margin) 의 margin 은 버전·타입 정의에 따라 number 만 받기도 하고 { top, right, bottom, left } 를 받기도 한다. number 를 넘겼는데 타입 정의가 객체를 기대하면 조용히 무시된다.
확인 — 호출 전후로 map.getBounds() 를 찍어 여백 차이가 나는지 본다. 안 나면 안 먹은 것이다.
const BOUNDS_MARGIN = { top: 80, right: 24, bottom: 160, left: 24 }
하단을 크게 잡는 이유는 대개 바텀시트·목록 패널이 지도를 덮기 때문이다. 지도 컨테이너는 전체 화면인데 실제로 보이는 영역은 그보다 작다. 여백은 그 차이를 보정하는 값이지 미관용 패딩이 아니다.
R8. anchor 와 fitBounds 의 픽셀 불일치
icon: { content, anchor: new naver.maps.Point(0, 0) }
anchor (0, 0) 은 HTML 콘텐츠의 좌상단을 좌표에 붙이고, 마커 박스는 우하단으로 뻗는다. 그런데 fitBounds 는 지리 좌표만 보고 픽셀 크기를 모른다. 이름 길이에 따라 콘텐츠 폭이 달라지므로 오른쪽·아래 가장자리 마커는 고정 margin 으로 못 막고 잘린다.
- 핀 형태:
anchor = new Point(width / 2, height)— 뾰족한 끝이 좌표에 닿는다. - 말풍선·라벨: 콘텐츠 크기가 가변이라 anchor 를 상수로 못 준다.
content래퍼에transform: translate(-50%, -100%)를 걸고 anchor 는Point(0, 0)으로 두는 편이 안정적이다. CSS 가 픽셀 정렬을 맡고 anchor 는 관여하지 않는다. - 그래도 남는 클리핑은
BOUNDS_MARGIN을 마커 최대 폭의 절반 이상으로 잡아 흡수한다.
R9. 컨테이너 크기 0 에서 호출하지 않는다
onReady 시점에 레이아웃이 확정되지 않았거나(숨겨진 탭, display:none, 애니메이션 중) 높이가 0 이면 fitBounds 가 엉뚱한 줌을 계산한다. 나중에 컨테이너가 커져도 카메라는 재계산되지 않는다.
const el = map.getElement()
if (el.clientWidth === 0 || el.clientHeight === 0) {
const ro = new ResizeObserver(() => {
if (el.clientWidth && el.clientHeight) {
ro.disconnect()
map.refresh() // 내부 사이즈 재측정
map.fitBounds(bounds, BOUNDS_MARGIN)
}
})
ro.observe(el)
return
}
탭 전환 후 지도가 회색으로 남는 증상도 같은 원인이고 처방도 map.refresh() 다.
R10. 생성과 부착을 분리하고 생명주기를 반환한다
// ✗ 변환 함수 안에서 부수 효과. 붙는 시점을 통제할 수 없다
const markers = targets.map((p) => new naver.maps.Marker({ position, map }))
new Marker({ map }) 은 생성 즉시 지도에 붙는다. 만들어두고 나중에 붙이려면 map 을 빼고 setMap(map) 을 따로 부른다. 더 중요한 건 떼는 경로가 있는가다 — 목록 필터가 바뀌어 함수가 다시 불리면 이전 마커는 지도에 그대로 남는다.
return {
markers,
destroy() {
for (const m of markers) {
naver.maps.Event.clearInstanceListeners(m)
m.setMap(null)
}
},
}
useEffect(() => {
if (!map) return
const layer = placeMarkers(map, places)
return () => layer.destroy()
}, [map, places])
함정 — setMap(null) 만으로는 리스너가 정리되지 않아 마커 인스턴스가 GC 되지 않는다. Event.addListener(marker, 'click', ...) 가 있으면 clearInstanceListeners 가 필수다. 그리고 places 가 매 렌더 새 배열이면 전량 재생성된다 — useMemo 로 안정화하거나 R11 로 넘어간다.
R11. 전량 재생성 대신 id 기준 diff
목록이 자주 바뀌고 마커가 수십 개 이상이면 전량 파기·재생성은 깜빡임과 GC 압박을 만든다.
const registry = new Map<string, naver.maps.Marker>()
function sync(map: naver.maps.Map, targets: Located[]) {
const next = new Set(targets.map((p) => p.id))
for (const [id, marker] of registry) {
if (!next.has(id)) {
naver.maps.Event.clearInstanceListeners(marker)
marker.setMap(null)
registry.delete(id)
}
}
for (const p of targets) {
const existing = registry.get(p.id)
if (existing) {
existing.setPosition(new naver.maps.LatLng(p.latitude, p.longitude))
} else {
registry.set(p.id, new naver.maps.Marker({ /* ... */ map }))
}
}
}
선택 상태 변화(강조 마커)는 마커를 재생성하지 말고 marker.setIcon({ content: ... }) 로 콘텐츠만 교체한다.
R12. 개수가 커지면 클러스터링
수백 개를 그대로 찍으면 DOM 마커(HTML content) 기준으로 스크롤·줌이 눈에 띄게 무거워진다. 네이버는 MarkerClustering 을 코어에 포함하지 않고 별도 오픈소스 모듈로 배포한다. MAX_MARKERS 로 자르는 건 그 전 단계의 임시방편이고, 상한을 넘겼을 때 “일부만 표시 중”임을 사용자에게 알릴지 결정해둔다.
조립
type Located = Place & { latitude: number; longitude: number }
const isLocated = (p: Place): p is Located =>
Number.isFinite(p.latitude) && Number.isFinite(p.longitude)
const MAX_AUTO_ZOOM = 17
const SINGLE_MARKER_ZOOM = 16
const BOUNDS_MARGIN = { top: 80, right: 24, bottom: 160, left: 24 }
export function placeMarkers(map: naver.maps.Map, places: Place[]) {
const targets = places.filter(isLocated).slice(0, MAX_MARKERS)
const positions = targets.map(
(p) => new naver.maps.LatLng(p.latitude, p.longitude)
)
const markers = targets.map(
(place, i) =>
new naver.maps.Marker({
position: positions[i],
map,
icon: {
content: createMarkerContent(place),
anchor: new naver.maps.Point(0, 0), // 정렬은 content 래퍼 CSS 담당
},
})
)
if (positions.length === 1) {
map.setCenter(positions[0])
map.setZoom(SINGLE_MARKER_ZOOM)
} else if (positions.length > 1) {
const bounds = positions.reduce(
(acc, p) => acc.extend(p),
new naver.maps.LatLngBounds(positions[0], positions[0])
)
map.fitBounds(bounds, BOUNDS_MARGIN)
if (map.getZoom() > MAX_AUTO_ZOOM) map.setZoom(MAX_AUTO_ZOOM)
}
return {
markers,
destroy() {
for (const m of markers) {
naver.maps.Event.clearInstanceListeners(m)
m.setMap(null)
}
},
}
}
확인
- 좌표 결측 항목을 자르기 전에 걸러내는가
-
latitude!같은 non-null 단언이 남아 있지 않은가 - 마커가 0 개 / 1 개일 때 카메라 동작이 정의되어 있는가
- 같은 건물에 두 곳일 때 줌이 최대까지 튀지 않는가
-
fitBounds의 margin 이 실제로 반영되는가 (getBounds()로 확인) - 바텀시트·헤더가 덮는 영역이 margin 에 반영되어 있는가
- 가장 오른쪽·아래 마커의 라벨이 잘리지 않는가 (가장 긴 이름으로 테스트)
- 숨겨진 탭에서 초기화될 때
map.refresh()경로가 있는가 - 재호출 시 이전 마커가 제거되는가
- 리스너가
clearInstanceListeners로 정리되는가