카카오맵 API를 정상적으로 호출한 후, 이번에는 이 컴포넌트를 재사용 가능하게 만들어 보려 한다.

단순히 복사하는 것보다 props를 넘겨줌으로써 코드 반복을 피하고, Contact 페이지에 여러 오피스 위치를 동시에 보여주도록 한다.

 

우선 조금 전 카카오맵을 불러온 코드이다.

 

import React, { useEffect } from "react";
import s from "../stores/styling";


// 'Property 'kakao' does not exist on type 'Window & typeof globalThis'.' 오류에 대한 해결법.
// kakao 객체가 window에 있다고 declare로 명시한다.
declare global {
  interface Window {
    kakao: any;
  }
}

interface FetchMapProps {
  id: string;
  latitude: number;
  longitude: number;
}

const FetchMap:React.FC<FetchMapProps> = ({ id, latitude, longitude}) => {

//useEffect를 통해 컴포넌트 마운트 될 때 <script>태그를 동적으로 추가하는 법
  useEffect(() => {
    const loadKakaoMapScript = () => {
      return new Promise<void>((resolve, reject) => {
        const script = document.createElement("script");    //<script>태그 생성
        script.type = "text/javascript";
        //api키는 프로젝트 루트 디렉토리 .env 파일에 저장. CRA 보안 정책
        script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.REACT_APP_KAKAO_MAP_KEY}&libraries=services&autoload=false`;
        
        //onload (then 역할)와 onerror (catch 역할)은 <script>요소의 이벤트 핸들러 속성으로 제공됨.
        script.onload = () => { //성공
          resolve();
        };
        script.onerror = () => {    //실패
          reject(new Error("Kakao Map script loading error"));
        };
        document.head.appendChild(script);  //생성된 <script>를 HTML <head>에 추가
      });
    };

    //지도 초기화
    const initMap = () => {

      if (!window.kakao || !window.kakao.maps) {
        console.error("Kakao maps library is not loaded.");
        return;
      }

      let container = document.getElementById(id); // 지도를 담을 영역의 DOM 레퍼런스
      let options = {
        center: new window.kakao.maps.LatLng(latitude, longitude), // 위도, 경도
        level: 2, // 지도의 레벨(확대, 축소 정도)
      };
      
      // kakao 객체가 window 하위 객체라고 정의해야하므로 window.kakao라고 표기
      new window.kakao.maps.Map(container, options); // 지도 생성 및 객체 리턴
    } 

    loadKakaoMapScript()
      .then(() => { //지도 로드 성공 시, 지도 초기화
        window.kakao.maps.load(initMap); // TypeError: window.kakao.maps.LatLng is not a constructor 에러 해결지점
      })
      .catch((error) => {   //지도 로드 실패 시, 로그 메세지
        console.error(error);
      });
  }, []);   //마운트 시 한번만 수행

  return <s.Map id="{id}" className="map" />;
};

export default FetchMap;

 

마지막 return 문은 원래 id="map" 이었다. 

그러나 이것을 테스트 겸 단순 복사+붙여넣기 했더니 다른 컴포넌트 요소는 잘 복사가 되었지만, 지도 부분만 복사되지 않았다.

 

이유는 document.getElementById(id) 부분, 즉 id에 따라 지도를 뿌려준다고 설정해두었는데, 이 id가 "map"으로 고정되어버려 한 번만 로드되는 것이었다.

 

id="{id}" 로 바꾸어 생성되는 <s.Map> 블록마다 id를 직접 부여해주면 지도가 복사된다.

 

 


 

 

문제 없이 될 것이라 기대했지만, TypeError: Cannot read properties of null (reading 'currentStyle') 가 나타났다.

 

1. useEffect 문이 해당 id가 매겨진 div를 찾으려는데 ---- document.getElementById(id) 

2. 해당 div는 아직 렌더링되지 않아서 ---- null

3. 매핑되지 못하고 null 값을 리턴한다.

라는 오류였다.

 

 

그렇다면 useEffect문이 컴포넌트가 완전히 로드된 이후에만 처리하도록 고쳐보자.

import React, { useEffect, useRef } from "react";
import s from "../stores/styling";

declare global {
  interface Window {
    kakao: any;
  }
}

interface FetchMapProps {
  id: string;
  latitude: number;
  longitude: number;
}

const FetchMap: React.FC<FetchMapProps> = ({ id, latitude, longitude }) => {
  const mapContainerRef = useRef<HTMLDivElement | null>(null);	
  // mapContainerRef라는 ref 객체 생성
  // 이 객체는 current라는 속성을 가지고 있고, 초기값은 null이다.
  // 이 객체의 current 속성은 HTMLDivElement 또는 null일 수 있다.

  useEffect(() => {
    const loadKakaoMapScript = () => {
      return new Promise<void>((resolve, reject) => {
        const script = document.createElement("script");
        script.type = "text/javascript";
        script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.REACT_APP_KAKAO_MAP_KEY}&libraries=services&autoload=false`;
        script.onload = () => {
          resolve();
        };
        script.onerror = () => {
          reject(new Error("Kakao Map script loading error"));
        };
        document.head.appendChild(script);
      });
    };

    const initMap = () => {
      if (!window.kakao || !window.kakao.maps) {
        console.error("Kakao maps library is not loaded.");
        return;
      }

      if (mapContainerRef.current) {
        const container = mapContainerRef.current;
        const options = {
          center: new window.kakao.maps.LatLng(latitude, longitude),
          level: 2,
        };
        new window.kakao.maps.Map(container, options);
      }
    };

    loadKakaoMapScript()
      .then(() => {
        window.kakao.maps.load(initMap);
      })
      .catch((error) => {
        console.error(error);
      });
  }, [id, latitude, longitude]); //마운트 시 한번만 수행되던 것을 props가 바뀔때마다 실행

  return <s.Map ref={mapContainerRef} id={id} className="map" />;
  //mapContainerRef 객체의 current 속성이 가리키는 곳 지정
};

