지도와 함께 움직이는 캔버스 설계/구현 시행착오 스토리

정혜인·2024년 11월 29일
1

저는 최근 지도 위에 캔버스에서 그림을 그릴 수 있는 기능을 구현하는 프로젝트를 진행했습니다.

이 과정에서 가장 큰 고민은 줌인/줌아웃과 이동(팬)을 했을 때, 지도와 캔버스가 함께 움직이며, 그린 그림이 좌표에 따라 올바르게 유지되도록 만드는 방법이었습니다.

고민 끝에 설계를 구체화하고, 이를 구현하였는데, 이번 포스팅에서는 처음 설계와 고민했던 한계, 설계 변경 과정, 그리고 최종적으로 구현한 방법을 중심으로 소개해보려 합니다.


사실, 이 지도-캔버스 연동 과정은 프로젝트에서 제가 맡았던 부분이 아니었는데 팀원 분께서 2주 동안 구현하지 못하셔서 머리를 싸매시는 것을 보고……

그렇게 어려운지 저도 승부욕이 생겨서 주말에 잠깐 시도해보았다가 해결해버린 태스크였는데요……..

저희 프로젝트의 핵심 기능이기도 했고, 첫 주차 때부터 실제로 구현이 어려울 것이라고 생각했던 부분이었기 때문에 어떻게 해결했는지를 작성해두면 좋을 것 같아 포스팅하게 되었습니다.


🚩 구현 목표

먼저 구현 목표는 간단했지만 도전적이었습니다.

  1. 사용자는 지도 위에 캔버스를 통해 그림을 그릴 수 있다.
  2. 지도를 줌인/줌아웃하거나 이동했을 때, 캔버스와 지도는 동기화되어 움직인다.
  3. 그려진 그림은 지도 좌표와 연관되어야 하며, 지도 이동 후에도 같은 위치에 있어야 한다.


🤔 고민했던 부분

구현을 시작하며 아래와 같은 질문을 던졌습니다:

  1. 지도와 캔버스는 서로 다른 두 레이어로 구성될 텐데, 이 두 레이어를 어떻게 동기화할 것인가?
  2. 지도를 줌인/줌아웃할 때, 그려진 그림의 크기나 위치는 어떻게 변해야 할까?
  3. 캔버스 위에 그려진 좌표를 지도 좌표계로 변환하려면 어떤 수학적 처리가 필요할까?

🤔 처음 설계했던 방법

1️⃣ 지도와 캔버스 이벤트 연계

처음에는 캔버스에서 발생한 이벤트(마우스 클릭, 드래그 등)를 아래 지도에 전달하는 방식으로 설계했습니다.

  • 사용자가 캔버스를 클릭하거나 드래그하면, 해당 이벤트를 지도 API에 넘겨 지도에서 줌이나 이동이 가능하도록 했습니다.
  • 이렇게 하면 별도의 복잡한 동기화 로직 없이, 지도 API가 이벤트를 처리하므로 구현이 간단하다고 판단했습니다.

문제가 된 부분

  • 지연 발생: 캔버스에서 이벤트를 지도에 전달하는 과정에서 처리 시간이 길어졌고, 지도가 움직이는 데 약간의 딜레이가 발생했습니다.
  • 사용자 경험 저하: 사용자가 즉각적인 반응을 기대하는 인터페이스에서 딜레이는 치명적이었습니다.

🔄 두 번째 설계: 캔버스와 지도 크기 고정 방식

이 문제를 해결하기 위해 캔버스를 대한민국 지도 크기만큼 고정하는 설계를 시도했습니다.

2️⃣ 대한민국 고정 캔버스 설계

  • 대한민국 지도의 전체 크기를 기준으로 캔버스를 설정하고, 지도의 줌 및 이동을 제한했습니다.
  • 사용자가 지도에서 줌/이동을 하면, 캔버스를 같은 비율로 확대/축소하거나 이동시켜 동기화된 상태를 유지했습니다.
  • 사용자에게 보이는 화면은 확대된 부분만이고, 실제 캔버스는 그 밑에 대한민국의 크기만큼을 저장하고 있는 형태였습니다.

