저는 최근 지도 위에 캔버스에서 그림을 그릴 수 있는 기능을 구현하는 프로젝트를 진행했습니다.
이 과정에서 가장 큰 고민은 줌인/줌아웃과 이동(팬)을 했을 때, 지도와 캔버스가 함께 움직이며, 그린 그림이 좌표에 따라 올바르게 유지되도록 만드는 방법이었습니다.
고민 끝에 설계를 구체화하고, 이를 구현하였는데, 이번 포스팅에서는 처음 설계와 고민했던 한계, 설계 변경 과정, 그리고 최종적으로 구현한 방법을 중심으로 소개해보려 합니다.
사실, 이 지도-캔버스 연동 과정은 프로젝트에서 제가 맡았던 부분이 아니었는데 팀원 분께서 2주 동안 구현하지 못하셔서 머리를 싸매시는 것을 보고……
그렇게 어려운지 저도 승부욕이 생겨서 주말에 잠깐 시도해보았다가 해결해버린 태스크였는데요……..
저희 프로젝트의 핵심 기능이기도 했고, 첫 주차 때부터 실제로 구현이 어려울 것이라고 생각했던 부분이었기 때문에 어떻게 해결했는지를 작성해두면 좋을 것 같아 포스팅하게 되었습니다.
먼저 구현 목표는 간단했지만 도전적이었습니다.
구현을 시작하며 아래와 같은 질문을 던졌습니다:
처음에는 캔버스에서 발생한 이벤트(마우스 클릭, 드래그 등)를 아래 지도에 전달하는 방식으로 설계했습니다.
이 문제를 해결하기 위해 캔버스를 대한민국 지도 크기만큼 고정하는 설계를 시도했습니다.
데이터 관리의 어려움:
대한민국 크기 전체에 해당하는 캔버스에 그림 데이터와 좌표를 모두 저장하려니, 데이터가 많아질수록 관리가 어려워졌습니다.
특히 대용량 데이터를 저장하거나 불러올 때 성능 문제가 발생할 가능성이 컸습니다.
캔버스가 너무크기에 이미지가 깨질 가능성 O:
캔버스에 표시되는 이미지나 요소들이 깨질 경우 사용자 경험을 떨어뜨릴 가능성이 있었습니다.
pointer-event
속성 끄기pointer-events - CSS: Cascading Style Sheets | MDN
pointer-event
라고 함은 CSS의 속성 중 하나로, 요소가 마우스 클릭, 터치, 커서 이동과 같은 포인터 이벤트를 받을 수 있는지 여부를 제어하는 속성입니다.
저희는 tailwindcss
를 사용하고 있었기에, 다음과 같이 pointer-event-none
클래스를 주는 것만으로도 이 옵션을 끌 수 있었습니다.
<div class='container relative w-[300px] h-[300px]'>
<div class='top-child absolute z-30 w-full h-full pointer-event-none' />
<div class='bottom-child absolute z-0 w-full h-full' />
</div>
이렇게 하면 top-child
의 모든 포인터 이벤트를 끌 수 있는 것인데,
결론적으로 이벤트를 가로채지 않고 클릭 등의 이벤트가 bottom-child
로 전달이 됩니다.
위 사진처럼 캔버스에서 pointer-event
속성을 꺼주니, 지도로 바로 event가 넘어가는 것을 확인할 수 있었고, 이렇게 지도에 먼저 이벤트를 주고 캔버스에 이벤트를 넘겨주는 방식도 고려해보았습니다.
하지만 이렇게 되면 이벤트를 지도로 넘겼다가, 지도에서 캔버스로 위로 보내주는 과정이 필요하기 때문에 이벤트 핸들링이 훨씬 복잡하고 성능적으로 좋지 않을 것이라고 판단하였고,
결국 아래와 같이 구현하였습니다.
고민 끝에, 캔버스를 고정하지 않고 이동 및 줌 이벤트가 발생할 때마다 캔버스를 다시 그려주는 방식으로 구현했습니다.
설계 단계 | 방법 | 장점 | 한계점 |
---|---|---|---|
1차 | 캔버스 이벤트 → 지도 이벤트 전달 | 간단한 구현, 기존 지도 로직 재사용 | 이벤트 처리 딜레이, UX 저하 |
2차 | 캔버스 고정 & 지도 이동/줌 제한 | 동기화 불필요, 구현 간단 | 데이터 관리 어려움, 유연성 부족 |
3차 | 이벤트 → 지도 이벤트 → 캔버스 이벤트 | 지도를 터치하는 동작과 동일, 지도 api에서 제공하는 이벤트 그대로 사용 가능 | 이벤트 핸들링에서 성능 저하, 로직 복잡 |
최종 | 이동마다 캔버스 재렌더링 | 데이터 관리 효율적, 성능 유연 | 약간의 렌더링 비용 발생 |
결국 위에서 결정한 대로 이동할 때마다 캔버스를 재렌더링하는 방식으로 생각하고, 구체적으로 어떻게 동작시킬지에 대해 고민해보았습니다.
지도를 렌더링하는 역할은 지도 API(예: 네이버 지도)가 맡고, 캔버스는 HTML <canvas>
엘리먼트로 구현했습니다. 지도와 캔버스는 동일한 DOM 트리 안에 있지만, 캔버스는 투명한 오버레이처럼 동작하도록 설정했습니다.
<div id="map-container">
<div id="map"></div> <!-- 지도 -->
<canvas id="canvas"></canvas> <!-- 캔버스 -->
</div>
#map
은 지도 API로 초기화.#canvas-overlay
는 absolute
로 지도 위를 덮어 그림을 그릴 수 있도록 구성.줌이나 이동 시, 지도와 캔버스의 위치 및 크기를 동기화하는 것이 핵심이었습니다. 이를 위해 다음을 고려했습니다:
줌 동기화
지도의 줌 레벨이 변경되면, 그림의 크기와 위치도 동일하게 변해야 합니다.
zoom_changed
이벤트를 활용해 현재 줌 레벨을 감지했습니다.이동 동기화
지도가 이동할 때 캔버스와 그림도 함께 이동해야 했습니다.
center_changed
이벤트를 사용해 지도 중심 좌표의 변화를 감지하고, 그에 따라 캔버스를 다시 렌더링했습니다.그림을 그릴 때 사용자는 픽셀 좌표로 작업하지만, 이 좌표를 지도 좌표(LatLng)로 변환하여 저장해야 이동과 줌에도 위치가 유지됩니다.
이를 위해 다음 단계를 설계했습니다:
canvasPointToLatLng()
메서드를 사용해 캔버스 픽셀 좌표를 지도 좌표로 변환했습니다.latLngToCanvasPoint()
메서드를 통해 저장된 좌표를 다시 캔버스의 픽셀 좌표로 변환하여 올바른 위치에 그림을 렌더링했습니다.지도 이동/줌 시, 캔버스를 다시 렌더링할 때 성능 문제가 발생할 가능성을 고려했습니다.
이를 해결하기 위해:
처음에는 스크롤과 드래그로 줌/이동을 구현하기 전에, 캔버스에 점을 찍으면 지도의 좌표 그대로 연동이 되는지 확인이 필요했습니다.
그래서 직접적인 줌/이동 대신 버튼을 이용한 테스트 방식을 설계했습니다.
redrawCanvas
를 호출합니다.줌 기능과 지도 이동 테스트를 위해 작성한 버튼 핸들러:
const handleZoomChange = (zoomChange: number) => {
if (!map) return;
const currentZoom = map.getZoom();
map.setZoom(currentZoom + zoomChange);
redrawCanvas(); // 캔버스를 다시 그리기
};
const handleMapPan = (direction: 'up' | 'down' | 'left' | 'right') => {
if (!map) return;
const moveAmount = 100; // 지도 이동 거리
let point: naver.maps.Point;
switch (direction) {
case 'up':
point = new naver.maps.Point(0, -moveAmount);
break;
case 'down':
point = new naver.maps.Point(0, moveAmount);
break;
case 'left':
point = new naver.maps.Point(-moveAmount, 0);
break;
case 'right':
point = new naver.maps.Point(moveAmount, 0);
break;
default:
return;
}
map.panBy(point); // 지도 이동
redrawCanvas(); // 캔버스 다시 그리기
};
<button onClick={() => handleZoomChange(1)} className="rounded bg-green-500 p-2">
Zoom In
</button>
<button onClick={() => handleZoomChange(-1)} className="rounded bg-red-500 p-2">
Zoom Out
</button><button onClick={() => handleMapPan('up')} className="rounded bg-blue-500 p-2">
Up
</button><button onClick={() => handleMapPan('down')} className="rounded bg-blue-500 p-2">
Down
</button><button onClick={() => handleMapPan('left')} className="rounded bg-blue-500 p-2">
Left
</button><button onClick={() => handleMapPan('right')} className="rounded bg-blue-500 p-2">
Right
</button>
gif 용량을 줄이느라 이미지 잔상이 남는 점..... 양해 바랍니다.......
(만약 이미지가 멈춰있다면, 이미지에서 오른쪽 마우스 클릭 후 새 탭에서 이미지 열기
로 들어가주세요... 벨로그가 gif를 지원하지 않는가봐요....)
테스트를 통해서 약간의 문제들을 발견했는데, 이는 금방 해결하기도 했고 큰 문제가 아니었기 때문에 언급만 해보겠습니다.
먼저 테스트를 통해 캔버스와 지도가 항상 동기화되는 것이 아니란 점을 확인했습니다.
캔버스에 찍은 점이 지도를 이동하거나 줌 아웃했을 때, 예상한 좌표에 머무르지 않고 엉뚱한 곳으로 이동했습니다.
디버깅을 해보니 지도 좌표와 캔버스 픽셀 좌표 간 변환 로직에 오류가 있었습니다.
그래서
latLngToCanvasPoint
함수를 수정하였고결론적으로 더이상 문제가 없음을 발견했고, 이후 버튼이 아닌 마우스 클릭과 터치 이벤트로 줌, 이동을 다룰 수 있도록 수정해보았습니다.
그 과정에서도 많은 어려움이 있었고 하나의 포스팅에서 모두 작성하기에는 너무 길어질 것 같아 다음 포스팅에서 작성해두었습니다. (가장 아래 ‘트러블 슈팅’ 참고)
아래는 제가 작성한 주요 코드입니다.
// 캔버스 초기화
const canvas = document.getElementById('canvas-overlay') as HTMLCanvasElement;
const context = canvas.getContext('2d');
// 지도 이벤트와 캔버스 동기화
map.addListener('zoom_changed', () => renderCanvas());
map.addListener('center_changed', () => renderCanvas());
// 지도 좌표 → 캔버스 픽셀 좌표
function latLngToCanvasPoint(latLng: google.maps.LatLng): { x: number; y: number } {
const projection = map.getProjection();
const point = projection.fromLatLngToPoint(latLng);
return { x: point.x * scale, y: point.y * scale };
}
// 캔버스 픽셀 좌표 → 지도 좌표
function canvasPointToLatLng(pixel: { x: number; y: number }): google.maps.LatLng {
const projection = map.getProjection();
return projection.fromPointToLatLng(new google.maps.Point(pixel.x / scale, pixel.y / scale));
}
// 캔버스에 그림 그리기
function renderCanvas() {
context.clearRect(0, 0, canvas.width, canvas.height);
// 그림 데이터를 순회하며 지도 좌표를 픽셀 좌표로 변환
drawings.forEach(drawing => {
const { x, y } = latLngToPixel(drawing.latLng);
context.beginPath();
context.arc(x, y, drawing.radius, 0, Math.PI * 2);
context.fillStyle = drawing.color;
context.fill();
});
}
결론적으로 이렇게 구현하여 지도와 캔버스를 연동시킬 수 있었고, 캔버스를 이동시키거나 줌인/줌아웃 했을 때에도 캔버스에 그려져 있던 그림들이 그 자리에 그대로 위치한 채 문제 없이 동작하도록 구현해주었습니다.
사실 이 문제 말고도 캔버스와 지도를 연동하고 서비스하는 과정에서 정말 많은 시행착오가 있었는데요..
모두 한번에 작성하기엔 어려움이 있어서, 나누어서 더 작성해두었습니다.
지도와 캔버스 연동 과정에서 생긴 내용만 해도 정말 많아서.....
지도-캔버스 연동하면서 발생한 문제들에 대한 더 많은 내용은 아래 링크에서 확인하시면 좋을 것 같습니다!!
- 📱 터치 이벤트로 캔버스에서 지도 이동과 확대/축소 구현하기: feat. 마우스 이벤트와의 차이
- 🗺️ 모바일에서 화면 확대/축소, 스크롤 금지시키기 (지도 화면 고정하기)