Leaflet으로 인터랙티브 지도 만들기: 마커·팝업, GeoJSON, React·Vue 통합, 좌표계

이 글의 핵심

지도가 회색 타일만 보이거나 React 서버 렌더링 환경에서 지도 컴포넌트가 깨지는 것처럼 Leaflet은 붙이는 순간부터 막히는 지점이 있습니다. 경량 오픈소스 지도 라이브러리로서의 장단점을 Google Maps와 비교하고, 타일 서버 사용 정책과 마커가 많을 때의 성능 문제까지 짚어 프로덕션에 올릴 수 있는 구성을 정리합니다.

이 글의 핵심

Leaflet으로 인터랙티브 지도를 구현하는 글입니다. 마커, 팝업, GeoJSON, 레이어, React/Vue 통합까지 실전 예제로 정리했습니다.

실무에서 마주치는 문제들

Google Maps 비용이 높아요

API 호출마다 비용이 듭니다. Leaflet은 무료 오픈소스입니다.

커스터마이징이 제한적이에요

Google Maps는 제한적입니다. Leaflet은 완전히 자유롭습니다.

오프라인 지도가 필요해요

클라우드 의존적입니다. Leaflet은 로컬 타일을 사용할 수 있습니다.

여기서 “무료”라는 말은 조심해서 읽어야 합니다. Leaflet 라이브러리 자체는 BSD 라이선스의 무료 오픈소스이지만, 화면에 보이는 지도 그림(타일)은 누군가의 서버가 제공하는 것이고 그 서버에는 비용과 이용 정책이 있습니다. Leaflet은 “타일을 받아 격자로 배치하고, 그 위에 마커와 도형을 그리고, 드래그·줌 이벤트를 처리하는” 프런트엔드 엔진일 뿐이며, 지도 데이터·주소 검색(지오코딩)·길 찾기는 전부 별도 서비스입니다. Google Maps에서 옮겨 온다면 이 기능들을 각각 어디서 가져올지부터 정해야 합니다.


Leaflet을 고르는 이유

핵심 특징

Leaflet은 오픈소스 JavaScript 지도 라이브러리입니다. 주요 장점:

  • 무료: 오픈소스
  • 가벼움: 42KB
  • 모바일 친화적: 터치 제스처
  • 플러그인: 수백 개의 플러그인
  • 커스터마이징: 완전한 제어

Leaflet은 기능을 의도적으로 작게 유지하고 나머지를 플러그인에 맡기는 설계라, 핵심 라이브러리만으로는 벡터 타일이나 3D 지형, 지도 회전을 지원하지 않습니다. 그런 기능이 필요하다면 WebGL 기반의 MapLibre GL JS가 대안이 되고, 반대로 마커와 팝업, 간단한 도형을 올리는 대부분의 서비스 지도라면 Leaflet의 단순함이 장점이 됩니다.


설치와 첫 지도 띄우기

설치

npm install leaflet
npm install -D @types/leaflet

기본 지도

<!DOCTYPE html>
<html>
  <head>
    <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
    <style>
      #map { height: 400px; }
    </style>
  </head>
  <body>
    <div id="map"></div>
    <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
    <script>
      const map = L.map('map').setView([37.5665, 126.9780], 13);
      L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '© OpenStreetMap contributors',
      }).addTo(map);
    </script>
  </body>
</html>

세 가지가 모두 있어야 지도가 제대로 보입니다. leaflet.css는 타일 이미지를 절대 위치로 배치하고 컨트롤을 그리는 스타일이라, 빠뜨리면 타일이 페이지 아래로 줄줄이 흩어져 보입니다. #map의 height는 더 흔한 함정인데, 빈 div는 높이가 0이라 지도가 초기화되어도 아무것도 보이지 않고 에러도 나지 않습니다. setView([위도, 경도], 줌)는 위도가 먼저이며, 줌 13은 도시의 구 단위가 보이는 정도입니다.

타일 URL의 {z}/{x}/{y}는 줌 레벨과 타일 좌표로 치환됩니다. 예전 예제에서 흔히 보이는 {s}.tile.openstreetmap.org(a/b/c 서브도메인)는 HTTP/1.1 시절 동시 연결 수 제한을 우회하던 방식으로, OpenStreetMap은 지금 서브도메인 없는 tile.openstreetmap.org 사용을 권장합니다. attribution은 선택이 아니라 OSM 데이터 라이선스(ODbL)의 의무 사항이라 지도 오른쪽 아래의 저작권 표시를 CSS로 숨기면 안 됩니다. 이 글의 짧은 예제 중 일부는 코드를 줄이려고 생략했지만, 실제 서비스에서는 모든 타일 레이어에 넣어야 합니다.


