본문 바로가기
React 관련/프로젝트

React 구글맵(googleMap) 최적화(with typescript)

by ash9river 2024. 4. 26.

지난 포스팅에서 useEffect 안에 ref.current를 업데이트 하는 방식으로 구글맵을 렌더링하였다.

https://ash9river.tistory.com/36

 

React로 간단히 만들어보는 구글맵(with Typescript)

구글 맵을 프로젝트에 이용하려고 했는데, 진짜 진짜 정보가 적어서 어떻게든 완성하였다.나는 @googlemaps/react-wrapper를 이용하였고, 기본적인 세팅은 다른 글에서 보았다. https://leirbag.tistory.com/158

ash9river.tistory.com

 

그러나 이는 commit phase에서 React가 refs를 업데이트한 이후, componentDidMount의 시기에서 ref.current의 값을 변경하기에 비효율적이다. 이러한 문제를 타파하기 위해서, useEffect 대신에 callback ref를 사용해야 한다.

https://ash9river.tistory.com/49

 

React 렌더링 사이클과 useEffect, 그리고 useRef

ref와 useEffectuseEffect는 굉장히 섬세하다. 웬만해서는 사용해서는 안된다. 그런데, 이전에 구글 맵을 렌더링할 때, useEffect와 ref를 같이 사용함으로써, 예기치 못한 부작용들이 발생할 수 있었다. 

ash9river.tistory.com

 

callback ref

callback ref를 사용할 때, 이 콜백 함수는 React가 DOM 요소를 생성할 때 (componentDidMount와 유사한 시점) 호출된다. 또한, DOM 요소가 삭제될 때 (componentWillUnmount와 유사한 시점) 호출되기도 한다.

 

callback ref가 React component's life-cycle과 직접적으로 연결되어 있지 않지만, DOM 요소가 생성되거나 제거될 때, callback ref가 호출되는 시점은 componentDidMount 및 componentWillUnmount와 유사하므로, callback ref를 componentDidMount에서 사용하는 것과 유사한 효과를 얻을 수 있다.

 

이제 설명은 간단히 끝났으니, 구글맵의 예시를 통해 알아보자.
간단한 부분부터 디테일한 부분까지 천천히 파악해보자.

구글맵

기존의 구글맵 코드는 useEffect의 안에 ref.current를 조작함으로써, DOM를 조작하고 있다. 그러나 이 코드에 어떤 문제가 있는지 확인해보자.

 

기존 코드

import { useEffect, useRef, useState } from 'react';
import styles from './GoogleMap.module.scss';
import MapMarker from './MapMarker';

function GoogleMap() {
  const ref = useRef<HTMLDivElement>(null);
  const [googleMap, setGoogleMap] = useState<google.maps.Map>();

  useEffect(() => {
    if (ref.current) {
      const initialMap = new window.google.maps.Map(ref.current, {
        center: {
          lat: 37.549186395087,
          lng: 127.07505567644,
        },
        zoom: 16,
        mapId: process.env.REACT_APP_GOOGLE_VECTOR_MAP_KEY as string,
        /* disableDefaultUI: true,
        clickableIcons: false, */
        minZoom: 12,
        maxZoom: 18,
        gestureHandling: 'greedy',
        restriction: {
          latLngBounds: {
            north: 39,
            south: 32,
            east: 132,
            west: 124,
          },
          strictBounds: true,
        },
      });

      setGoogleMap(initialMap);
    }
  }, []);

  return (
    <div ref={ref} id="map" className={styles['google-map']}>
      {googleMap !== undefined ? <MapMarker map={googleMap} /> : null}
    </div>
  );
}

export default GoogleMap;

 

현재, ref는 초기값이 null인 RefObject이다.

interface RefObject<T> {
    readonly current: T | null;
}

function useRef<T>(initialValue: T | null): RefObject<T>;

 

그리고 useEffect의 의존성 배열은 비어있고, useEffect의 콜백 함수를 통해서 ref.current를 업데이트한다.
이는 commit phase에서 ref가 결정되고, 컴포넌트가 마운트된 이후(componentDidMount)에 ref.current를 업데이트하기에, 상당히 비효율적이다.

 

