본문 바로가기

Front-End

React 에서 Naver Maps API 를 사용할때 초기 페이지 로드 속도 최적화하기

제가 회사에서 작업하는 페이지에는 지도를 인터렉션 하는 UI 가 많습니다. 지도 API 는 모두 naver maps API 를 사용하고 있습니다.
이 naver maps API 를 이용하다보니, 페이지를 렌더링하는데 꽤나 많은 리소스가 잡히고 외부 리소스 script 이다 보니 다양한 트러블 슈팅을 경험 하였었는데요,
이번에 지도가 화면에 나타나는 속도를 개선하는 과정에서 겪은 이슈와 해결 과정등을 공유해볼까 합니다.

1. 일반적인 naver maps API 호출과 렌더링

보통 naver maps API 를 불러와 렌더링한다고 하면, 다음과 같이 렌더링 하게 됩니다.

// public/index.html

...

<script type="text/javascript" src="https://openapi.map.naver.com/openapi/v3/maps.js?ncpClientId=***">
</script>
    
...
// Map Component

// 지도에 나타낼 각 좌표에 대한 데이터를 props 로 받아왔다고 가정

interface Props {
  ...
}

const MapComponent = ({ data }: Props) => {
  const mapRef = useRef<naver.maps.Map | null>(null);

  useEffect(() => {
    if (window?.naver && !mapRef.current) {
      mapRef.current = new naver.maps.Map("map", {
        // 지도에 적용할 옵션
        // ...
      });
      
      // 각종 지도에 대한 인터렉션 정의나 마커 생성
      ...
    }
  }, [data]);

  return <div id="map" />;
};

지도에 마커를 찍기위해, 서버에서 데이터등을 받아오고 각 데이터에는 좌표 정보등이 매핑 되어져 있다고 가정하겠습니다.

위 코드에 조금 설명을 붙여 보자면,

window.naver 를 검사하여 maps API 가 정상적으로 다운로드 되었는지, mapRef.current 를 검사하여 new naver.maps.Map 을 한번만 생성하였는지 등을 처리할수 있습니다.

특히 이 검사는 위와 같이 useEffect 가 data 를 의존성으로 가지고 있고, 이 data 들이 정적이지 않고 가변성이 있다면 반드시 해줘야 합니다. 컴포넌트가 처음 마운트 된후 에도 실행이 되고, data 가 비어있다가 들어오거나, data 의 값이 변하였다고 한다면 맵을 여러번 생성하게 되기 때문입니다.

이런 방식을 일반적으로 많이 사용할것이라고 생각이 됩니다. naver maps API 를 리액트에서 어떻게 사용하는지 조금만 검색해봐도 나오는 내용이니까요 🙂

하지만 이 경우는 정말 '일반적' 인 렌더링을 하는 경우이고, 꽤나 사이즈가 큰 프로젝트 안에서 지도를 가져오게 되면 지도를 다운로드 받는시간도, 그 크기도 줄이고 싶어집니다. 지도를 화면에 넓게 그려내고, 그안에서 마커를 20 ~ 100개 정도 그려내고, 각 마커에 인터렉션 조작등을 처리하게 되면 지도 API 를 사용하여 페이지를 그리는것은 꽤나 무겁다는 것을 알수 있습니다.

이러한 상황에서 어떻게 하면 외부 스크립트에 해당하는 naver maps API 를 초기 렌더링 성능 최적화를 할수 있을까요?
먼저 가장 유효하고 간단한 방법으로 script 태그에 defer 속성을 부여해서 사용하는 방법을 시도해봤습니다.
script 태그를 동기식으로 사용하거나 async 속성을 사용해서 naver maps API 를 로드하게 되면, 메인 React Bundle 스크립트의 실행을 Blocking 할수 있습니다. 이에대한 자세한 내용은 아래 블로그에 정말 좋은내용이 게시되어져 있습니다.

https://yceffort.kr/2020/10/defer-than-async

 

Home

yceffort

yceffort.kr