마커와 팝업

기본 마커

import L from 'leaflet';
const map = L.map('map').setView([37.5665, 126.9780], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
// 마커 추가
const marker = L.marker([37.5665, 126.9780]).addTo(map);
// 팝업
marker.bindPopup('서울특별시').openPopup();
// 커스텀 아이콘
const customIcon = L.icon({
  iconUrl: '/marker.png',
  iconSize: [32, 32],
  iconAnchor: [16, 32],
  popupAnchor: [0, -32],
});
L.marker([37.5665, 126.9780], { icon: customIcon }).addTo(map);

iconAnchor는 아이콘 이미지의 어느 픽셀이 실제 좌표를 가리킬지 정합니다. 핀 모양 아이콘이라면 아래쪽 가운데([너비/2, 높이])여야 하고, 이 값을 빠뜨리면 기본값이 아이콘의 중앙이 되어 줌을 바꿀 때마다 마커가 실제 위치에서 조금씩 떠 있는 것처럼 보입니다. popupAnchor는 팝업이 아이콘 기준으로 어디서 열릴지를 정하며, 핀 끝에서 아이콘 높이만큼 위로 올리는 것이 일반적입니다.

npm으로 Leaflet을 설치해 Webpack이나 Vite로 번들하면 기본 마커 아이콘이 깨진 이미지로 보이는 문제가 거의 항상 생깁니다. Leaflet이 CSS 파일 위치를 기준으로 marker-icon.png 경로를 추측하는데, 번들러가 이미지를 다른 경로로 옮기기 때문입니다. 아이콘 이미지를 직접 import해 기본 아이콘을 다시 지정하면 해결됩니다.

import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';

L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });

마커 수가 수백 개를 넘으면 기본 L.marker는 각각 DOM 요소(<img>)를 만들기 때문에 느려집니다. 모양이 단순해도 된다면 아래 GeoJSON 예제처럼 circleMarker를 쓰고 지도 옵션에 preferCanvas: true를 주면 모든 도형을 하나의 캔버스에 그려 훨씬 많은 점을 처리할 수 있습니다.


GeoJSON 레이어

const geojsonData = {
  type: 'FeatureCollection',
  features: [
    {
      type: 'Feature',
      properties: {
        name: 'Location 1',
        category: 'restaurant',
      },
      geometry: {
        type: 'Point',
        coordinates: [126.9780, 37.5665],
      },
    },
  ],
};
L.geoJSON(geojsonData, {
  onEachFeature: (feature, layer) => {
    layer.bindPopup(feature.properties.name);
  },
  pointToLayer: (feature, latlng) => {
    return L.circleMarker(latlng, {
      radius: 8,
      fillColor: '#ff7800',
      color: '#000',
      weight: 1,
      opacity: 1,
      fillOpacity: 0.8,
    });
  },
}).addTo(map);

GeoJSON에서 가장 많이 틀리는 것은 좌표 순서입니다. GeoJSON 표준(RFC 7946)은 [경도, 위도] 순서인데 Leaflet의 L.marker와 setView는 [위도, 경도] 순서라, 같은 코드 안에서 두 규칙이 섞입니다. 위 예제의 coordinates: [126.9780, 37.5665]는 서울을 가리키지만, 순서를 뒤집어 넣으면 위도 126도라는 존재하지 않는 값이 되거나 남극 근처 바다에 점이 찍힙니다. L.geoJSON은 이 변환을 알아서 해 주므로 GeoJSON 데이터에서는 표준 순서를 그대로 지키면 됩니다.

onEachFeature는 모든 도형(점, 선, 면)에 대해 한 번씩 호출되어 팝업이나 이벤트를 붙이는 곳이고, pointToLayer는 점 도형을 어떤 레이어로 그릴지 정하는 곳입니다. 선과 면의 색은 style 옵션에 함수를 넘겨 feature.properties.category에 따라 다르게 줄 수 있습니다. 팝업에 feature.properties.name을 그대로 넣으면 문자열이 HTML로 해석되므로, 사용자가 입력한 데이터라면 <script> 삽입을 막기 위해 이스케이프하거나 DOM 요소를 만들어 textContent로 넣는 편이 안전합니다. 행정 구역 경계처럼 좌표가 수십만 개인 GeoJSON은 그대로 올리면 브라우저가 멈출 수 있으니, mapshaper 같은 도구로 미리 단순화하거나 벡터 타일로 바꾸는 것을 고려해야 합니다.


