
기존 서비스에서 지도에 표시된 기계 간 거리를 알 수 없었고,
현장 인력이 직접 거리를 가늠해야 했던 불편함이 있었다.
이를 해결하기 위해
지도 위에서 바로 거리를 확인할 수 있으면 좋겠다
는 요구를 받고 거리 재기 기능을 구현하는 작업을 진행했다.
사실 구글 지도에는 이미 거리 측정 기능이 포함되어 있다.
하지만 현재 프로젝트에서 사용하고 있는 지도는 라이트 모드로 사용중이었기 때문에 해당 기능이 포함되어 있지 않았다.
+ 기존 기능에서는 실시간 미리보기 기능이 없었기에 해당 기능을 추가하여 직접 구현하기로 결정했다.
거리 재기 기능은 마우스 이동에 따라 지속적으로 화면을 다시 그려야 하는 특성이 있다.
이런 상황에서 Canvas는 구글 지도의 Polyline이나 DOM 기반 방식보다
- 재렌더링 비용이 낮고
- DOM 노드 수를 최소화할 수 있으며
- 점선·말풍선·안내 문구와 같은 복합적인 UI를 자유롭게 표현할 수 있다
는 장점이 있었다.
그 결과 성능과 UX를 모두 만족시키는 구현이 가능했다.
기능 구현을 위한 첫 번째 과제는 다음과 같았다.
마우스로 찍은 점은 화면 좌표(px)인데,
거리는 위경도(lat/lng) 기반으로 계산해야 한다.
그리고 결과 UI(점/선/말풍선)는 ‘Canvas(px)’에 그려야 한다.
그래서 입력(마우스) → 계산(lat/lng) → 출력(Canvas) 이 3단계를 연결하려면
좌표 변환이 필수였다.
마우스 이벤트로 얻는 좌표는 보통 event.clientX, event.clientY로
이 값은 마우스 이벤트가 발생한 당시 브라우저 viewport 기준 마우스 위치 픽셀 좌표이다.
하지만 이 좌표만으로는 구현이 불가능하다.
이유는 아래와 같다.
clientX=500 이라도 지도 이동/줌 상태에 따라 가리키는 실제 지리 좌표가 매번 달라진다.때문에
1. 사용자가 클릭한 화면 위치가
2. 지도 상의 어떤 위경도(lat/lng)인지 알아내고
3. 그 위경도끼리 거리 계산을 하고
4. 다시 그 위경도를 Canvas 픽셀로 바꿔서 그림
이라는 과정이 필요하다.
Google Maps는 지도 내부에서 좌표를 다음처럼 다룬다.
우리는 여기서 현실 좌표와 월드 좌표를 자유롭게 변환할 수 있어야 한다.
google.maps.Projection 클래스에서 관련된 메서드 2개를 찾을 수 있다.
fromLatLngToPoint(latLng) -> Point 반환: fromPointToLatLng(point) -> LatLng 반환: 즉 이 둘은 LatLng ↔ World Point 변환기라고 보면 된다.
현재 지도의 줌 스케일 + 지도 기준점(nw)를 이용해 직접 계산해야한다.
그 과정은 아래와 같다.
단순한 clientX/clientY 로는 좌표가 일치하지 않는 문제가 발생했다.
왜일까 조금 생각해보니 지도 컨테이너가 viewport보다 더 작았다.
마우스 이벤트의 clientX/clientY는 화면 전체 기준이기 때문에
지도 컨테이너 기준으로 다시 맞춰야 한다.
// 엘리먼트의 크기와 뷰포트에 상대적인 위치 정보를 제공하는 DOMRect 객체를 반환
const rect = container.getBoundingClientRect();
const xPx = event.clientX - rect.left;
const yPx = event.clientY - rect.top;
지도를 담고 있는 컨테이너의 뷰포트를 기준으로 마우스 좌표를 찾기 위한 계산 과정이다.
(지도 왼쪽 끝에서부터 몇 px 떨어져 있는지 정확하게 알기 위함.)
참고: getBoundingClientRect
여기까지가 지도 DOM 위에서의 픽셀 좌표 구하기 이다.
여기서 Projection이 또 등장한다.
마우스 좌표는 결국 브라우저에서 현재 화면에 보이는 지도 영역과는 다른 상대 좌표이기 때문에
지도 기준점(좌측 상단 = North-West | 0,0)을 정한 다음
현재 마우스 픽셀 좌표를 지도 줌 레벨에 따른 scale로 나누고 지도 기준점의 픽셀 좌표를 더해서
월드 좌표계로 변환하는 과정이 필요하다.
정리하자면 아래와 같다.
- 마우스 픽셀 좌표를 구한다. --- (1)에서 진행한 작업
- 해당 마우스 픽셀 좌표를 지도 줌 레벨에 따른 scale로 나눠서 원점으로부터 이동한 세계 좌표 거리(offset)를 구한다 --- 참고: 픽셀 좌표
- 2번 계산 후 지도 화면의 좌상단(현재 보이는 bounds의 북서쪽(NW) 지점에 해당됨)의 세계 좌표값을 더해준다.(원점을 지정하는 연산)
- 마지막으로 클릭 지점을 기록하기 위해 해당 월드 좌표계를 다시 위경도 좌표로 변환한다. (거리 계산은 세계 좌표로 하면 안 되기 때문 => 실제 지구 곡률과 무관)
코드 예시:
/**
* 마우스 좌표를 지도상의 위도경도로 변환한다.
* @param event 마우스 이벤트
*/
private pointerEventToLatLng(event: MouseEvent): google.maps.LatLngLiteral | null {
const map = this.map;
const container = this.mapContainer;
if (!map || !container) return null;
const projection = map.getProjection();
const bounds = map.getBounds();
if (!projection || !bounds) return null;
const rect = container.getBoundingClientRect();
const zoom = map.getZoom() ?? 0;
const scale = Math.pow(2, zoom);
const nw = projection.fromLatLngToPoint(new google.maps.LatLng(bounds.getNorthEast().lat(), bounds.getSouthWest().lng()));
if (!nw) return null;
const point = new google.maps.Point(
(event.clientX - rect.left) / scale + nw.x,
(event.clientY - rect.top) / scale + nw.y,
);
const latLng = projection.fromPointToLatLng(point);
if (!latLng) return null;
return { lat: latLng.lat(), lng: latLng.lng() };
}
거리 선/점/말풍선은 Canvas에 그리기 때문에
저장해둔 위경도를 다시 Canvas 픽셀로 바꿔야 한다.
이건 반대로 작업한다
LatLng -> World Point(
fromLatLngToPoint)World Point -> Pixel (scale + nw 기준)
const worldPoint = projection.fromLatLngToPoint(new google.maps.LatLng(latLng.lat, latLng.lng)); return { x: (worldPoint.x - nw.x) * scale, y: (worldPoint.y - nw.y) * scale, };이렇게 하면 지도 위 특정 위치(lat/lng)를 Canvas 위의 정확한 위치(px)로 찍을 수 있다.
지도를 드래그하거나 줌을 하면 다음 값들이 변한다
- bounds (현재 보이는 영역)
- zoom (스케일)
- nw 기준점(화면 원점에 해당하는 월드 좌표)
그래서 한 번 계산해둔 좌표를 계속 쓰면 안 되고
지도 상태가 바뀔 때마다 다시 계산해서 그려야 한다.
해당 로직을 구글 지도의 idle(이동이나 확대/축소 후 지도가 유휴 상태가 되면 시작됨) 이벤트로 해결했다.
this.idleListener = this.map.addListener('idle', () => {
this.resizeCanvas();
this.drawOverlay();
this.updateClearButtonPosition();
});
추가로 고민이 많았던 부분은
“아직 클릭하지 않은 상태의 거리 정보를 어떻게 보여줄 것인가”였다.
단순히 점을 찍고 선을 연결하는 것까지는 비교적 명확했지만
사용자 경험 측면에서는 다음 요구사항을 만족해야 했다.
사용자는 다음과 같은 흐름을 자연스럽게 기대한다.
- 첫 번째 점을 찍은 이후
- 마우스를 움직이면
- 마지막 점 → 마우스 위치까지의 거리가 실시간으로 표시되고
- 해당 거리를 담은 말풍선이 마우스를 따라다닌다
- 아직 클릭하지 않았기 때문에
- 이 정보는 “확정값”이 아닌 미리보기 상태
즉 화면에는 항상 두 가지 상태가 공존해야 했다.
이 둘을 구분해서 다루지 않으면
구현도, UX도 깔끔하게 풀리지 않는다는 걸 금방 느꼈다.
그래서 상태를 아래와 같이 나눴다.
points: 클릭으로 확정된 거리 포인트들preview: 마우스가 가리키는 임시 좌표 (LatLng)이렇게 역할을 분리하자
구현 흐름이 명확해졌다
거리 재기 모드가 활성화된 상태에서
마우스를 움직일 때마다 다음 작업을 수행한다.
mousemove 이벤트 감지LatLng로 변환preview 좌표로 저장handleMouseMove(event: MouseEvent) {
if (!this.state.active || this.state.points.length === 0) return;
const latLng = this.pointerEventToLatLng(event);
if (!latLng) return;
this.distanceSvc.setPreview(latLng);
}
preview 좌표가 갱신되면
해당 상태 스트림을 구독 중인 컴포넌트에서
drawOverlay()가 다시 호출된다.
이 함수에서는 항상 같은 순서로 화면을 그린다.
- 캔버스를 깨끗이 지운다
- 확정된 점들을 실선으로 연결한다
- preview가 존재하면
- 마지막 점 → preview 위치까지 점선으로 연결
- preview 위치에 거리 말풍선을 표시한다


