[기능 구현] 마커 클러스터링 구현하기

seeen·2024년 1월 17일
4
post-thumbnail

23년 11월 둘째 주에 있었던 일을 정리한 글이다.

마커 클러스터링 도입 배경

서비스 초기에는 데이터가 그리 많지도 않았고 모바일 환경을 지원하지 않았으므로, 마커끼리 겹쳐서 특정 마커가 가려지는 현상은 알고 있었지만 서비스 이용에 크게 영향을 줄 정도는 아니였다. 하지만 모바일 환경을 지원하고 '대동붕어빵여지도'와 같은 데이터가 많은 지도가 쌓이면서 그 필요성이 분명해졌다.

마커 클러스터링을 하지 않고 '대동붕어빵여지도'를 보면 다음과 같다.

수도권 부분에 전체 마커의 대략 절반에 해당하는 마커가 있는데, 마커의 상당수가 가려져 어떤 마커가 어떤 위치에 있는지 확인이 되지 않는다. 추가로 마커 때문에 지도까지 가려져 지도를 보는 것 또한 어려웠다. PC 환경은 그나마 나은편이다. 모바일 환경은 지도가 아예 보이지가 않는다.. 따라서 우리 팀은 마커 클러스터링을 도입하기로 결정했다.

마커 클러스터링 구현

이 기능은 백엔드 팀원 @매튜와 함께 진행했다.

괜찮을지도의 지도는 T Map인데, 아쉽게도 T Map에서 '벡터' 방식의 지도는 마커 클러스터링을 지원하지 않는다. 따라서 마커 클러스터링을 직접 구현해야했는데, 여러 가지 방법을 고민했었다.

구현 방법 정하기

지도에 가상의 구역을 나눠 줌 레벨에 따라 구역 별 클러스터링 방법과 마커와 마커의 중첩 여부를 판단하여 클러스터링을 하는 방법을 최종 후보로 두고, 우리는 후자를 선택하였다.

이 두 가지 방법의 차이점을 느낄 수 있는 예시를 들자면, 전자는 카카오맵 마커 클러스터링과 후자는 네이버 지도 (송파구 맛집 검색)으로 볼 수 있겠다. 우선 마커와 마커간 중첩 여부를 판단하는 방법으로 클러스터링을 진행한 이유는 다음과 같다.

  • 지도마다 마커 분포가 제각각이라 구역별 마커 클러스터링 시 부자연스러워 보일 수 있음
  • 규모가 작은 지도의 경우 구역별 마커 클러스터링 시 특정 줌 레벨까지 확대하지 않으면 하나의 마커로 보여지기에 사용자가 줌 레벨을 조작하는 상황이 잦을 것임
  • 사용자에게 보다 섬세한 마커 클러스터링 경험을 위하여

요약하면 서비스 특성에 맞는 보다 나은 사용자 경험을 위해서이다. 😇

구현 과정 - 마커의 지름이 차지하는 실제 거리 도출

백엔드와 프론트엔드의 책임을 다음과 같이 분리했다.

  • 프론트엔드: 사용자의 줌 레벨에 따라 마커의 지름이 차지하는 실제 거리를 구하여 서버로 넘겨준다.
  • 백엔드: DB에 있는 각 마커의 좌표 정보와 마커 지름에 해당하는 거리 값을 조합하여 유니온 파인드 연산을 수행하고, 그 결과를 반환한다.

마커의 크기는 고정적이지만, 사용자의 줌 레벨에 따라 마커의 지름이 차지하는 지도 상의 실제 거리는 가변적이다. 따라서 이 값으로 마커와 마커간 서로 포함되는 즉, 마커의 겹침 여부를 판단할 수 있고 이를 유니온 파인드 연산을 수행하여 합쳐진 하나의 마커로 표시하는 것이다.

마커의 실제 거리를 구하는 방법을 요약하면 다음과 같다.

  1. 지도의 좌우 끝 경계 좌표값을 가져온다.
  2. 사용자의 스크린 상에서 지도 영역의 좌우 끝의 스크린 좌표값을 가져온다.
  3. 1번에서 구한 좌표값으로 지도의 가로 사이즈에 해당하는 실제 거리를 구한다.
  4. 2번에서 구한 스크린 좌표값으로 지도의 가로 사이즈를 구한다.
  5. 지도 가로 사이즈에 해당하는 실제 거리 / 지도 가로 사이즈 = 1px당 실제 거리
  6. 5번에서 구한 값 * 마커 지름 = 마커의 지름이 차지하는 실제 거리

코드로 보면 다음과 같다. 아래 코드에서 사용된 T Map API 함수의 역할이 궁금하다면 여기서 확인해볼 수 있다.