React Leaflet으로 통합하기

React Leaflet

npm install react-leaflet
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
export default function Map() {
  return (
    <MapContainer
      center={[37.5665, 126.9780]}
      zoom={13}
      style={{ height: '400px', width: '100%' }}
    >
      <TileLayer
        url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution='© OpenStreetMap contributors'
      />
      <Marker position={[37.5665, 126.9780]}>
        <Popup>서울특별시</Popup>
      </Marker>
    </MapContainer>
  );
}

React Leaflet은 Leaflet을 감싼 컴포넌트 라이브러리지만, Leaflet이 DOM을 직접 조작하기 때문에 일반적인 React 컴포넌트와 다르게 동작하는 부분이 있습니다. 가장 흔한 함정은 MapContainer의 center와 zoom이 처음 마운트될 때만 적용된다는 점입니다. 상태가 바뀌어 center prop에 새 좌표를 넘겨도 지도는 움직이지 않는데, 이는 버그가 아니라 사용자가 드래그한 위치를 React 리렌더가 덮어쓰지 않게 하려는 설계입니다. 지도를 코드로 움직이려면 MapContainer 안쪽에 자식 컴포넌트를 두고 useMap()으로 얻은 인스턴스에서 map.setView()나 map.flyTo()를 호출해야 합니다.

import 'leaflet/dist/leaflet.css'를 빠뜨리면 앞의 기본 예제와 같은 타일 흩어짐이 생기고, 앞에서 설명한 기본 마커 아이콘 문제도 똑같이 나타납니다. React 18의 Strict Mode 개발 환경에서는 effect가 두 번 실행되는데, React Leaflet 4 이상은 이를 처리하지만 직접 useEffect에서 L.map()을 호출하는 코드라면 정리 함수에서 map.remove()를 하지 않을 경우 Map container is already initialized 에러가 납니다. 참고로 React Leaflet 4는 React 18, 5는 React 19를 요구하므로 버전을 맞춰 설치해야 합니다.


Vue에서 쓰기

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
const mapContainer = ref<HTMLElement>();
let map: L.Map | undefined;  // ref()로 감싸지 않음: 반응형 프록시가 Leaflet 내부와 충돌
onMounted(() => {
  if (!mapContainer.value) return;
  map = L.map(mapContainer.value).setView([37.5665, 126.9780], 13);
  L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
  L.marker([37.5665, 126.9780])
    .addTo(map)
    .bindPopup('서울특별시')
    .openPopup();
});
onBeforeUnmount(() => {
  map?.remove();  // 이벤트 리스너와 DOM 정리
});
</script>
<template>
  <div ref="mapContainer" style="height: 400px;"></div>
</template>

Vue에서는 onMounted 안에서 초기화해야 합니다. <script setup>이 실행되는 시점에는 템플릿의 div가 아직 DOM에 없으므로 mapContainer.value가 undefined이기 때문입니다. 지도 인스턴스를 ref()나 reactive()에 담지 않고 일반 변수로 둔 데는 이유가 있습니다. Vue 3의 반응형 시스템은 객체를 Proxy로 감싸는데, Leaflet 인스턴스처럼 내부 상태가 복잡한 객체를 프록시로 감싸면 Leaflet 내부에서 비교하는 객체와 프록시가 서로 달라져 줌 애니메이션 중 Cannot read properties of null (reading '_latLngToNewLayerPoint') 같은 이해하기 어려운 에러가 납니다. 템플릿에서 지도 인스턴스를 참조해야 한다면 shallowRef나 markRaw를 쓰면 됩니다. onBeforeUnmount에서 map.remove()를 호출하지 않으면 SPA에서 라우트를 오갈 때마다 이전 지도의 이벤트 리스너와 타일 요청이 남아 메모리가 늘어납니다. Vue용 래퍼가 필요하다면 vue-leaflet(@vue-leaflet/vue-leaflet)도 있지만, 위처럼 직접 붙이는 방식이 Leaflet 문서와 플러그인을 그대로 활용하기에는 더 단순합니다.


지도 이벤트 처리

map.on('click', (e) => {
  console.log('Clicked at:', e.latlng);
  L.marker(e.latlng).addTo(map).bindPopup('You clicked here!').openPopup();
});
marker.on('click', () => {
  console.log('Marker clicked');
});