export default FetchMap;

 

useRef는 useState와는 달리 값이 변경되어도 컴포넌트가 리렌더링 되지 않는다.

따라서 DOM 요소를 직접 조작해야 하는 경우 유용하게 쓰일 수 있다.

 

이렇게 하니 컴포넌트를 두번, 세번 재사용 할 때 잘 동작한다.

그러나 사용되는 횟수에 따라 매번 API 호출이 일어나는 것으로 확인했다.

여러 좌표를 한번에 불러와 뿌려버릴 수는 없을까?

 


 

 

이때는 script 태그가 이미 추가되었는지 확인하고, 이미 로드된 경우 API 호출을 막아야 한다.

기존 코드의 경우, useEffect문 내에서 컴포넌트가 마운트 될때마다 loadKakaoMapScript 함수를 호출하여 Promise 객체를 새로 생성했다. 

이 부분을 먼저 useEffect문 바깥으로 꺼내고, 전역으로 script 로드 상태를 관리해보겠다.

let isScriptLoaded = false; // 스크립트 로드 상태 관리. 초기값 false

const loadKakaoMapScript = () => {
  return new Promise<void>((resolve, reject) => {
    if (isScriptLoaded) {
      resolve(); // 이미 로드되었다면 패스
      return;
    }

    const script = document.createElement("script");
    script.type = "text/javascript";
    script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.REACT_APP_KAKAO_MAP_KEY}&libraries=services&autoload=false`;
    script.onload = () => {
      isScriptLoaded = true;
      resolve();
    };
    script.onerror = () => {
      reject(new Error("Kakao Map script loading error"));
    };
    document.head.appendChild(script);
  });
};

 

 

완성된 전체 코드이다.


import React, { useEffect, useRef } from "react";
import s from "../stores/styling";

declare global {
  interface Window {
    kakao: any;
  }
}

interface FetchMapProps {
  id: string;
  latitude: number;
  longitude: number;
}

let isScriptLoaded = false; // 스크립트 로드 상태 관리 (초기값 false)
const scriptLoadPromise = loadKakaoMapScript();

function loadKakaoMapScript() {
  // useEffect 바깥에서 Promise 객체를 생성, 전역으로 관리
  return new Promise<void>((resolve, reject) => {
    if (isScriptLoaded) { //이미 스크립트 로드가 돼있다면 이 과정을 스킵
      resolve();
      return;
    } 
    else { //스크립트 로드가 안돼있는 경우
      const script = document.createElement("script"); //script 태그 추가 (아직 추가되지 않고, JS 객체로 존재)
      script.type = "text/javascript";
      script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.REACT_APP_KAKAO_MAP_KEY}&libraries=services&autoload=false`;
      //src속성으로 로드할 외부 JS파일 url 지정
      script.onload = () => { //script 태그의 onload 이벤트 핸들러 설정
        //스크립트 로드 시도
        isScriptLoaded = true; //성공 시 true
        resolve();
      };

      script.onerror = () => { //실패 시 에러 처리
        reject(new Error("Kakao Map script loading error"));
      };
      document.head.appendChild(script);  //세부사항 성공, 실패 여부 상관없이 script태그 추가
    }
  });
}

const FetchMap: React.FC<FetchMapProps> = ({ id, latitude, longitude }) => {
  const mapContainerRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const initMap = () => { //지도 초기화
      if (!window.kakao || !window.kakao.maps) {  //라이브러리가 올바르게 초기화 됐는지 확인하고, 안됐다면 에러 처리
        console.error("Kakao maps library is not loaded.");
        return;
      }

      if (mapContainerRef.current) {  
        const container = mapContainerRef.current; //HTML에 접근. current는 레퍼런스가 가리키는 실제 DOM 요소이다.
        const options = {
          center: new window.kakao.maps.LatLng(latitude, longitude),
          level: 2,
        };
        new window.kakao.maps.Map(container, options);
      }
    };

    scriptLoadPromise //스크립트가 로드된 상태가 확인되면
      .then(() => { //지도를 초기화한다.
        window.kakao.maps.load(initMap);
      })
      .catch((error) => { //로드가 안됐다면 에러 처리
        console.error(error);
      });
  }, [id, latitude, longitude]);  // id값, 위도, 경도가 바뀔 때마다 실행 (여러개의 지도 생성 가능)

  return <s.Map ref={mapContainerRef} id={id} className="map" />; // 
  // useRef를 쓰지 않고 그냥 div를 사용하면? --> 컴포넌트 마운트 전에 DOM요소에 접근해 문제 발생.
  // useRef는 마운트 이후에도 DOM 요소에 직접 접근할 수 있게 해준다.
};

export default FetchMap;

 

script 로드 여부를 전역으로 관리하여 중복된 네트워크 호출을 막고, 지도를 효율적으로 여러번 그릴 수 있도록 구성했다.

+ Recent posts