본문 바로가기

React

React에서 지도 API(Google Maps, Leaflet) 통합하기

지도는 위치 기반 기능을 제공하는 대부분의 서비스에서 핵심 UI입니다. React에서 Google Maps와 Leaflet을 실무적으로 통합하는 방법과 성능/배포 체크리스트를 정리합니다.

1. 어떤 지도를 선택할까?

Google Maps는 풍부한 데이터와 길찾기, 장소 검색 등 상용 기능을 제공합니다. 비용과 API 키 관리가 필요합니다. Leaflet은 오픈소스이며 타일 공급자(예: OpenStreetMap)를 자유롭게 선택할 수 있어 비용 절감에 유리합니다. 커스텀 UI와 플러그인 확장에 강점이 있습니다.

2. 설치와 환경 변수 준비

프로젝트에 필요한 라이브러리를 설치하고 API 키를 환경 변수로 관리합니다. Vite는 import.meta.env, CRA/Next는 process.env를 사용합니다.

// 패키지 설치 (주석으로 표시)
// Google Maps
// npm i @react-google-maps/api
// 또는 스크립트 직접 로딩만 사용할 수 있습니다.

// Leaflet
// npm i react-leaflet leaflet

// 환경 변수 예시
// .env
// REACT_APP_GOOGLE_MAPS_API_KEY=your_key
// VITE_GOOGLE_MAPS_API_KEY=your_key

3. Google Maps 통합: @react-google-maps/api

공식 JS SDK를 React 친화적으로 감싼 라이브러리입니다. 로더 훅으로 키를 주입하고, 지도/마커 컴포넌트를 선언형으로 사용합니다.

import { useCallback, useMemo, useRef, useState } from "react";
import { GoogleMap, Marker, InfoWindow, useJsApiLoader } from "@react-google-maps/api";

const containerStyle = { width: "100%", height: "400px" };
const defaultCenter = { lat: 37.5665, lng: 126.9780 }; // 서울

export default function GoogleMapBasic() {
  const { isLoaded } = useJsApiLoader({
    id: "google-map-script",
    googleMapsApiKey: import.meta?.env?.VITE_GOOGLE_MAPS_API_KEY || process.env.REACT_APP_GOOGLE_MAPS_API_KEY,
    // 필요 시 libraries: ["places"]
  });

  const mapRef = useRef(null);
  const onLoad = useCallback((map) => {
    mapRef.current = map;
  }, []);
  const onUnmount = useCallback(() => {
    mapRef.current = null;
  }, []);

  const [markers, setMarkers] = useState([
    { id: 1, position: { lat: 37.5665, lng: 126.9780 }, title: "서울" },
  ]);
  const [activeId, setActiveId] = useState(null);

  const options = useMemo(() => ({
    disableDefaultUI: false,
    clickableIcons: true,
  }), []);

  if (!isLoaded) return null;

  return (
    <GoogleMap
      mapContainerStyle={containerStyle}
      center={defaultCenter}
      zoom={12}
      onLoad={onLoad}
      onUnmount={onUnmount}
      options={options}
      onClick={(e) => {
        if (!e.latLng) return;
        const newMarker = {
          id: Date.now(),
          position: { lat: e.latLng.lat(), lng: e.latLng.lng() },
          title: "새 마커",
        };
        setMarkers((prev) => [...prev, newMarker]);
      }}
    >
      {markers.map((m) => (
        <Marker key={m.id} position={m.position} onClick={() => setActiveId(m.id)} title={m.title} />
      ))}
      {activeId && (
        <InfoWindow position={markers.find((m) => m.id === activeId).position} onCloseClick={() => setActiveId(null)}>
          <div>마커 정보입니다.</div>
        </InfoWindow>
      )}
    </GoogleMap>
  );
}

팁: options, center, markers를 useMemo/useCallback으로 안정화하면 불필요한 리렌더를 줄일 수 있습니다. Places 등 추가 라이브러리가 필요하면 libraries로 로드합니다.

4. Google Maps 통합: 스크립트 직접 로딩

라이브러리 없이 가볍게 쓰고 싶다면 스크립트를 직접 주입합니다. 초기화와 정리를 신경 써야 합니다.

import { useEffect, useRef } from "react";

function loadGoogleMaps(key) {
  return new Promise((resolve, reject) => {
    if (window.google && window.google.maps) return resolve(window.google);
    const script = document.createElement("script");
    script.src = `https://maps.googleapis.com/maps/api/js?key=${key}`;
    script.async = true;
    script.onload = () => resolve(window.google);
    script.onerror = reject;
    document.head.appendChild(script);
  });
}

export default function GoogleMapVanilla() {
  const ref = useRef(null);
  const mapRef = useRef(null);
  const markersRef = useRef([]);

  useEffect(() => {
    let mounted = true;
    const key = import.meta?.env?.VITE_GOOGLE_MAPS_API_KEY || process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
    loadGoogleMaps(key).then((google) => {
      if (!mounted || !ref.current) return;
      mapRef.current = new google.maps.Map(ref.current, {
        center: { lat: 37.5665, lng: 126.9780 },
        zoom: 12,
      });

      const clickListener = mapRef.current.addListener("click", (e) => {
        const marker = new google.maps.Marker({ position: e.latLng, map: mapRef.current });
        markersRef.current.push(marker);
      });

      // 정리
      return () => {
        google.maps.event.removeListener(clickListener);
        markersRef.current.forEach((m) => m.setMap(null));
      };
    });
    return () => { mounted = false; };
  }, []);

  return <div ref={ref} style={{ width: "100%", height: 400 }} />;
}

5. Leaflet 통합: react-leaflet