callback ref를 이용한 코드

import React, { useCallback, useState } from 'react';
import styles from './GoogleMap.module.scss';
import MapMarker from './MapMarker';

function GoogleMap() {
  const [googleMap, setGoogleMap] = useState<google.maps.Map>();

  const mapRef = useCallback<React.RefCallback<HTMLDivElement>>(
    (node: HTMLDivElement) => {
      if (node) {
        const initialMap = new window.google.maps.Map(node, {
          center: {
            lat: 37.549186395087,
            lng: 127.07505567644,
          },
          zoom: 16,
          mapId: process.env.REACT_APP_GOOGLE_VECTOR_MAP_KEY as string,
          /* disableDefaultUI: true,
          clickableIcons: false, */
          minZoom: 12,
          maxZoom: 18,
          gestureHandling: 'greedy',
          restriction: {
            latLngBounds: {
              north: 39,
              south: 32,
              east: 132,
              west: 124,
            },
            strictBounds: true,
          },
        });

        setGoogleMap(initialMap);
      }
    },
    [],
  );

  return (
    <div ref={mapRef} id="map" className={styles['google-map']}>
      {googleMap ? <MapMarker map={googleMap} /> : null}
    </div>
  );
}

export default GoogleMap;

callback ref를 사용하여, mapRef를 만들었고, useState를 사용하여, 구글 맵을 담았다.

 

callback ref는 commit phase의 시작에서 즉시 호출된다. mapRef가 호출될 때 구글 맵을 생성하고 초기화한다.

그리고 useState를 사용하여 구글 지도 객체를 상태로 관리하고 있으며, 해당 상태가 변경되면, 컴포넌트가 리렌더링을 트리거한다.

 

구글 맵 마커

기존의 구글 맵 마커 컴포넌트는 어거지로 만들었다. 어떤 것이 문제인지 확인해보자.

 

기존 코드

import { useEffect, useRef, useState } from 'react';
import styles from './MapMarker.module.scss';
import MapPin from './MapPin';

function MapMarker({ map }: { map: google.maps.Map }) {
  if (map === undefined) return <>error</>; // react router의 errorElement를 사용할 수도 있지만, 일단 임시
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (ref.current) {
      const initMarker = new google.maps.marker.AdvancedMarkerElement({
        position: {
          lat: 37.549186395087,
          lng: 127.07505567644,
        },
        map,
        title: '이건 마커다 마커마커',
        content: ref.current, // PinElement
        // ref.current를 조정함으로써 마커의 커스텀이 가능해질수도?
      });

      return () => {
        initMarker.map = null;
      };
    }
  }, [ref]);

  return (
    <div className={styles.marker}>
      <MapPin ref={ref}>마커</MapPin>
    </div>
  );
}

export default MapMarker;

 

useEffect의 의존성 배열에 ref를 담고, 그 안에서 ref.current를 조작하는 방식이다.
이는 상기 서술했던 것처럼 비효율적이다.

 

callback ref를 이용한 코드

import { useCallback, useEffect, useRef } from 'react';
import styles from './MapMarker.module.scss';
import MapPin from './MapPin';

function MapMarker({ map }: { map: google.maps.Map }) {
  const ref = useRef<google.maps.marker.AdvancedMarkerElement>();
  const markerRef = useCallback<React.RefCallback<HTMLElement>>(
    (node: HTMLDivElement) => {
      if (node) {
        const initMarker = new google.maps.marker.AdvancedMarkerElement({
          position: {
            lat: 37.549186395087,
            lng: 127.07505567644,
          },
          map,
          title: '이건 마커다 마커마커',
          content: node, // PinElement
          // ref.current를 조정함으로써 마커의 커스텀이 가능해질수도?
        });

        ref.current = initMarker;
      }
    },
    [],
  );

  useEffect(() => {
    return () => {
      if (ref.current) {
        ref.current.map = null;
      }
    };
  }, []);

  return (
    <div className={styles.marker}>
      <MapPin ref={markerRef}>마커</MapPin>
    </div>
  );
}

