ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 카카오 지도에 여러 개의 마커 표시하고 최적화하기 (Next.js, Grid-Based 알고리즘)
    기타 2024. 8. 19. 22:43

    카카오 지도 API를 활용한 프로젝트를 진행하던 중,

    백엔드 서버에서 받아온 여러 위치 데이터를 바탕으로

    지도에 마커를 표시하는 기능을 구현해야 할 일이 있었습니다.

     

    이 과정에서 다수의 마커를 효율적으로 관리하고,

    지도를 최적화하는 방법에 대해 고민하게 되었습니다.

     

    이번 글에서 어떠한 과정으로 고민하고 문제를 해결했는지 작성해보려 합니다.

     

    -> 참고: 프로젝트는 Next.js, tailwind css, typescript를 사용하였습니다.

    | 지도 생성

    우선 카카오 지도 API를 사용하기 위해

    다음과 같이 next.js의 Script 태그를 사용해 스크립트를 포함시켜 줍니다.

    const KakaoMap = () => {
      return (
        <>
          <Script
            src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_APP_KEY}&libraries=clusterer,services&autoload=false`}
          />
          <div id="map" className="relative w-full h-dvh" />
        </>
      );
    };
    
    export default KakaoMap;

     

    그다음 스크립트가 로드된 후 카카오 지도를 생성할 수 있도록

    Script태그의 onLoad 속성에 다음과 같이 카카오 지도 생성 함수를 넣어 줍니다.

    const KakaoMap = () => {
      const handleLoadMap = () => {
        window.kakao.maps.load(() => {
          const mapContainer = document.getElementById("map");
          const mapOption = {
            center: new window.kakao.maps.LatLng(37.566535, 126.9779692), // 처음 위치
            level: 3, // 처음 레벨
          };
    
          const map = new window.kakao.maps.Map(mapContainer, mapOption);
          setMap(map); // 지도 상태 저장
        });
      };
    
      return (
        <>
          <Script
            src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_APP_KEY}&libraries=clusterer,services&autoload=false`}
            onLoad={handleLoadMap}
          />
          <div id="map" className="relative w-full h-dvh" />
        </>
      );
    };
    
    export default KakaoMap;

     

    이제 서버에서 데이터를 가져와 준 후

    지도에 마커를 생성해 줍니다.

    // 데이터 저장
    useEffect(() => {
      const fetch = async () => {
        const data = await getAllMarker();
    
        setMarkers(data); // 데이터 상태 저장
      };
    
      fetch();
    }, []);
    useEffect(() => {
      if (!map || !markers) return;
    
      const imageSize = new window.kakao.maps.Size(44, 49);
      const imageOption = { offset: new window.kakao.maps.Point(21, 39) };
    
      const imageUrl = "/pin-active.svg";
    
      const pin = new window.kakao.maps.MarkerImage(
        imageUrl,
        imageSize,
        imageOption
      );
    
      markers.forEach((marker) => {
        const position = new window.kakao.maps.LatLng(marker.latitude, marker.longitude);
        new window.kakao.maps.Marker({
          map: map,
          position: position,
          image: pin,
        });
      });
    }, [map, markers]);

     

    이런 식으로 하면 위치 데이터를 가지고 지도에 마커들을 표시할 수 있습니다.

    | 문제

    하지만 프로젝트에서 가져오는 위치 데이터가 5000개를 넘다 보니, 

    지도를 이동하거나 확대/축소할 때 과도한 연산이 발생했습니다.

    그 결과 아래와 같이 CPU 점유율이 수시로 100%까지 치솟고,

    화면이 멈추는 현상이 빈번하게 발생했습니다.

     

     

    이어 따라 화면에 표시되는 마커의 수를 줄이고, 그룹별로 묶어 표시하기 위해

    다음과 같이 카카오 지도의 클러스터러(clusterer)를 적용했습니다.

    useEffect(() => {
      if (!map || !markers) return;
    
      const imageSize = new window.kakao.maps.Size(44, 49);
      const imageOption = { offset: new window.kakao.maps.Point(21, 39) };
    
      const imageUrl = "/pin-active.svg";
    
      const pin = new window.kakao.maps.MarkerImage(
        imageUrl,
        imageSize,
        imageOption
      );
    
      const newMarkers = markers.map((marker) => {
        const position = new window.kakao.maps.LatLng(marker.latitude, marker.longitude);
        const marker = new window.kakao.maps.Marker({
          map: map,
          position: position,
          image: pin,
        });
    
        return marker;
      });
    
      const clusterer = new window.kakao.maps.MarkerClusterer({
        map: map, // 클러스터러 적용할 지도
        gridSize: 240, // 클러스터 포함 범위
      });
    
      clusterer.addMarkers(newMarkers);
    }, [map, markers]);

     

    그 결과, 지도의 이동은 확실히 부드러워졌지만,

    여전히 화면을 확대하거나 축소할 때 CPU 점유율이 100%까지 치솟으며 잠깐의 멈춤이 발생했습니다

    | 해결

    이에 저는 지도의 중심을 기준으로 범위를 설정하고,

    해당 범위 내의 마커들만 연산 및 표시하도록 하여,

    직접 클러스터링을 구현함으로써 성능을 최적화하기로 했습니다.

     

    클러스터링을 구현할 때 여러 가지 사용할 수 있는 알고리즘이 있습니다.보통 밀도 기반의 DBSCAN알고리즘,
    데이터 포인트들을 계층적으로 묶어 트리구조로 클러스터링 하는 Hierarchical Clustering 알고리즘 등을 사용할 수 있습니다.

     

    해당 프로젝트에서는 지도를 그리드 형태로 나누고, 각 그리드 셀에 있는 데이터를하나의 클러스터로 묶는 Grid-Based 클러스터링 알고리즘을 사용하려 합니다.

     

    해당 알고리즘은 계산 속도가 매우 빠르고, 클러스터링 구조가 간단하고 명확하다는 장점이 있습니다.

     

    -> 여러 가지 클러스터링 알고리즘의 구현 방법과 장단점에 대해서는 다음에 블로그 글로 작성해 보겠습니다.

    1. 마커 필터링 (Harversine 공식을 활용한 거리 계산)

    먼저 각 마커와 지도의 중심 사이의 거리를 계산하기 위해

    harversine 공식을 사용한 함수를 만들었습니다.

    const haversineDistance = (
      lat1: number,
      lng1: number,
      lat2: number,
      lng2: number
    ): number => {
      const R = 6371;
      const dLat = (lat2 - lat1) * (Math.PI / 180);
      const dLng = (lng2 - lng1) * (Math.PI / 180);
      const a =
        Math.sin(dLat / 2) * Math.sin(dLat / 2) +
        Math.cos(lat1 * (Math.PI / 180)) *
          Math.cos(lat2 * (Math.PI / 180)) *
          Math.sin(dLng / 2) *
          Math.sin(dLng / 2);
      const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
      return R * c;
    };

     

    또한 이를 바탕으로 특정 거리 이내에 있는 마커들만 필터링하는 함수를 하나 만들었습니다.

    export const findNearbyMarkers = ({
      markers,
      latitude,
      longitude,
      maxDistance,
    }: {
      markers: MarkerRes[];
      latitude: number;
      longitude: number;
      maxDistance: number;
    }): MarkerRes[] => {
      return markers.filter((marker) => {
        const distance = haversineDistance(
          latitude,
          longitude,
          marker.latitude,
          marker.longitude
        );
        return distance <= maxDistance;
      });
    };

    2. 지도의 레벨에 따른 거리 및 셀 크기 설정

    지도 레벨에 따라 표시할 머커의 범위와 클러스터링 셀의 크기를 동적으로 조정했습니다.

    const getDistance = (level: number) => {
      switch (true) {
        case level <= 3:
          return 1;
        case level <= 5:
          return 2;
        case level <= 6:
          return 4;
        case level <= 7:
          return 7;
        case level <= 8:
          return 14;
        case level <= 9:
          return 21;
        case level <= 10:
          return 30;
        case level <= 11:
          return 40;
        default:
          return 120;
      }
    };
    
    const getCellSize = (level: number) => {
      switch (true) {
        case level === 6:
          return 0.02;
        case level === 7:
          return 0.04;
        case level === 8:
          return 0.08;
        case level === 9:
          return 0.2;
        case level === 10:
          return 0.5;
        case level === 11:
          return 0.8;
        default:
          return 1.6;
      }
    };

    여기서 getDistance 함수는 지도 레벨에 따라 마커를 표시할 최대 거리를 설정하고,

    getCellSize 함수는 클러스터링에 사용할 셀의 크기를 설정했습니다.

     

    -> 참고: 이 과정을 위해 카카오 지도에 거리 재기 기능을 사용하고, 수치를 계속 확인해 보며 하나하나 조정했습니다.

    3. 클러스터링 구현

    다음으로 그리드 기반 클러스터링 방식을 직접 구현했습니다.

    이 방법은 마커들의 위치를 그리드 셀로 변환 후,

    동일한 셀에 속한 마커들을 하나의 그룹으로 묶는 방식입니다.

    interface MarkerRes {
      markerId: number;
      latitude: number;
      longitude: number;
      address?: string;
    }
    
    interface MarkerGroup {
      centerLatitude: number;
      centerLongitude: number;
      count: number;
    }
    
    const getGridCoordinates = (
      lat: number,
      lng: number,
      cellSize: number
    ): { x: number; y: number } => {
      const x = Math.floor(lng / cellSize);
      const y = Math.floor(lat / cellSize);
      return { x, y };
    };
    
    export const clusterMarkers = (
      markers: MarkerRes[],
      cellSize: number
    ): MarkerGroup[] => {
      const groups: { [key: string]: MarkerGroup } = {};
    
      markers.forEach((marker) => {
        const { x, y } = getGridCoordinates(
          marker.latitude,
          marker.longitude,
          cellSize
        );
        const key = `${x},${y}`;
    
        if (!groups[key]) {
          groups[key] = { centerLatitude: 0, centerLongitude: 0, count: 0 };
        }
    
        groups[key].centerLatitude += marker.latitude;
        groups[key].centerLongitude += marker.longitude;
        groups[key].count += 1;
      });
    
      return Object.values(groups).map((group) => ({
        centerLatitude: group.centerLatitude / group.count,
        centerLongitude: group.centerLongitude / group.count,
        count: group.count,
      }));
    };

     

    이때 클러스터링 로직은 다음과 같습니다.

    • 그리드 좌표 계산: getGridCoordinates 함수를 통해 마커의 위도와 경도를
      그리드 셀의 좌표로 변환합니다. 이 좌표를 바탕으로 마커들을 그룹화합니다.
    • 그룹 중심 계산: 각 그룹 내의 마커들을 평균 내에 클러스터의 중심 좌표를 계산합니다.
    • 그룹화된 마커 반환: 최종적으로 각 그룹의 중심 좌표와 해당 그룹에 속한 마커의 개수를 반환합니다.

    4. 마커와 오버레이 관리

    이제 마커를 생성하기 위한 함수를 따로 만들어 주겠습니다.

    interface CreateMarkerOption {
      image?: "pending" | "active" | "selected";
      position?: any;
      markerId?: string | number;
    }
    
    interface CreateMarker {
      options: CreateMarkerOption;
      map: KakaoMap;
    }
    
    const createMarker = ({ options, map }: CreateMarker) => {
      const imageSize = new window.kakao.maps.Size(44, 49);
      const imageOption = { offset: new window.kakao.maps.Point(21, 39) };
    
      const imageUrl =
        options.image === "selected"
          ? "/pin-selected.svg"
          : "/pin-active.svg";
      
      const pin = new window.kakao.maps.MarkerImage(
        imageUrl,
        imageSize,
        imageOption
      );
    
      const marker = new window.kakao.maps.Marker({
        map: map,
        position: options.position,
        image: pin,
      });
    
     setMarkers([marker]);
    };

     

    다음으로 각 그룹을 커스텀 오버레이로 표시해 주기 위해 오버레이 생성을 위한 함수도 만들어 주겠습니다.

    interface CreateOverlayOption {
      position?: any;
      title: string;
    }
    
    interface CreateOverlay {
      options: CreateOverlayOption;
      map: KakaoMap;
    }
    
    const createOverlay = ({ map, options }: CreateOverlay) => {
      const overlayDiv = document.createElement("div");
      const root = createRoot(overlayDiv);
    
      const overlay = new window.kakao.maps.CustomOverlay({
        position: options.position,
        content: overlayDiv,
        clickable: true,
      });
    
      // Overlay 컴포넌트는 따로 만들어 줘야 합니다.
      root.render(<Overlay title={options.title} position={options.position} />);
    
      overlay.setMap(map);
    
      setOverlays(overlay);
    };

     

    이제 지도 중심 이동과 확대/축소 레벨에 따라 표시할 마커들을 다시 로드하기 위한 함수를 만들어 줍니다.

    지도의 레벨이 낮아 마커가 많아질 때는 직접 구현한 클러스터링 기능을 사용해 그룹화된 오버레이로 표시하고,

    확대된 상태에서는 개별 마커를 다시 그립니다.

    interface ReloadMarkersOprion {
      maxLevel: number;
      selectId?: number;
    }
    
    interface ReloadMarkers {
      options: ReloadMarkersOprion;
      map: KakaoMap;
    }
    
    const reloadMarkers = ({ map, options }: ReloadMarkers) => {
      // 지도에 표시된 모든 마커 삭제 함수 (상태 null로)
      deleteAllMarker();
      // 지도에 표시된 모든 오버레이 삭제 함수 (상태 null로)
      deleteOverlays();
      
      const position = map.getCenter();
      const level = map.getLevel();
    
      const distance = getDistance(level);
    
      const nearbyMarker = findNearbyMarkers({
        markers: marker,
        latitude: position.getLat(),
        longitude: position.getLng(),
        maxDistance: distance,
      });
    
      if (level >= options.maxLevel) {
        const group = clusterMarkers(nearbyMarker, getCellSize(level));
        for (let i = 0; i < group.length; i++) {
          createOverlay({
            map,
            options: {
              position: new window.kakao.maps.LatLng(
                group[i].centerLatitude,
                group[i].centerLongitude
              ),
              title: `${group[i].count} 개`,
            },
          });
        }
      } else {
        for (let i = 0; i < nearbyMarker.length; i++) {
          if (options.selectId) {
            let image: "pending" | "active" | "selected";
            if (nearbyMarker[i].markerId === options.selectId) {
              image = "selected";
            } else {
              image = "active";
            }
            createMarker({
              map,
              options: {
                image: image,
                markerId: nearbyMarker[i].markerId,
                position: new window.kakao.maps.LatLng(
                  nearbyMarker[i].latitude,
                  nearbyMarker[i].longitude
                ),
              },
            });
          } else {
            createMarker({
              map,
              options: {
                image: "active",
                markerId: nearbyMarker[i].markerId,
                position: new window.kakao.maps.LatLng(
                  nearbyMarker[i].latitude,
                  nearbyMarker[i].longitude
                ),
              },
            });
          }
        }
      }
    };

     

    해당 함수들을 재사용하기 쉽게 커스텀 훅으로 만들어줬습니다.

    const useMarkerControl = () => {
      // ...위에 함수들
      return { createMarker, createOverlay, reloadMarkers };
    };

    5. 지도에 마커 그리기

    지도에 중심 좌표나 확대 수준이 변경되면

    계산된 그룹과 마커를 그리기 위해 카카오 지도 API의 idle 이벤트를 사용하여

    마커들을 다시 그려줍니다.

    useEffect(() => {
      if (!map) return;
    
      const handleIdle = () => {
        // selectedId는 선택된 마커를 구분하기 위함
        if (selectedId) {
          reloadMarkers({ map, options: { maxLevel: 6, selectId: selectedId } });
        } else {
          reloadMarkers({ map, options: { maxLevel: 6 } });
        }
      };
    
      window.kakao.maps.event.addListener(map, "idle", handleIdle);
    
      return () => {
        window.kakao.maps.event.removeListener(map, "idle", handleIdle);
      };
    }, [map, selectedId]);

    | 끝

    이와 같은 최적화를 통해 아래 이미지와 같이 지도의 이동과 확대/축소 시 성능을 크게 개선할 수 있었습니다.

    CPU 점유율 또한 최대 30%로 훨씬 안정적으로 유지되었고,

    많은 마커를 동시에 표시하더라고 화면이 멈추는 현상이 사라졌습니다.

     

    사용자 경험도 한층 개선되었으며, 지도상의 마커를 효율적으로 관리할 수 있는 체계가 마련되었습니다.

     

    -> 참고: 모든 성능 측정은 동일한 환경에서 측정되었습니다.

     

Designed by Tistory.