거리 재기 기능을 구현하면서 단순히 “기능 하나를 추가했다”기보다
지도 기반 인터랙션 UI를 설계할 때 어떤 기준을 가져야 하는지를 많이 고민하게 됐다.
또 Canvas는 단순한 그래픽 도구가 아니라
잦은 재렌더링이 필요한 지도 인터랙션 UI에서
좋은 선택지가 될 수 있다는 점을 느꼈다.
반면 Canvas를 선택하면서 감수해야 했던 부분도 분명했다.
- 상태 기반 UI가 아니다
- DOM처럼 요소 단위로 관리되지 않기 때문에
- 수정에도 모든 것을 다시 그려야 한다
- 디버깅 난이도
- 눈에 보이는 UI가 코드 구조와 1:1로 매칭되지 않음
- 접근성 한계
- 스크린 리더, 키보드 포커스(Tab 등) 대응이 어려움
그래서 Canvas는
모든 UI에 적합한 만능 도구가 아니라 렌더링 빈도가 높은 인터랙션 UI에 적합한 도구라는 점을 다시 한 번 느꼈다.
이 작업을 통해 자연스럽게 다음과 같은 고민들을 하게 됐다.
기능 자체보다도
왜 이 방식이었는지 설명할 수 있는 구현을 목표로 했던 점이
이번 작업에서 가장 의미 있었던 부분이라고 생각한다.
지도 위에서 동작하는 UI는
일반적인 화면 UI와는 다른 관점이 필요하다는 것도 다시 한 번 느꼈다.
좌표 변환은 단순 계산이 아니라 기준점과 좌표계에 대한 이해가 필요했다.
구현하는 과정을 통해 좌표를 다루는 기술에 대해 더 깊게 학습했다.
이번 구현을 통해
작은 기능 하나라도
사용자 맥락을 이해하고 기술 선택의 이유를 분명히 하며 구조적으로 개선하려는 시도를 한다면
단순한 기능 추가를 넘어
팀과 서비스 전체에 도움이 되는 코드로 남길 수 있다는 걸 배웠다.
앞으로도 지도와 같이 복잡한 도메인을 다룰 때
“일단 된다”에서 멈추지 않고
왜 이렇게 설계했는지 설명할 수 있는 구현을 계속해 나가고 싶다.