export default MapMarker;

 

callback ref인 markerRef에서 구글 맵 마커를 생성하고 초기화한다.

 

그리고, cleanup 함수를 작성하기 위해서 ref에 google.maps.marker.AdvancedMarkerElement 값을 담는다.(React의 버전이 18.3 이전이다.)
이 때, ref는 .current 값을 직접 수정해야 하기 때문에 React.MutableRefObject<T | undefined>이다.

 

만약 useEffect의 cleanup 함수를 통해서 google.maps.marker.AdvancedMarkerElement의 map 속성을 null로 초기화시키지 않으면 다음과 같은 상황이 발생한다.

 

마커핀과 마커 텍스트의 테두리가 진해진 모습이다.
이는 마커핀과 마커 텍스트가 같은 위치에 여러 개 겹친 상태로 렌더링되어서 진해진 것처럼 보이는 것이다.
하나의 컴포넌트에서 똑같은 여러 개의 요소가 렌더링되었기 때문에 이를 피하기 위해서 cleanup 함수를 작성하는 것이다.

 

마지막으로 MapPin 컴포넌트에 callback ref를 전달하고 있다. callback ref의 값이 같으면 자식 컴포넌트인 MapPin 컴포넌트도 리렌더링을 트리거하지 않게 되어 좀 더 효과적으로 설계했다고 볼 수 있다.

 

구글 맵 핀

useEffect를 통해서 렌더링을 이끌어내었다.
기존의 callback ref는 commit phase에 값이 갱신되므로, 의존성 배열을 빈 배열로 두어서 마운트 이후에(componentDidMount) 렌더링을 다시 이끌어내었다.

기존 코드

import { ReactNode, forwardRef, useEffect } from 'react';

interface MapPinProps {
  children: ReactNode;
}

const MapPin = forwardRef<HTMLDivElement, MapPinProps>(function MapPin(
  { children },
  ref,
) {
  useEffect(() => {
    if (typeof ref !== 'function') {
      if (ref?.current) {
        const initPin = new google.maps.marker.PinElement({
          background: '#db4455',
          borderColor: '#881824',
        });
        ref.current.appendChild(initPin.element);

        return () => {
          ref.current?.removeChild(initPin.element);
        };
      }
    }
  }, []);
  return <div ref={ref}>{children}</div>;
});

export default MapPin;

기존 코드는 callback ref를 사용하지 않았음으로, forward ref를 통해 전달받는 ref의 타입 가드를 !==function으로 하였다.

변경 코드

import { ReactNode, forwardRef, useEffect, useRef } from 'react';

interface MapPinProps {
  children: ReactNode;
}

const MapPin = forwardRef<HTMLDivElement, MapPinProps>(function MapPin(
  { children },
  ref,
) {
  const myRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (typeof ref === 'function') {
      const initPin = new google.maps.marker.PinElement({
        background: '#db4455',
        borderColor: '#881824',
      });
      myRef.current?.appendChild(initPin.element);
      ref(myRef.current);

      return () => {
        myRef.current?.removeChild(initPin.element);
      };
    }
  }, []);

  return <div ref={myRef}>{children}</div>;
});

export default MapPin;

 

callback ref를 forward ref로 전달하고 있으므로, function으로 타입 가드를 하였다.

 

마커 핀 컴포넌트에서는 useEffect를 사용하였는데, 만약 useEffect를 사용하지 않으면 마커핀이 렌더링되지 않았다.

 

 

또한, cleanup 함수를 작성하지 않으면 마커 핀이 여러 개 생겨버린다.

 

 

최적화 결과

처음 페이지 로딩시 결과는 다음과 같다.

최적화 전

최적화 후

 

새로고침을 여러 번 해본 결과

최적화 전

최적화 후

마무리

한 눈으로 봐도 최적화가 잘된 것같다.

렌더링 사이클을 분석하면서 어떤 것을 써야하는지, 최적화에 대해 좀 더 깊게 생각할 수 있는 계기가 되었다.