이 defer 속성을 이용해서 메인 스크립트의 실행을 blocking 하지 않게 해서, 지도가 조금 늦게 로드 되더라도 렌더링간 블로킹에 의해 낭비 되는 시간을 줄이고, 다른 UI 를 그리는 시간을 확보할수가 있게 될것이라고 가정하고 시도해 보았습니다. 

2. naver maps API script 비동기 적으로 defer 속성을 이용하여 렌더링 하기

트러블 슈팅

naver maps API 를 호출하는 script 태그에 defer 속성을 입히고 그대로 렌더링을 해보면, 화면에 지도가 나타 나지 않거나 window 에 naver 객체가 잡히지 않아 런타임 에러가 나는 상황이 많았습니다. 특히 빌드후에 실행시켜봤을때 비정상적인 동작이 많았습니다. 그 이유는 defer 속성을 사용하게 되면, 지도 객체를 생성하고 UI 에 그려내는 컴포넌트가 실행되기 전에 maps API 가 다운로드 되지 않을수 있기 때문입니다.

이런경우, 컴포넌트 자체에서는 상당히 컨트롤 하기 어려워 집니다. 지도에 표시할 데이터 도 응답을 받아 오는 시간이 존재하고, 지도를 로딩 하는 시간도 존재하기 때문에, 컴포넌트를 언제 렌더링 해야하는지 그 타이밍을 정확히 잡기 어려워 질수 있습니다.
추가로 여기에 맵 컴포넌트가 나중에 렌더링 되므로, 코드 스플리팅 되어 있다면 상당히 복잡한 렌더링 타이밍을 가지고 있게 됩니다.

해결책으로는, Map Component 를 감싸는 상위 Container Component 에서 지도가 로드 되었는지 체크하고, 지도에 나타낼 데이터도 정상적으로 가지고 있는지 체크를 state 의 형태로 다뤄서 렌더링 하도록 할수 있습니다. 문제는, 리액트 컴포넌트의 입장에서 public/index.html 에 명시해둔 script 태그가 호출하는 naver maps API 가 로드 되었는지 알수 있는 방법이 없다는 것입니다. 따라서 index.html 에 존재하는 script 태그를 리액트 컴포넌트에서 동적으로 호출 하도록 하면, onLoad 이벤트를 통해 스크립트가 로드 되었는지 컴포넌트가 알수 있도록 할수 있습니다. 아래와 같은 코드를 작성해볼수 있었습니다.

const useMapLoad = () => {
    const [isMapLoad, setIsMapLoad] = useState(false);
    const loadStart = useRef(false);

    useEffect(() => {
      if (window?.naver) {
        setIsMapLoad(true);
        return;
      }
      if (loadStart.current) return;
      if (document.getElementById("NavarMap")) return;

      loadStart.current = true;

      const $naverMap = document.createElement("script");
      $naverMap.id = "NavarMap";
      $naverMap.defer = true;
      $naverMap.type = "text/javascript";
      $naverMap.src =
        "https://openapi.map.naver.com/openapi/v3/maps.js?ncpClientId=***";
      document.head.appendChild($naverMap);

      const set = () => {
        setIsMapLoad(true);
        if (!$naverMap) return;
        $naverMap.removeEventListener("load", set);
      };

      if ($naverMap) {
        $naverMap.addEventListener("load", set);
      }
    },[]);
    
    return { isMapLoad };
}

const MapContainer = () => {
    // 전역 컨텍스트의 예시로 recoil 을 들었습니다.
    // data 의 타입은 임의로 지정했고, data 를 응답 받기전에는 null 로 초기값이 잡혀있다고 가정하겠습니다.
    const data : DataType | null = useRecoilValue(mapDataState);
    const { isMapLoad } = useMapLoad();
    
    return <>{data && isMapLoad && <MapComponent data={data} />}</>
}