장점

  • 간단한 구현: 대한민국 크기로 고정된 캔버스 위에서만 작업하면 되므로 복잡한 좌표 변환 로직을 줄일 수 있다는 장점이 있었습니다.
  • 동기화 불필요: 캔버스 자체가 지도와 항상 동일한 상태를 유지하므로, 별도의 동기화 로직 없이 구현이 가능할 것이라 판단하였습니다.

문제가 된 부분

  1. 데이터 관리의 어려움:

    대한민국 크기 전체에 해당하는 캔버스에 그림 데이터와 좌표를 모두 저장하려니, 데이터가 많아질수록 관리가 어려워졌습니다.

    특히 대용량 데이터를 저장하거나 불러올 때 성능 문제가 발생할 가능성이 컸습니다.

  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가 넘어가는 것을 확인할 수 있었고, 이렇게 지도에 먼저 이벤트를 주고 캔버스에 이벤트를 넘겨주는 방식도 고려해보았습니다.

하지만 이렇게 되면 이벤트를 지도로 넘겼다가, 지도에서 캔버스로 위로 보내주는 과정이 필요하기 때문에 이벤트 핸들링이 훨씬 복잡하고 성능적으로 좋지 않을 것이라고 판단하였고,

결국 아래와 같이 구현하였습니다.

최종 설계: 이동마다 캔버스 재렌더링

3️⃣ 최종적인 방법

고민 끝에, 캔버스를 고정하지 않고 이동 및 줌 이벤트가 발생할 때마다 캔버스를 다시 그려주는 방식으로 구현했습니다.

핵심 설계 아이디어

  • 지도 좌표계와 픽셀 좌표계를 변환하여, 그림의 데이터는 지도 좌표계로 저장합니다.
  • 지도 이동/줌 이벤트가 발생하면 캔버스를 클리어한 뒤, 지도 좌표를 픽셀 좌표로 변환하여 그림을 다시 렌더링합니다.
  • 이 방식은 데이터가 많아질 경우에도 유연하게 대응할 수 있습니다.

📖 설계 변화 요약

설계 단계방법장점한계점
1차캔버스 이벤트 → 지도 이벤트 전달간단한 구현, 기존 지도 로직 재사용이벤트 처리 딜레이, UX 저하
2차캔버스 고정 & 지도 이동/줌 제한동기화 불필요, 구현 간단데이터 관리 어려움, 유연성 부족
3차이벤트 → 지도 이벤트 → 캔버스 이벤트지도를 터치하는 동작과 동일, 지도 api에서 제공하는 이벤트 그대로 사용 가능이벤트 핸들링에서 성능 저하, 로직 복잡
최종이동마다 캔버스 재렌더링데이터 관리 효율적, 성능 유연약간의 렌더링 비용 발생

🛠️ 구체적 설계 과정

결국 위에서 결정한 대로 이동할 때마다 캔버스를 재렌더링하는 방식으로 생각하고, 구체적으로 어떻게 동작시킬지에 대해 고민해보았습니다.

1️⃣ 지도와 캔버스의 구조 정의

지도를 렌더링하는 역할은 지도 API(예: 네이버 지도)가 맡고, 캔버스는 HTML <canvas> 엘리먼트로 구현했습니다. 지도와 캔버스는 동일한 DOM 트리 안에 있지만, 캔버스는 투명한 오버레이처럼 동작하도록 설정했습니다.

  • 구조 설계
    <div id="map-container">
      <div id="map"></div> <!-- 지도 -->
      <canvas id="canvas"></canvas> <!-- 캔버스 -->
    </div>
    • #map은 지도 API로 초기화.
    • #canvas-overlayabsolute로 지도 위를 덮어 그림을 그릴 수 있도록 구성.


2️⃣ 캔버스와 지도의 동기화

