네이버 지도 마커 + 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 로 정리되는가
#595