naver maps API 가 로드 되었는지 확인후 MapComponent 를 렌더링 하는것을 검색해보면, 이와 비슷한 해결책을 많이 볼수 있습니다.
하지만 이방법도, 지도를 빠르게 로드 하기위한 작업을 했다고 말하기는 어렵습니다. 왜냐하면 MapContainer 가 렌더링 과정을 거쳐, useEffect 내의 콜백함수가 실행 될때까지 naver maps API 스크립트가 없기 때문에, 스크립트가 다운로드를 시작하는 시점이 많이 늦게 됩니다. onLoad 이후 state 를 업데이트 시킨후에야 MapComponent 가 렌더링 되며 map 을 생성하는 코드등이 실행될것 이기 때문에, 이 방법만 가지고는 초기 로드 속도를 개선했다고 이야기 하기가 어렵습니다. 위와같은 코드는 페이지에 지도가 항상 노출되는 경우가 아닌, 특정 인터렉션에 의해 선택적으로 지도가 렌더링 된다면 유효하게 사용할수 있습니다.

3. naver maps API js resource 우선 로딩, 사전 연결

외부 리소스를 로드 해야할때, link 태그와 함께 몇가지 속성을 사용하여 로드 우선순위를 정해주거나 앞으로 사용될 리소스들을 미리 가져오도록 할수 있습니다. 이에대한 내용은 아래 블로그에 잘 요약 되어져 있습니다. 

https://beomy.github.io/tech/browser/preload-preconnect-prefetch/

 

[Browser] 리소스 우선순위 - preload, preconnect, prefetch

브라우저에서 리소스에 우선순위를 지정하여 다운로드할 수 있게 하는 방법에 대해 이야기하도록 하겠습니다.

beomy.github.io

따라서, 다음과 같이 public 의 html 파일에 가져오도록 지정해줄수 있습니다.

// public/index.html
  
  ...
  <link rel="preload" as="script" href="https://openapi.map.naver.com/openapi/v3/maps.js?ncpClientId=***">
  <link rel="preconnect" href="https://nrbe.pstatic.net">
  <link rel="dns-prefetch" href="https://nrbe.pstatic.net">
  ...

위의 nrbe.pstatic.net 은, 타일 이미지를 가져오는 요청 주소입니다. 이 주소는 브라우저 개발자 도구에서 지도 타일 이미지 요청 헤더를 보면 알수 있습니다.

dns-prefetch 는 브라우저에게 DNS 조회를 미리 수행하도록 지시합니다. 이를 통해 브라우저가 페이지의 외부 리소스(: 이미지, 스크립트, 스타일시트 ) 대한 요청을 보내기 전에 해당 도메인의 IP 주소를 미리 확인하고 저장할 있습니다.

preconnect 는 http 통신을 하기 위한 TCP 연결 TLS(HTTPS 경우) 설정을 미리 수행하도록 지시합니다.

preconnect 와 dns-prefetch 두 설정 모두, 받아 올 리소스가 정확한 주소를 알수 없으며 받아오는 리소스가 미래에 사용될수 있을때 사용할수 있는 사전 연결 속성입니다. 두 설정을 같이 사용해 nrbe.pstatic.net 을 연결하는것은, 사전연결을 모두 수행해놓기 위함인데 이와 관련된 문서는 mdn 에서 찾아볼수 있었습니다.

https://developer.mozilla.org/en-US/docs/Web/Performance/dns-prefetch

 

Using dns-prefetch - Web performance | MDN

DNS-prefetch is an attempt to resolve domain names before resources get requested. This could be a file loaded later or link target a user tries to follow.

developer.mozilla.org

이렇게 설정하여, 가장먼저 naver maps script 를 로드 하게 요청 해두고, 지도 타일 이미지들에대해 사전 연결을 수행하여 지도 로드속도를 추가로 높일수 있게됩니다.

하지만 이렇게 되면, 로드가 일찍 끝나면 끝난대로 빠르게 MapComponent 렌더링이 실행되도록 해줘야 합니다. 네트워크 환경에 따라 지도 로드가 다 되지 않아서 기다린후 실행 되어야 할수도 있습니다. 여전히 존재하는 문제는, 위의 컴포넌트 입장에서 script 가 로드가 되었는지 알수가 없다는 것 입니다.

4. Custom Event 를 사용하여 지도 load 를 판별하고 MapComponent 를 렌더링 하기