Leaflet 이벤트 객체의 e.latlng에는 클릭한 지점의 위도·경도가 들어 있어, 클릭한 곳에 마커를 추가하거나 좌표를 폼에 채우는 기능을 쉽게 만들 수 있습니다. 위 코드는 클릭할 때마다 마커가 계속 추가되므로, “위치 하나를 고르는” 기능이라면 마커를 하나만 만들어 두고 marker.setLatLng(e.latlng)로 옮기는 편이 맞습니다. 드래그가 끝났을 때 서버에서 새 데이터를 불러오려면 moveend 이벤트에서 map.getBounds()로 현재 보이는 범위를 구해 API에 넘기는데, 이 이벤트는 사용자가 지도를 조금씩 움직일 때마다 발생하므로 디바운스를 걸어 요청 수를 줄여야 합니다. 등록한 리스너는 map.off('click', handler)로 제거하며, 이때 같은 함수 참조를 넘겨야 하므로 익명 함수 대신 이름 있는 함수로 등록해 두는 것이 좋습니다.

지도가 탭이나 모달, 접혀 있던 패널 안에 있다면 처음 보일 때 타일 일부만 회색으로 비어 보이는 현상이 흔히 생깁니다. Leaflet은 초기화할 때 컨테이너 크기를 측정하는데, 숨겨진 요소는 크기가 0으로 측정되기 때문입니다. 탭이 열리거나 모달이 표시된 직후 map.invalidateSize()를 호출하면 크기를 다시 계산해 정상적으로 그립니다.


markercluster 플러그인

Leaflet.markercluster

npm install leaflet.markercluster
import L from 'leaflet';
import 'leaflet.markercluster';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
const markers = L.markerClusterGroup();
locations.forEach((loc) => {
  const marker = L.marker([loc.lat, loc.lng]);
  markers.addLayer(marker);
});
map.addLayer(markers);

markercluster는 가까이 모인 마커들을 줌 레벨에 따라 숫자가 적힌 원 하나로 묶고, 확대하면 풀어서 보여 줍니다. 수천 개의 마커를 한꺼번에 DOM에 올리는 대신 현재 줌에서 보이는 묶음만 그리므로 성능과 가독성이 함께 좋아집니다. 플러그인의 CSS 두 개를 import하지 않으면 묶음 원이 스타일 없이 깨져 보이는데, JavaScript만 import하고 끝내는 실수가 매우 흔합니다. 마커를 하나씩 addLayer하면 추가할 때마다 클러스터를 다시 계산하므로, 많은 마커는 배열로 모아 markers.addLayers(array)로 한 번에 넣는 편이 훨씬 빠르고, 옵션의 chunkedLoading: true를 켜면 추가 작업을 쪼개서 브라우저가 멈추지 않게 합니다. 다만 수십만 개 규모라면 브라우저에서 모든 점을 받는 것 자체가 부담이므로, 아래 수천 개 마커의 성능에서 설명하듯 서버에서 줌 레벨별로 미리 집계해 보내는 방식이 필요합니다.


내부 동작과 좌표계(EPSG:3857)

Leaflet은 기본적으로 Web Mercator(EPSG:3857) 타일을 가정합니다. 화면은 픽셀이지만, 지도 내부에서는 줌 레벨 z마다 세계를 2^z × 2^z 타일 격자로 나눕니다. {z}/{x}/{y} URL 패턴은 이 타일 주소를 가리킵니다. 경도·위도(WGS84)는 LatLng로 다루고, 타일 계산은 내부적으로 투영 변환을 거칩니다.

실무 함의: GeoJSON은 좌표 순서가 [경도, 위도](RFC 7946)입니다. 실수로 반대로 넣으면 마커가 엉뚱한 해상에 찍힙니다. 한국에서 네이버·카카오 타일을 쓸 때는 각 제공자의 좌표계·URL·사용 약관을 별도로 확인해야 합니다.


타일 정책, 대량 마커, SSR

타일 서버와 사용 정책

OpenStreetMap 공용 타일(tile.openstreetmap.org)은 대량 트래픽·앱 상용 배포에 부적합할 수 있습니다. 서비스 규모가 커지면 자체 타일 서버, MapTiler·Mapbox 등 상용, 또는 정부/기관 제공 WMTS를 검토합니다. CDN 앞에 두면 지연·비용을 동시에 다루기 쉽습니다.

수천 개 마커의 성능

DOM 마커는 수가 늘면 느려집니다. 클러스터(markercluster)·Canvas 레이어·서버 측 클러스터링(줌별 집계) 중 하나를 선택합니다. 뷰포트 밖 데이터는 아예 요청하지 않도록 bbox 쿼리를 API에 두는 패턴이 흔합니다.

