0. 지도가 너무 렉이 걸려!
지금 작업하고 있는 프로젝트는 네이버 지도를 활용해서 사용자가 저장한 식당 위치를 지도에 띄워주는 기능이 있다. 커스텀 마커를 생성하고 마커에 대한 정보들을 html로 변환 후, 마커의 content에 추가해주었다. 네이버 지도에 마커가 하나씩 생성될 때마다 DOM 요소가 하나씩 생성된다. 즉, 마커가 생성되는 개수가 늘어날수록 렌더링 성능에 영향을 끼치게 된다. 실제로 마커가 500개 넘게 네이버 지도에 추가되었을 경우, 줌과 드래그를 할 때 너무 렉이 걸려서 사용자 경험에 악영향을 끼치게 되었다. 그래서 일주일에 걸쳐서 네이버 지도를 최적화하기 위해서 여러 가지 시도를 하며 삽질을 해보았다.

1. HTML 마커 방식 대신 캔버스를 사용해보자
위에서 설명했듯이, 마커가 하나씩 추가될 때마다 DOM 요소 또한 하나씩 추가되어서 렌더링 속도가 느려졌다. 그렇다면, 네이버 지도에 직접 마커를 추가하지 말고 네이버 지도 위에 캔버스를 추가해서 그 위에 마커를 렌더링하면 어떨까.
(네이버 지도를 캔버스로 어떻게 구현해야 할 지 아래 블로그를 통해서 확인할 수 있다.
작성자분께서 상세히 작성해주셔서 많은 도움이 되었다.
링크: https://velog.io/@happyhyep/지도와-함께-움직이는-캔버스-구현-스토리 )
전체적인 구조를 설명하자면, 네이버 지도 위에 마커 렌더링용 캔버스를 추가하고, 그 위에 마우스 이벤트용 투명 레이어를 덧씌었다.
캔버스 방식에서 중요하게 생각해야할 점이 있다면,
1. 마커는 지도에 박혀있는 상황이 아니라, 지도 위의 캔버스에 좌표로 존재한다.
-> 드래그를 하거나 줌을 할 때, 지도 위에 캔버스가 재렌더링되지 않는다면 마커는 이상한 위치에 존재하게 된다.
2. 마우스레이어 밑에 캔버스 밑에 네이버 지도가 있다. 드래그나 줌을 할 때 발생하는 이벤트가 캔버스에도, 네이버 지도에도 전달이 되어야한다. (마커에 hover를 했을 때에는 InfoWindow가 띄어져야 되서, 이 부분도 고려해야한다)
-> 이벤트 버블링과 캡처링을 잘 활용해야한다.
2. 드래그를 하면 마커가 자꾸 이상한 곳으로 이동한다
1번에서 많이 애를 먹었었다. 줌을 하고나면 자동으로 지도가 자동으로 재렌더링이 되면서 마커들이 원래 위치에 있었는데, 드래그를 할 때에는 마커들이 자꾸 따로 이상한 위치로 이동했다.

로직 상으로는 드래그를 하면 캔버스가 재렌더링되면서 마커가 올바르게 이동해야되는데 왜 이런 일이 발생하는지 파악하지 못해서 답답했는데, 다행히 사수분께서 해결법을 알려주셔서 수정할 수 있었다.
const projection = this.map.getProjection();
if (!projection) return;
// 각 마커의 위치를 현재 지도 투영에 맞게 업데이트
this.markers = this.markers.map((marker) => {
// 지리적 좌표는 유지하되 화면 좌표를 재계산
const position = projection.fromCoordToOffset(marker.position);
// 마커 정보 유지하면서 업데이트된 화면 좌표 정보 추가
return {
...marker,
screenPosition: position, // 선택적으로 화면 좌표 캐싱
};
});
일단 나는 지도에 상호작용이 발생했을 때 map에서 가져온 projection을 fromCoordToOffset 메서드를 통해서 마커 위치를 재렌더링했었다.
-> 하지만 알고보니 드래그 시에 projection 객체는 변하지 않았고, 직접 마커의 위도와 경도를 계산해서 적용하니 드래그를 해도 마커가 잘 따라올 수 있었다.
// screenPosition을 interface에 선언
screenPosition?: { x: number; y: number };
}
(...)
this.markers = this.markers.map((marker) => {
try {
const lat = marker.position.lat();
const lng = marker.position.lng();
// 2. 직접 좌표 변환 로직 구현
// 마커의 경/위도를 지도 경계 내 상대적 위치(0~1)로 변환
const relativeX = (lng - minLng) / boundsWidth;
const relativeY = (maxLat - lat) / boundsHeight; // 위도는 위에서 아래로 감소하므로 반전
// 상대적 위치를 픽셀 좌표로 변환
const x = relativeX * mapSize.width;
const y = relativeY * mapSize.height;
// 계산된 화면 좌표
const position = { x, y };
// 마커 정보 유지하면서 업데이트된 화면 좌표 정보 추가
return {
...marker,
screenPosition: position,
};
} catch (error) {
// 오류 발생 시 기존 마커 정보 반환
localConsole?.error(`마커 ${marker.id} 좌표 변환 오류:`, error);
return marker;
}
3. 상호작용 중 마커를 안 보이게 하면 성능이 개선된다
캔버스 방식으로 전환했지만 드래그나 줌을 할 때 렉이 개선되지 않았다. 아무래도 상호작용 중에 마커의 위치를 맞추기 위해 캔버스를 재렌더링하는 비용이 크기 때문인 것 같았다. 결국, 마커가 많을 때에는 html 마커 방식이나 캔버스 방식이나 렉이 걸렸다.
그런 와중 줌이나 드래그를 하는 중에 마커를 안보이게 하면 렌더링이 빨라지지 않을까 생각해서 한 번 구현해보았다.
놀랍게도, 상호작용 중에 마커를 안 보이게 하면 속도가 훨씬 개선되었다! 아무래도 상호작용 중에 마커 렌더링을 하지 않아도 되니 계산량이 줄어들어 성능이 개선된 것 같다.
html 마커 방식에서 똑같이 적용하면 어떨까, 구현해보니 html 마커 방식에서도 속도가 눈에 띄게 개선되었다.
-> 그래서 네이버 지도는 다시 html 마커 방식으로 구현하기로 했다.
4. 마커 클러스터링과 마커 필터링
지도 위에 마커가 많을 때 어떻게 성능을 개선할 수 있을까 검색을 해보았을 때, 마커 클러스터링을 적용해 보라는 답변이 많이 나왔었다. 그래서 마커 클러스터링을 한 번 적용해보았는데 렉이 거의 발생하지 않았다. 하지만 마커 클러스터링이 사용자 경험에 별로 좋지 않다는 의견이 있었고, 마커 클러스터링을 적용하는 대신에 지도가 작을 때는 중요도가 낮은 마커만 표시하고 확대할 수록 식당들을 더 표기하는 마커 필터링을 적용하게 되었다.

마커 필터링은 두 가지 방식으로 구현할 수 있었는데,
- 지도를 그리드로 쪼갠 다음 그리드 안에 정해진 개수의 마커 개수 이상으로 표시되지 않게 하는 방법
- 마커 간 거리 기준을 정해놓고(예시: 10px) 마커가 기준 이상으로 가까워지면 필터링하는 방법
마커 겹침을 막기 위해서는 2번 방법이 더 유효한 거 같아서 나는 2번으로 구현하였다.
interface MarkerWithPosition {
marker: naver.maps.Marker;
screenPosition: naver.maps.Point;
기준: number;
}
const markersToShow: naver.maps.Marker[] = [];
const minDistance = 10; // 마커 간 최소 픽셀 거리
const occupiedAreas: { x: number; y: number }[] = [];
markersWithPositions.forEach(({ marker, screenPosition }) => {
// 다른 마커와 너무 가까운지 확인
const hasOverlap = occupiedAreas.some((pos) => {
const dx = pos.x - screenPosition.x;
const dy = pos.y - screenPosition.y;
return dx * dx + dy * dy < minDistance * minDistance;
});
if (!hasOverlap) {
markersToShow.push(marker);
occupiedAreas.push(screenPosition);
}
});
// 선택된 마커만 표시
markersToShow.forEach((marker) => marker.setVisible(true));
어떤 마커를 표시할지는 기준을 정해서 정렬을 수행하였는데, 걱정과는 다르게 크게 렌더링 성능이 영향을 끼치지는 않은 것 같아서 다행이었다.
5. 네이버 지도 옵션 변경
네이버 지도 옵션 중 렌더링에 영향을 주는 옵션들을 꺼버릴 수는 없을까. 최대한 성능을 최적화하기 위해서 다음과 같은 옵션을 껐다.
if (!naverMap.current) {
try {
const mapOptions = {
...mapOptionSelector(domain),
tileTransition: false, // 타일 전환 애니메이션 비활성화
baseTileOpacity: 1, // 베이스 타일 최대 불투명도
maxTileSize: 256, // 타일 크기 최적화
baseTileFadeInZoom: 0, // 페이드인 효과 제거
disableKineticPan: true, // 관성 이동 효과 비활성화
scaleControl: false, // 줌 컨트롤러 비활성화
};
naverMap.current = new naver.maps.Map("map", mapOptions);
성능 개선에 제일 도움을 준건 tileTransition : false였다.
-> 이 옵션을 해제하면 줌을 할 때 애니메이션이 적용되지 않아서 살짝 부자연스럽지만 (약간 깨져보이기도 한다) 속도가 빨라졌다.
하지만 회의 결과 이 애니메이션이 적용되는 것이 사용자 경험이 좋아보인다는 결론이 나와서 tileTransition은 유지하기로 했다.
6. 줌 애니메이션 개선 시도
const tilesloadedListener = naver.maps.Event.addListener(
naverMap.current,
"tilesloaded",
() => {
setTimeout(() => {
if (!isDragging.current) {
markersVisible = true;
updateVisibleMarkers();
}
}, 200);
}
);
타일 로딩이 다 끝나고 200ms 이후에 마커가 표시되도록 구현해보았지만 줌 애니메이션의 버벅임은 개선되지 않았다. 다른 것도 여러 번 시도 후에 나온 결론은,
나는 dragend와 zoom_changed를 활용했는데, 지도에서 상호작용이 끝난 순간인 ‘idle’ eventlistener를 대신 활용할 수 있다.
단, 드래그가 끝났을 때와 줌이 끝났을 때의 구현을 달리할거면 따로 이벤트를 적용하는 것이 좋다. (setTimeout 시간을 따로 준다든지)
7. 쿼드트리 알고리즘 적용
사실 뷰포트 바깥에 있는 마커들만 표시하는 최적화도 진행했는데, 이 때 쿼드트리 알고리즘을 적용했었다. (마커 필터링을 진행할 때 마커들 간의 거리를 계산할 때에도)
(...)
/**
* 현재 노드를 4개의 하위 노드로 분할
*/
subdivide(): void {
const x = this.bounds.x;
const y = this.bounds.y;
const w = this.bounds.width / 2;
const h = this.bounds.height / 2;
const nextDepth = this.depth + 1;
// 북서쪽
this.children[0] = new QuadNode(
{ x, y, width: w, height: h },
this.maxPoints,
this.maxDepth,
nextDepth
);
// 북동쪽
this.children[1] = new QuadNode(
{ x: x + w, y, width: w, height: h },
this.maxPoints,
this.maxDepth,
nextDepth
);
// 남서쪽
this.children[2] = new QuadNode(
{ x, y: y + h, width: w, height: h },
this.maxPoints,
this.maxDepth,
nextDepth
);
// 남동쪽
this.children[3] = new QuadNode(
{ x: x + w, y: y + h, width: w, height: h },
this.maxPoints,
this.maxDepth,
nextDepth
);
// 기존 포인트들을 자식 노드로 이동
this.points.forEach((point) => {
for (const child of this.children) {
if (this.pointInBounds(point, child.bounds)) {
child.insert(point);
break;
}
}
});
this.points = [];
this.divided = true;
}
(...)
위와 같은 시행착오 끝에 결국 어떤 최적화를 적용했냐면,
1. 마커 간의 거리를 기준으로 마커 필터링
2. 지도와 상호작용 시에는 마커를 안보이게 만듦
3. 뷰포트 바깥에 있는 마커는 표시하지 않음
4. 쿼드트리 알고리즘 사용
5. 네이버 지도 옵션에서 불필요한 옵션 끄기
0. 지도가 너무 렉이 걸려!
지금 작업하고 있는 프로젝트는 네이버 지도를 활용해서 사용자가 저장한 식당 위치를 지도에 띄워주는 기능이 있다. 커스텀 마커를 생성하고 마커에 대한 정보들을 html로 변환 후, 마커의 content에 추가해주었다. 네이버 지도에 마커가 하나씩 생성될 때마다 DOM 요소가 하나씩 생성된다. 즉, 마커가 생성되는 개수가 늘어날수록 렌더링 성능에 영향을 끼치게 된다. 실제로 마커가 500개 넘게 네이버 지도에 추가되었을 경우, 줌과 드래그를 할 때 너무 렉이 걸려서 사용자 경험에 악영향을 끼치게 되었다. 그래서 일주일에 걸쳐서 네이버 지도를 최적화하기 위해서 여러 가지 시도를 하며 삽질을 해보았다.

1. HTML 마커 방식 대신 캔버스를 사용해보자
위에서 설명했듯이, 마커가 하나씩 추가될 때마다 DOM 요소 또한 하나씩 추가되어서 렌더링 속도가 느려졌다. 그렇다면, 네이버 지도에 직접 마커를 추가하지 말고 네이버 지도 위에 캔버스를 추가해서 그 위에 마커를 렌더링하면 어떨까.
(네이버 지도를 캔버스로 어떻게 구현해야 할 지 아래 블로그를 통해서 확인할 수 있다.
작성자분께서 상세히 작성해주셔서 많은 도움이 되었다.
링크: https://velog.io/@happyhyep/지도와-함께-움직이는-캔버스-구현-스토리 )
전체적인 구조를 설명하자면, 네이버 지도 위에 마커 렌더링용 캔버스를 추가하고, 그 위에 마우스 이벤트용 투명 레이어를 덧씌었다.
캔버스 방식에서 중요하게 생각해야할 점이 있다면,
1. 마커는 지도에 박혀있는 상황이 아니라, 지도 위의 캔버스에 좌표로 존재한다.
-> 드래그를 하거나 줌을 할 때, 지도 위에 캔버스가 재렌더링되지 않는다면 마커는 이상한 위치에 존재하게 된다.
2. 마우스레이어 밑에 캔버스 밑에 네이버 지도가 있다. 드래그나 줌을 할 때 발생하는 이벤트가 캔버스에도, 네이버 지도에도 전달이 되어야한다. (마커에 hover를 했을 때에는 InfoWindow가 띄어져야 되서, 이 부분도 고려해야한다)
-> 이벤트 버블링과 캡처링을 잘 활용해야한다.
2. 드래그를 하면 마커가 자꾸 이상한 곳으로 이동한다
1번에서 많이 애를 먹었었다. 줌을 하고나면 자동으로 지도가 자동으로 재렌더링이 되면서 마커들이 원래 위치에 있었는데, 드래그를 할 때에는 마커들이 자꾸 따로 이상한 위치로 이동했다.

로직 상으로는 드래그를 하면 캔버스가 재렌더링되면서 마커가 올바르게 이동해야되는데 왜 이런 일이 발생하는지 파악하지 못해서 답답했는데, 다행히 사수분께서 해결법을 알려주셔서 수정할 수 있었다.
const projection = this.map.getProjection();
if (!projection) return;
// 각 마커의 위치를 현재 지도 투영에 맞게 업데이트
this.markers = this.markers.map((marker) => {
// 지리적 좌표는 유지하되 화면 좌표를 재계산
const position = projection.fromCoordToOffset(marker.position);
// 마커 정보 유지하면서 업데이트된 화면 좌표 정보 추가
return {
...marker,
screenPosition: position, // 선택적으로 화면 좌표 캐싱
};
});
일단 나는 지도에 상호작용이 발생했을 때 map에서 가져온 projection을 fromCoordToOffset 메서드를 통해서 마커 위치를 재렌더링했었다.
-> 하지만 알고보니 드래그 시에 projection 객체는 변하지 않았고, 직접 마커의 위도와 경도를 계산해서 적용하니 드래그를 해도 마커가 잘 따라올 수 있었다.
// screenPosition을 interface에 선언
screenPosition?: { x: number; y: number };
}
(...)
this.markers = this.markers.map((marker) => {
try {
const lat = marker.position.lat();
const lng = marker.position.lng();
// 2. 직접 좌표 변환 로직 구현
// 마커의 경/위도를 지도 경계 내 상대적 위치(0~1)로 변환
const relativeX = (lng - minLng) / boundsWidth;
const relativeY = (maxLat - lat) / boundsHeight; // 위도는 위에서 아래로 감소하므로 반전
// 상대적 위치를 픽셀 좌표로 변환
const x = relativeX * mapSize.width;
const y = relativeY * mapSize.height;
// 계산된 화면 좌표
const position = { x, y };
// 마커 정보 유지하면서 업데이트된 화면 좌표 정보 추가
return {
...marker,
screenPosition: position,
};
} catch (error) {
// 오류 발생 시 기존 마커 정보 반환
localConsole?.error(`마커 ${marker.id} 좌표 변환 오류:`, error);
return marker;
}
3. 상호작용 중 마커를 안 보이게 하면 성능이 개선된다
캔버스 방식으로 전환했지만 드래그나 줌을 할 때 렉이 개선되지 않았다. 아무래도 상호작용 중에 마커의 위치를 맞추기 위해 캔버스를 재렌더링하는 비용이 크기 때문인 것 같았다. 결국, 마커가 많을 때에는 html 마커 방식이나 캔버스 방식이나 렉이 걸렸다.
그런 와중 줌이나 드래그를 하는 중에 마커를 안보이게 하면 렌더링이 빨라지지 않을까 생각해서 한 번 구현해보았다.
놀랍게도, 상호작용 중에 마커를 안 보이게 하면 속도가 훨씬 개선되었다! 아무래도 상호작용 중에 마커 렌더링을 하지 않아도 되니 계산량이 줄어들어 성능이 개선된 것 같다.
html 마커 방식에서 똑같이 적용하면 어떨까, 구현해보니 html 마커 방식에서도 속도가 눈에 띄게 개선되었다.
-> 그래서 네이버 지도는 다시 html 마커 방식으로 구현하기로 했다.
4. 마커 클러스터링과 마커 필터링
지도 위에 마커가 많을 때 어떻게 성능을 개선할 수 있을까 검색을 해보았을 때, 마커 클러스터링을 적용해 보라는 답변이 많이 나왔었다. 그래서 마커 클러스터링을 한 번 적용해보았는데 렉이 거의 발생하지 않았다. 하지만 마커 클러스터링이 사용자 경험에 별로 좋지 않다는 의견이 있었고, 마커 클러스터링을 적용하는 대신에 지도가 작을 때는 중요도가 낮은 마커만 표시하고 확대할 수록 식당들을 더 표기하는 마커 필터링을 적용하게 되었다.

마커 필터링은 두 가지 방식으로 구현할 수 있었는데,
- 지도를 그리드로 쪼갠 다음 그리드 안에 정해진 개수의 마커 개수 이상으로 표시되지 않게 하는 방법
- 마커 간 거리 기준을 정해놓고(예시: 10px) 마커가 기준 이상으로 가까워지면 필터링하는 방법
마커 겹침을 막기 위해서는 2번 방법이 더 유효한 거 같아서 나는 2번으로 구현하였다.
interface MarkerWithPosition {
marker: naver.maps.Marker;
screenPosition: naver.maps.Point;
기준: number;
}
const markersToShow: naver.maps.Marker[] = [];
const minDistance = 10; // 마커 간 최소 픽셀 거리
const occupiedAreas: { x: number; y: number }[] = [];
markersWithPositions.forEach(({ marker, screenPosition }) => {
// 다른 마커와 너무 가까운지 확인
const hasOverlap = occupiedAreas.some((pos) => {
const dx = pos.x - screenPosition.x;
const dy = pos.y - screenPosition.y;
return dx * dx + dy * dy < minDistance * minDistance;
});
if (!hasOverlap) {
markersToShow.push(marker);
occupiedAreas.push(screenPosition);
}
});
// 선택된 마커만 표시
markersToShow.forEach((marker) => marker.setVisible(true));
어떤 마커를 표시할지는 기준을 정해서 정렬을 수행하였는데, 걱정과는 다르게 크게 렌더링 성능이 영향을 끼치지는 않은 것 같아서 다행이었다.
5. 네이버 지도 옵션 변경
네이버 지도 옵션 중 렌더링에 영향을 주는 옵션들을 꺼버릴 수는 없을까. 최대한 성능을 최적화하기 위해서 다음과 같은 옵션을 껐다.
if (!naverMap.current) {
try {
const mapOptions = {
...mapOptionSelector(domain),
tileTransition: false, // 타일 전환 애니메이션 비활성화
baseTileOpacity: 1, // 베이스 타일 최대 불투명도
maxTileSize: 256, // 타일 크기 최적화
baseTileFadeInZoom: 0, // 페이드인 효과 제거
disableKineticPan: true, // 관성 이동 효과 비활성화
scaleControl: false, // 줌 컨트롤러 비활성화
};
naverMap.current = new naver.maps.Map("map", mapOptions);
성능 개선에 제일 도움을 준건 tileTransition : false였다.
-> 이 옵션을 해제하면 줌을 할 때 애니메이션이 적용되지 않아서 살짝 부자연스럽지만 (약간 깨져보이기도 한다) 속도가 빨라졌다.
하지만 회의 결과 이 애니메이션이 적용되는 것이 사용자 경험이 좋아보인다는 결론이 나와서 tileTransition은 유지하기로 했다.
6. 줌 애니메이션 개선 시도
const tilesloadedListener = naver.maps.Event.addListener(
naverMap.current,
"tilesloaded",
() => {
setTimeout(() => {
if (!isDragging.current) {
markersVisible = true;
updateVisibleMarkers();
}
}, 200);
}
);
타일 로딩이 다 끝나고 200ms 이후에 마커가 표시되도록 구현해보았지만 줌 애니메이션의 버벅임은 개선되지 않았다. 다른 것도 여러 번 시도 후에 나온 결론은,
나는 dragend와 zoom_changed를 활용했는데, 지도에서 상호작용이 끝난 순간인 ‘idle’ eventlistener를 대신 활용할 수 있다.
단, 드래그가 끝났을 때와 줌이 끝났을 때의 구현을 달리할거면 따로 이벤트를 적용하는 것이 좋다. (setTimeout 시간을 따로 준다든지)
7. 쿼드트리 알고리즘 적용
사실 뷰포트 바깥에 있는 마커들만 표시하는 최적화도 진행했는데, 이 때 쿼드트리 알고리즘을 적용했었다. (마커 필터링을 진행할 때 마커들 간의 거리를 계산할 때에도)
(...)
/**
* 현재 노드를 4개의 하위 노드로 분할
*/
subdivide(): void {
const x = this.bounds.x;
const y = this.bounds.y;
const w = this.bounds.width / 2;
const h = this.bounds.height / 2;
const nextDepth = this.depth + 1;
// 북서쪽
this.children[0] = new QuadNode(
{ x, y, width: w, height: h },
this.maxPoints,
this.maxDepth,
nextDepth
);
// 북동쪽
this.children[1] = new QuadNode(
{ x: x + w, y, width: w, height: h },
this.maxPoints,
this.maxDepth,
nextDepth
);
// 남서쪽
this.children[2] = new QuadNode(
{ x, y: y + h, width: w, height: h },
this.maxPoints,
this.maxDepth,
nextDepth
);
// 남동쪽
this.children[3] = new QuadNode(
{ x: x + w, y: y + h, width: w, height: h },
this.maxPoints,
this.maxDepth,
nextDepth
);
// 기존 포인트들을 자식 노드로 이동
this.points.forEach((point) => {
for (const child of this.children) {
if (this.pointInBounds(point, child.bounds)) {
child.insert(point);
break;
}
}
});
this.points = [];
this.divided = true;
}
(...)
위와 같은 시행착오 끝에 결국 어떤 최적화를 적용했냐면,
1. 마커 간의 거리를 기준으로 마커 필터링
2. 지도와 상호작용 시에는 마커를 안보이게 만듦
3. 뷰포트 바깥에 있는 마커는 표시하지 않음
4. 쿼드트리 알고리즘 사용
5. 네이버 지도 옵션에서 불필요한 옵션 끄기