지도 위에 마커가 수백 개, 수천 개씩 표시되는 상황을 경험해봤을까. 처음엔 잘 작동하다가 마커가 늘어날수록 지도가 버벅거리고, 줌인/줌아웃 할 때마다 화면이 멈춘다. 다만 대부분의 개발자들은 이게 마커 개수 때문인지, Kakao Map API의 문제인지, 아니면 자신의 코드가 비효율적인지 정확히 파악하지 못한 채 무작정 마커를 숨겼다 보였다 반복한다. 이번에는 Kakao Map API에서 대량의 마커를 안정적으로 렌더링하는 방법, 클러스터링으로 시각적 혼란을 줄이는 방법, 그리고 실제 성능 최적화 기법을 완벽하게 정리해서 소개하겠다.
Kakao Map API에서 마커는 DOM 요소가 아니라 캔버스 기반 렌더링이다. 따라서 HTML div처럼 개별 이벤트를 바인딩하거나 CSS로 스타일링하지 못한다. 문제는 마커 객체가 메모리에 누적되고, 화면에 보이지 않는 마커도 여전히 계산 대상이 된다는 점이다. 마커 500개만 해도 지도 드래그와 줌 이벤트 때마다 모든 마커의 위치를 다시 계산하므로 프레임 드롭이 발생한다.
이 문제를 해결하는 핵심은 세 가지다. 첫째, 화면에 보이는 영역(뷰포트) 내의 마커만 렌더링하는 뷰포트 필터링. 둘째, 여러 마커를 하나의 클러스터로 묶어서 표현하는 클러스터링. 셋째, 마커 객체를 지도에 일괄 추가하지 말고 필요할 때만 생성하는 레이지 로딩이다.
가장 간단한 해결책은 Kakao에서 제공하는 공식 MarkerClusterer 라이브러리를 쓰는 것이다. 이 라이브러리는 자동으로 마커들을 줌 레벨과 지리적 거리에 따라 그룹화한다.
<!DOCTYPE html>
<html>
<head>
<script src="//dapi.kakao.com/v2/maps/sdk.js?appkey=YOUR_APP_KEY"></script>
<script src="//dapi.kakao.com/v2/maps/sdk.js?appkey=YOUR_APP_KEY&libraries=clusterer"></script>
</head>
<body>
<div id="map" style="width:100%; height:600px;"></div>
<script>
var mapContainer = document.getElementById('map');
var map = new kakao.maps.Map(mapContainer, {
center: new kakao.maps.LatLng(37.5665, 126.9780),
level: 5
});
var clusterer = new kakao.maps.MarkerClusterer({
map: map,
averageCenter: true,
minLevel: 10
});
var markers = [];
var markerData = [
{ lat: 37.5665, lng: 126.9780, title: '서울' },
{ lat: 37.2637, lng: 127.0286, title: '성남' },
{ lat: 37.4979, lng: 127.0276, title: '서초' }
// 실제로는 수천 개의 데이터
];
markerData.forEach(function(data) {
var marker = new kakao.maps.Marker({
position: new kakao.maps.LatLng(data.lat, data.lng),
title: data.title
});
markers.push(marker);
});
clusterer.addMarkers(markers);
</script>
</body>
</html>
✓ 올바른 방식. MarkerClusterer는 마커들을 자동으로 그룹화하고, 줌 레벨 변화에 따라 동적으로 클러스터 크기를 조정한다. 'minLevel' 옵션으로 클러스터링이 시작되는 최소 줌 레벨을 지정할 수 있다. averageCenter를 true로 설정하면 클러스터의 중심을 포함된 마커들의 평균 좌표로 계산한다.
기본 클러스터 외형은 회색 원에 숫자만 표시된다. 실무에서는 보통 브랜드 색상이나 특정 아이콘을 쓰려고 한다. 또한 클러스터를 클릭했을 때 줌인하거나 상세 정보를 보여줘야 한다.
var clusterer = new kakao.maps.MarkerClusterer({
map: map,
averageCenter: true,
minLevel: 10,
disableClickZoom: false,
styles: [
{
width: 53,
height: 52,
background: 'url("https://example.com/cluster0.png") no-repeat',
backgroundSize: '100%',
color: '#fff',
textSize: 12,
fontWeight: 'bold'
},
{
width: 56,
height: 55,
background: 'url("https://example.com/cluster1.png") no-repeat',
backgroundSize: '100%',
color: '#fff',
textSize: 12,
fontWeight: 'bold'
},
{
width: 66,
height: 65,
background: 'url("https://example.com/cluster2.png") no-repeat',
backgroundSize: '100%',
color: '#fff',
textSize: 12,
fontWeight: 'bold'
}
]
});
clusterer.addMarkers(markers);
// 클러스터 클릭 이벤트 처리
kakao.maps.event.addListener(clusterer, 'clusterclick', function(cluster) {
var level = map.getLevel() - 1;
map.setLevel(level, { anchor: cluster.getCenter() });
});
✓ 올바른 방식. styles 배열은 마커 개수 범위에 따라 3단계(또는 더 많은 단계)의 서로 다른 클러스터 이미지를 정의한다. 예를 들어 styles[0]은 2-9개, styles[1]은 10-29개, styles[2]는 30개 이상의 마커를 그룹화한 클러스터에 적용된다. disableClickZoom을 false로 설정하면 클러스터 클릭 시 자동 줌인 기능이 활성화된다.
MarkerClusterer만으로도 충분한 경우가 많지만, 초기 로드 시 데이터가 엄청 크거나 마커별 개별 이벤트 처리가 복잡하다면 뷰포트 필터링을 추가로 적용해야 한다. 이 방식은 현재 지도 범위 내의 마커만 객체 생성을 한다.
var map = new kakao.maps.Map(mapContainer, {
center: new kakao.maps.LatLng(37.5665, 126.9780),
level: 5
});
var visibleMarkers = [];
var allMarkerData = []; // 서버에서 로드한 전체 마커 데이터
function filterMarkersByViewport() {
// 기존 마커 모두 제거
visibleMarkers.forEach(function(marker) {
marker.setMap(null);
});
visibleMarkers = [];
var bounds = map.getBounds();
var sw = bounds.getSouthWest();
var ne = bounds.getNorthEast();
// 현재 뷰포트 범위와 교차하는 마커만 필터링
allMarkerData.forEach(function(data) {
if (data.lat >= sw.getLat() && data.lat <= ne.getLat() &&
data.lng >= sw.getLng() && data.lng <= ne.getLng()) {
var marker = new kakao.maps.Marker({
position: new kakao.maps.LatLng(data.lat, data.lng),
title: data.title
});
marker.setMap(map);
visibleMarkers.push(marker);
}
});
}
// 초기 필터링
filterMarkersByViewport();
// 지도 이동/줌 변화 시 필터링 재실행
kakao.maps.event.addListener(map, 'idle', function() {
filterMarkersByViewport();
});
✓ 올바른 방식. map.getBounds()로 현재 지도 범위를 구하고, 그 범위에 포함된 마커만 생성한다. 'idle' 이벤트(지도 이동/줌 완료 후)에 필터링을 다시 실행하므로, 사용자가 지도를 움직일 때마다 보이는 영역의 마커만 메모리에 로드된다. 이 방식은 마커가 10,000개 이상인 경우 특히 효과적이다.
✗ 잘못된 방식. 모든 마커를 미리 생성한 후 클러스터링 적용.
// ✗ 나쁜 예: 10,000개 마커를 모두 메모리에 로드
var markers = [];
for (var i = 0; i < 10000; i++) {
var marker = new kakao.maps.Marker({
position: new kakao.maps.LatLng(Math.random() * 10, Math.random() * 10)
});
markers.push(marker);
}
clusterer.addMarkers(markers); // 이 시점에 모든 마커 객체가 메모리 점유
이 방식은 마커 객체 생성 단계에서 이미 메모리를 대량 점유한다. 클러스터링이 시각적 혼란을 줄이더라도 렌더링 성능 개선에는 도움이 되지 않는다.
✓ 올바른 방식. 서버에서 필요한 데이터만 분할 로드하고, 필터링 후 마커 생성.
// ✓ 좋은 예: 처음엔 현재 지도 범위 데이터만 서버에서 받기
fetch('/api/markers?lat=' + map.getCenter().getLat() + '&lng=' + map.getCenter().getLng() + '&zoom=' + map.getLevel())
.then(response => response.json())
.then(data => {
allMarkerData = data;
filterMarkersByViewport();
});
// 지도 이동 후 1초 이상 멈춤 상태일 때만 새 데이터 요청
var filterTimeout;
kakao.maps.event.addListener(map, 'idle', function() {
clearTimeout(filterTimeout);
filterTimeout = setTimeout(function() {
fetch('/api/markers?bounds=' + encodeBounds(map.getBounds()))
.then(response => response.json())
.then(data => {
allMarkerData = data;
filterMarkersByViewport();
});
}, 1000); // 1초 디바운싱
});
서버 API가 지도 범위를 기준으로 데이터를 필터링해서 반환하도록 설계하는 것이 필수다. 그렇지 않으면 결국 전체 마커 데이터를 클라이언트로 보내야 하므로 최적화 효과가 없다.
다음은 MarkerClusterer와 뷰포트 필터링, 커스텀 이벤트를 모두 포함한 완전한 예제다.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>편의점 위치 지도</title>
<script src="//dapi.kakao.com/v2/maps/sdk.js?appkey=YOUR_APP_KEY&libraries=clusterer"></script>
<style>
#map { width: 100%; height: 600px; }
#info { padding: 10px; background: #f0f0f0; margin-top: 10px; }
</style>
</head>
<body>
<div id="map"></div>
<div id="info">로딩 중...</div>
<script>
var mapContainer = document.getElementById('map');
var map = new kakao.maps.Map(mapContainer, {
center: new kakao.maps.LatLng(37.5665, 126.9780),
level: 5
});
var clusterer = new kakao.maps.MarkerClusterer({
map: map,
averageCenter: true,
minLevel: 10,
disableClickZoom: false,
styles: [
{ width: 53, height: 52, background: 'rgba(51,187,255,0.3)', color: '#0066ff', textSize: 10, fontWeight: 'bold' },
{ width: 56, height: 55, background: 'rgba(51,187,255,0.5)', color: '#0066ff', textSize: 11, fontWeight: 'bold' },
{ width: 66, height: 65, background: 'rgba(51,187,255,0.7)', color: '#0066ff', textSize: 12, fontWeight: 'bold' }
]
});
var allMarkers = [];
var markerData = [
{ lat: 37.5665, lng: 126.9780, name: 'CU 종로점' },
{ lat: 37.4979, lng: 127.0276, name: 'GS25 서초점' },
{ lat: 37.4989, lng: 127.0278, name: 'CU 강남점' },
{ lat: 37.5000, lng: 126.9900, name: 'emart24 명동점' }
];
markerData.forEach(function(data) {
var marker = new kakao.maps.Marker({
position: new kakao.maps.LatLng(data.lat, data.lng),
title: data.name
});
allMarkers.push(marker);
kakao.maps.event.addListener(marker, 'click', function() {
document.getElementById('info').innerHTML = '선택: ' + data.name;
});
});
clusterer.addMarkers(allMarkers);
kakao.maps.event.addListener(clusterer, 'clusterclick', function(cluster) {
var level = map.getLevel() - 1;
map.setLevel(level, { anchor: cluster.getCenter() });
document.getElementById('info').innerHTML = '클러스터 포함 마커: ' + cluster.getMarkerCount() + '개';
});
document.getElementById('info').innerHTML = '총 ' + markerData.length + '개 편의점 표시됨';
</script>
</body>
</html>
✓ 올바른 방식. MarkerClusterer로 마커를 그룹화하고, 개별 마커 클릭 시 정보를 표시하며, 클러스터 클릭 시 줌인하는 완전한 흐름을 구현했다.
Kakao Map에서 대량 마커를 다루는 것은 단순히 마커 객체를 추가하는 것이 아니라, 화면에 보이는 것만 처리하고 불필요한 계산을 줄이는 전략이 필요하다. MarkerClusterer 라이브러리는 시각적 혼란을 줄이고, 뷰포트 필터링은 메모리 사용량을 획기적으로 낮춘다. 이 두 가지 기법을 조합하면 수천 개의 마커도 부드럽게 렌더링할 수 있다. 이 글의 '뷰포트 필터링' 섹션을 참고해 서버 API를 먼저 개선한 후, MarkerClusterer를 적용하면 최고의 성능을 얻을 수 있을 것이다.