React Leaflet과 SSR

react-leaflet은 브라우저 전용 API에 의존하므로 Next.js 등 SSR에서는 dynamic(..., { ssr: false })로 지도 컴포넌트를 감싸 하이드레이션 불일치를 피합니다. 지도 높이가 0이면 타일이 안 보이므로 부모에 명시적 height를 주는 것이 첫 트러블슈팅 단계입니다.


회색 타일, 깨진 아이콘 등 증상별 점검

증상점검
회색 타일만 보임타일 URL·CORS·API 키·줌 범위 minZoom/maxZoom
마커 아이콘이 깨짐webpack/Vite가 default 아이콘 경로를 번들에서 누락 — Icon.Default.imagePath 보정 또는 정적 자산 경로 고정
모바일에서 더블탭 줌이 과함tap 옵션·터치 핸들러와 UI 버튼 충돌 여부
GeoJSON이 한참 떨어진 곳에 표시[lng, lat] 순서·CRS 불일치
메모리가 계속 증가이벤트 리스너·레이어를 remove()하지 않고 라우트만 바꾸는 SPA 패턴 — map.remove() 또는 레이어 정리
탭·모달 안 지도의 일부가 회색숨겨진 상태에서 초기화되어 크기가 0으로 측정됨 — 표시 직후 map.invalidateSize()
Map container is already initialized같은 DOM 요소에 L.map()을 두 번 호출(Strict Mode, HMR) — 정리 함수에서 map.remove()

회색 타일 문제는 원인을 좁히는 순서가 있습니다. 먼저 브라우저 개발자 도구의 Network 탭에서 타일 요청(.png)이 나가는지, 응답 코드가 무엇인지 확인합니다. 요청 자체가 없다면 지도 컨테이너의 높이나 invalidateSize 문제이고, 403이나 429가 보인다면 타일 서버의 이용 정책(API 키, 요청 제한, Referer 제한)에 걸린 것입니다. OpenStreetMap 공용 타일은 이용 정책상 대량 사용이나 앱 식별이 불가능한 요청을 차단할 수 있어서, 개발 중에는 잘 되다가 트래픽이 늘자 타일이 막히는 경우가 실제로 있습니다.


Leaflet 요약과 도입 전에 정할 것

  • Leaflet: 오픈소스 지도 라이브러리
  • 무료: 오픈소스
  • 가벼움: 42KB
  • 모바일 친화적: 터치 제스처
  • GeoJSON: 지리 데이터
  • 플러그인: 수백 개

도입 전에 정할 것

Leaflet을 서비스에 올리기 전에 먼저 정해야 하는 것은 코드가 아니라 타일과 데이터의 출처입니다. 어떤 타일 서버를 쓸지(무료 공용, 상용, 자체 구축), 그 서버의 이용 정책과 표기 의무는 무엇인지, 주소 검색과 길 찾기가 필요하다면 어떤 API를 쓸지를 정해 두면 나머지는 이 글의 예제로 충분히 구현할 수 있습니다. 그다음으로 마커 수의 규모를 가늠해 DOM 마커, 캔버스 렌더링, 클러스터, 서버 측 집계 중 무엇이 맞는지 고르면 됩니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Google Maps와 비교하면 어떤가요?

A. Leaflet이 무료이고 커스터마이징이 자유롭습니다. Google Maps는 더 많은 기능을 제공하지만 비쌉니다.

Q. 한국 지도를 사용할 수 있나요?

A. 기술적으로는 가능하지만 주의할 점이 많습니다. 카카오맵과 네이버 지도는 각자의 JavaScript SDK로 사용하는 것을 전제로 하며, 타일 URL을 직접 Leaflet에 연결하는 방식은 이용 약관상 허용되지 않을 수 있으므로 먼저 약관을 확인해야 합니다. 또 카카오맵 타일은 Web Mercator(EPSG:3857)가 아닌 국내 좌표계(EPSG:5181 등)를 사용해서, proj4leaflet 같은 플러그인으로 좌표계를 정의하지 않으면 타일이 엉뚱하게 배치됩니다. 공공 데이터가 필요하다면 국토교통부 브이월드(VWorld)가 API 키를 받아 Web Mercator 타일을 제공하므로 Leaflet에 연결하기 비교적 쉽습니다.

Q. 모바일에서 잘 작동하나요?

A. 네, 터치 제스처에 최적화되어 있습니다.