먼저, 아까 public/index.html 에서 옮겼던 script 태그를 다시 public/index.html 로 위치 시키고, defer 속성을 사용하도록 합니다. 그다음, 인라인 스크립트로 Custom Event 를 간단하게 정의했습니다. 

// public/index.html

<head>
  ...
  <link rel="preload" as="script" href="https://openapi.map.naver.com/openapi/v3/maps.js?ncpClientId=***">
  <link rel="preconnect" href="https://nrbe.pstatic.net">
  <link rel="dns-prefetch" href="https://nrbe.pstatic.net">
  
  <script>
      const mapsLoadEvent = new CustomEvent('navermapsload', {
        detail: {
          message: 'navermapsload'
        }
      });
  </script>
  ...
</head>  
  
<body>
  <div id="root"></div>
  <script defer type="text/javascript" src="https://openapi.map.naver.com/openapi/v3/maps.js?ncpClientId=***"
    onload="window.dispatchEvent(mapsLoadEvent)">
  </script>
</body>

이렇게 되면 전역(window) 에서 스크립트가 로드되었을때 커스텀 이벤트로 정의한 'navermapsload' 이벤트 발생하게 됩니다. 따라서 React Component 에서도 이벤트를 감지할수가 있게 됩니다. naver maps API script body 마지막에 두면, 빌드 후에 생기는 메인 bundle script 태그보다 뒤에 위치하기 때문에, 같은 defer 속성으로 잡히더라도 지도 script 실행되는 순서를 보장 받을수가 있게됩니다. 이제 아래와 같이 코드를 작성할수 있습니다.

const useMapLoad = () => {
  const isMapExist = window?.naver && window.naver.maps ? true : false;
  const [isMapLoad, setIsMapLoad] = React.useState(isMapExist);

  useLayoutEffect(() => {
    window.addEventListener(
        "navermapsload",
        () => {
          setIsMapLoad(true);
        },
        { once: true }
      );
  }, []);

  return { isMapLoad };
};

const MapContainer = () => {
    // 전역 컨텍스트의 예시로 recoil 을 들었습니다.
    // data 의 타입은 임의로 지정했고, data 를 응답 받기전에는 null 로 초기값이 잡혀있다고 가정하겠습니다.
    const data : DataType | null = useRecoilValue(mapDataState);
    const { isMapLoad } = useMapLoad();
    
    return <>{data && isMapLoad && <MapComponent data={data} />}</>
}

이미 script 의 로드가 끝난채로 MapContainer 가 더 늦게 렌더링 되었다면, window 객체에 window.naver.maps 객체가 잡히게 됩니다. 로드가 아직 끝나지 않았다면, 로드가 다 된후 위에서 정의한 navermapsload 이벤트가 window 에서 발생하게 됩니다. 이벤트 리스너를 추가하는 과정은 useLayoutEffect 로 감싸 Render Phase 가 되기 전에 최대한 빨리 리스너가 추가되도록 해줬습니다. 어느경우라도, isMapLoad 라는 state 는 naver maps script 가 존재할때 true 가 됩니다.

이렇게 함으로써 아래와 같은 개선을 할수 있었습니다.

1. 빠르게 naver maps script 를 요청
2. 메인 React Bundle Script 가 실행 될 시간을 확보
3. Custom Event 를 통해 naver maps script 가 컴포넌트의 입장에서 로드 되었는지 알수 있게 처리
4. 로드된후 다른 이슈가 생기지 않도록 안전하게 MapComponent 를 렌더링

실제로 어떻게 개선이 되었나 page speed insights 를 통해 측정해보았습니다.

위의 지표가 가장 정확하다고는 말할수 없지만, TBT 나 Speed Index, LCP 항목등에서 개선이 된것을 참고 할수 있었습니다.

이전에는 Custom Event 를 거의 사용해본적이 없었는데, 이런식으로 감지할수 없는 Event 를 감지하도록 처리해볼수가 있었습니다. 외부 리소스를 다운로드 받는 상황 뿐만아니라, 특정 인터렉션이나 비동기적인 동작에도 Custom Event 를 활용하여, 구현 요구사항에 적합한 처리를 할수 있도록 개선해볼수 있을것 같습니다.