React 생태계에서 가장 많이 쓰는 방식입니다. Leaflet CSS를 반드시 가져와야 아이콘과 컨트롤이 올바르게 보입니다.

import "leaflet/dist/leaflet.css";
import { MapContainer, TileLayer, Marker, Popup, useMapEvents } from "react-leaflet";
import L from "leaflet";

const icon = new L.Icon({
  iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
  iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
  shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
  iconSize: [25, 41],
  iconAnchor: [12, 41],
});

function ClickToAddMarker({ onAdd }) {
  useMapEvents({
    click(e) {
      onAdd(e.latlng);
    },
  });
  return null;
}

export default function LeafletBasic() {
  const position = [37.5665, 126.9780];
  const [markers, setMarkers] = React.useState([[37.5665, 126.9780]]);

  return (
    <MapContainer center={position} zoom={12} style={{ height: 400, width: "100%" }}>
      <TileLayer
        attribution='&copy; OpenStreetMap contributors'
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
      />
      <ClickToAddMarker onAdd={(latlng) => setMarkers((prev) => [...prev, [latlng.lat, latlng.lng]])} />
      {markers.map((pos, idx) => (
        <Marker key={idx} position={pos} icon={icon}>
          <Popup>마커 {idx + 1}</Popup>
        </Marker>
      ))}
    </MapContainer>
  );
}

팁: 클러스터링이 필요하면 react-leaflet-cluster 같은 래퍼를 사용하거나 leaflet.markercluster 플러그인을 적용합니다.

6. Leaflet 순수 사용

플러그인 제어를 세밀하게 하고 싶다면 Leaflet 인스턴스를 직접 관리합니다.

import "leaflet/dist/leaflet.css";
import { useEffect, useRef } from "react";
import L from "leaflet";

export default function LeafletVanilla() {
  const ref = useRef(null);
  const mapRef = useRef(null);

  useEffect(() => {
    if (!ref.current) return;
    mapRef.current = L.map(ref.current).setView([37.5665, 126.9780], 12);
    const tile = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
      attribution: "© OpenStreetMap contributors",
    }).addTo(mapRef.current);

    const marker = L.marker([37.5665, 126.9780]).addTo(mapRef.current);
    marker.bindPopup("서울").openPopup();

    return () => {
      mapRef.current.removeLayer(tile);
      mapRef.current.remove();
    };
  }, []);

  return <div ref={ref} style={{ width: "100%", height: 400 }} />;
}

7. 공통 패턴: 마커, 이벤트, 데이터 연동

- 서버에서 좌표를 페이징/클러스터링해서 가져오면 초기 로딩을 줄일 수 있습니다. React Query로 비동기 데이터를 캐시합니다.

- 마커 수가 많을 때는 클러스터링을 기본으로 적용합니다. Google은 @googlemaps/markerclusterer, Leaflet은 markercluster를 사용합니다.

- 지도 이동 이벤트에서 setState를 과도하게 호출하지 않도록 디바운스/스로틀을 적용합니다. 예: idle 이벤트에서만 네트워크 요청을 실행합니다.

// Google Maps에서 bounds 변경 후 서버 호출 최적화 예시
import { useRef } from "react";

function useDebounce(fn, delay) {
  const t = useRef();
  return (...args) => {
    clearTimeout(t.current);
    t.current = setTimeout(() => fn(...args), delay);
  };
}

// mapRef.current.addListener("idle", debounce(() => { fetchMarkersByBounds(...) }, 300));

8. 성능 최적화 체크리스트

- 지도를 초기화하는 컴포넌트를 페이지에서 분리하고 React.memo로 감싸면 리렌더가 감소합니다.

- 마커 props는 stable reference를 유지합니다. options, icon 객체는 useMemo로 캐싱합니다.

- SSR 환경에서는 클라이언트에서만 로드하도록 dynamic import를 사용합니다.

// Next.js에서 SSR 비활성화 예시
// import dynamic from "next/dynamic";
// const Map = dynamic(() => import("./LeafletBasic"), { ssr: false });

9. 사용자 위치, UX, 접근성

- navigator.geolocation으로 현재 위치를 받아 초기 중심으로 설정합니다. 권한 거부 시 기본 좌표로 폴백합니다.

function useCurrentPosition(defaultCenter) {
  const [center, setCenter] = React.useState(defaultCenter);
  React.useEffect(() => {
    if (!navigator.geolocation) return;
    navigator.geolocation.getCurrentPosition(
      (pos) => setCenter({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
      () => setCenter(defaultCenter),
      { enableHighAccuracy: true, timeout: 5000 }
    );
  }, []);
  return center;
}

- 지도는 시각적 요소가 많아 스크린 리더 접근성이 낮습니다. 주요 정보는 별도 리스트로도 제공하고, 키보드 포커스 가능한 컨트롤을 제공합니다.

10. 보안/배포 체크리스트

- Google Maps API 키는 반드시 도메인 제한과 API 제한을 설정합니다. 결제 계정을 연결하지 않으면 쿼터에 걸릴 수 있습니다.

- Leaflet은 타일 서버 정책을 준수합니다. 상용 서비스라면 유료 타일 공급자를 고려합니다.

- 빌드 후 지도 컨테이너 높이가 0이면 보이지 않습니다. 부모 컨테이너 높이, CSS 임베딩 여부를 확인합니다.

- 번들 크기 관리: 필요 라이브러리만 로드하고, 지도 컴포넌트를 코드 스플리팅합니다.

실무에서는 요구 기능과 비용, 데이터 품질에 따라 선택합니다. Google은 검색/길찾기/스트리트뷰 등 상용 기능이 강점이며, Leaflet은 커스텀 UI와 비용 절감에 탁월합니다. 위 예제를 기반으로 프로젝트에 맞는 방식으로 통합하면 됩니다.