const useGetRealDistanceOfPin = () => {
  const { Tmapv3 } = window;

  const getRealDistanceOfPin = (mapInstance: TMap) => {
    // 지도 경계 사분면 가져오기
    const mapBounds = mapInstance.getBounds();

    // 1번과 2번 과정
    // 지도의 좌측 끝 경계 좌표값
    const leftBound = new Tmapv3.LatLng(mapBounds._ne._lat, mapBounds._sw._lng);
    // 지도의 우측 끝 경계 좌표값
    const rightBound = new Tmapv3.LatLng(
      mapBounds._ne._lat,
      mapBounds._ne._lng,
    );

    // 3번 과정
    const realDistanceOfScreen = leftBound.distanceTo(rightBound);
    // 4번 과정
    const currentScreenSize =
      mapInstance.realToScreen(leftBound).x -
      mapInstance.realToScreen(rightBound).x;

    // 5번, 6번 과정
    return (realDistanceOfScreen / currentScreenSize) * PIN_SIZE;
  };

  return { getDistanceOfPin };
};

export default useRealDistanceOfPin;

그림을 통해 한 번 자세히 알아보자. 보통 getBounds() 라고 하면 지도의 경계 사분면 좌표값을 반환하는데, T Map은 다음과 같이 우상단 끝점 좌표와 좌하단 끝점 좌표를 구할 수 있다.

사용의 편리를 위해서 좌측 끝, 우측 끝 좌표값으로 변환하자. (위 코드에서 1, 2번에 해당하는 작업이나 T Map API의 LatLng 객체로 변환하는 과정도 포함되어 있습니다.) 그러면 다음과 같이 두 가지의 값을 구할 수 있다.

이 과정이 위 코드에서 3, 4번 과정에 해당하는 것이다. 지도의 가로 사이즈는 사용자의 모니터에 따라 달라진다. 지도 가로 사이즈에 해당하는 실제 거리 또한 지도의 가로 사이즈에 따라 달라지므로 그 비율은 동일하다. 따라서 사용자의 모니터에 따라 마커 클러스터링이 다르게 동작할 염려는 하지 않아도 된다. 😇

이렇게 구한 실제 거리 값과 가로 사이즈 값을 나누면 1px 당 실제 거리를 구할 수 있다. 이 값에 마커의 사이즈 (괜찮을지도는 60px이다.) 를 곱해주면 마커의 지름이 차지하는 실제 거리를 구할 수 있다. 이 과정이 위 코드에서 5, 6번 과정에 해당한다.

구현 과정 - 줌 이벤트가 발생할 때마다 마커 클러스터링 갱신

사용자가 지도를 조작할 때 발생하는 이벤트를 크게 드래그, 줌 이렇게 두 개로 나눌 수 있다. 드래그 이벤트가 발생할 때는 마커 지름에 해당하는 실제 거리 값이 변하지 않고, 줌 이벤트 때만 변한다.

따라서 위에서 구한 값을 사용자가 지도를 줌인, 줌아웃 할 때마다 서버에 전송해줘야 한다. 코드로 보면 다음과 같다.

useEffect(() => {
  setClusteredCoordinates();

  const onZoomEnd = (evt: Evt) => {
    if (zoomTimerIdRef.current) {
      clearTimeout(zoomTimerIdRef.current);
    }

    zoomTimerIdRef.current = setTimeout(() => {
      setClusteredCoordinates();
      adjustMapDirection();
    }, 100);
  };

  if (!mapInstance) return;
  mapInstance.on('ZoomEnd', onZoomEnd);

  return () => {
    mapInstance.off('ZoomEnd', onZoomEnd);
  };
}, [topicDetail]);

T Map에서 제공하는 이벤트 중 ZoomEnd 이벤트를 활용하였다. 이는 줌이 끝날 때만 발생한다. 줌이 끝날 때만 발생함에도 디바운싱을 걸어준 것을 볼 수 있는데, 간혹 ZoomEnd 이벤트가 따닥! 하는 것과 같이 짧은 찰나에 여러 번 발생하는 현상이 있었다.

마커 클러스터링이 비용이 꽤 나가는 연산이다보니, 불필요한 자원 낭비를 방지하고자 위와 같이 디바운싱까지 걸어주었다.

구현 결과

이전과 달리 마커끼리 겹치는 현상이 거의 없고 마커 사이로 지도도 잘 보여 위치 확인이 수월해졌다. 😇 하지만! '마커 렌더링 최적화'란 한 가지 거대한 문제점이 더 남아있었으니.. 이는 다음 편에서 작성해보도록 하겠다.

profile
woowacourse FE 5th, depromeet Web 15th

2개의 댓글

comment-user-thumbnail
2024년 1월 24일

당신은 최고야..

1개의 답글