줌이나 이동 시, 지도와 캔버스의 위치 및 크기를 동기화하는 것이 핵심이었습니다. 이를 위해 다음을 고려했습니다:

  1. 줌 동기화

    지도의 줌 레벨이 변경되면, 그림의 크기와 위치도 동일하게 변해야 합니다.

    • 지도에서 제공하는 zoom_changed 이벤트를 활용해 현재 줌 레벨을 감지했습니다.
    • 캔버스의 크기를 지도 줌 레벨에 따라 비례적으로 조정했습니다.
  2. 이동 동기화

    지도가 이동할 때 캔버스와 그림도 함께 이동해야 했습니다.

    • center_changed 이벤트를 사용해 지도 중심 좌표의 변화를 감지하고, 그에 따라 캔버스를 다시 렌더링했습니다.

3️⃣ 그림 좌표의 변환

그림을 그릴 때 사용자는 픽셀 좌표로 작업하지만, 이 좌표를 지도 좌표(LatLng)로 변환하여 저장해야 이동과 줌에도 위치가 유지됩니다.

이를 위해 다음 단계를 설계했습니다:

  • 픽셀 좌표 → 지도 좌표 변환 지도 API의 canvasPointToLatLng() 메서드를 사용해 캔버스 픽셀 좌표를 지도 좌표로 변환했습니다.
  • 지도 좌표 → 픽셀 좌표 변환 latLngToCanvasPoint() 메서드를 통해 저장된 좌표를 다시 캔버스의 픽셀 좌표로 변환하여 올바른 위치에 그림을 렌더링했습니다.

4️⃣ 결과물 렌더링 방식 설계

지도 이동/줌 시, 캔버스를 다시 렌더링할 때 성능 문제가 발생할 가능성을 고려했습니다.

이를 해결하기 위해:

  • 그림 데이터를 지도 좌표계로 저장한 후, redraw() 함수 훅을 구현하여 활용해 효율적으로 캔버스를 다시 그리도록 했습니다.

🛠️ 구현 과정

🛠️ 구현 초기 테스트: 버튼으로 줌과 이동 테스트

1. 버튼으로 테스트

처음에는 스크롤과 드래그로 줌/이동을 구현하기 전에, 캔버스에 점을 찍으면 지도의 좌표 그대로 연동이 되는지 확인이 필요했습니다.

그래서 직접적인 줌/이동 대신 버튼을 이용한 테스트 방식을 설계했습니다.

2. 테스트 과정

  1. 버튼을 클릭하면 지도에서 줌 인/줌 아웃 또는 이동을 수행합니다.
  2. 캔버스의 그림도 함께 이동하거나 축소/확대되도록 redrawCanvas를 호출합니다.
  3. 캔버스에 찍힌 점들이 지도 좌표와 정확히 맞는지 확인했습니다.

3. 테스트 코드 예시

줌 기능과 지도 이동 테스트를 위해 작성한 버튼 핸들러:

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(); // 캔버스 다시 그리기
};

테스트 버튼 UI

<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 용량을 줄이느라 이미지 잔상이 남는 점..... 양해 바랍니다.......
(만약 이미지가 멈춰있다면, 이미지에서 오른쪽 마우스 클릭 후 새 탭에서 이미지 열기로 들어가주세요... 벨로그가 gif를 지원하지 않는가봐요....)


🤔 테스트로…….

테스트를 통해서 약간의 문제들을 발견했는데, 이는 금방 해결하기도 했고 큰 문제가 아니었기 때문에 언급만 해보겠습니다.

1️⃣ 문제 발견

먼저 테스트를 통해 캔버스와 지도가 항상 동기화되는 것이 아니란 점을 확인했습니다.

캔버스에 찍은 점이 지도를 이동하거나 줌 아웃했을 때, 예상한 좌표에 머무르지 않고 엉뚱한 곳으로 이동했습니다.

디버깅을 해보니 지도 좌표와 캔버스 픽셀 좌표 간 변환 로직에 오류가 있었습니다.

2️⃣ 해결 방향을 설정

그래서

  • 지도 좌표를 캔버스 픽셀 좌표로 변환하는 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. 마우스 이벤트와의 차이

- 🗺️ 모바일에서 화면 확대/축소, 스크롤 금지시키기 (지도 화면 고정하기)

- 🖌 캔버스 위에서 캐릭터를 회전시키고 네온 효과 입히기

- 📍 ios에서 사용자의 실시간 방향 가져오기

0개의 댓글