본문 바로가기

React

React 앱에서 지도 경로 탐색(Route Finding) 기능 추가하기

React 앱에 지도 경로 탐색 기능을 추가하면 위치 기반 서비스, 물류, 여행, 배달 등 다양한 도메인에서 사용자 경험을 크게 향상시킬 수 있습니다. 이 글에서는 비용 없이 빠르게 적용 가능한 OpenStreetMap 생태계와 OSRM을 사용해 react-leaflet 기반 경로 탐색을 구현하는 방법을 실무 중심으로 정리합니다.

1. 어떤 스택을 선택할까?

지도와 경로 API는 크게 세 가지 선택지가 있습니다. 무료로 빠르게 시작하려면 OpenStreetMap 타일과 OSRM 공개 라우팅 서버 조합을 추천합니다. 상업용 API의 안정성이나 트래픽 SLA가 필요하다면 Mapbox Directions API, Google Directions API, OpenRouteService를 고려합니다.

이번 글은 다음 조합을 사용합니다. React + react-leaflet + Leaflet 타일 + OSRM 공개 라우팅 서버. 주소 검색은 Nominatim을 사용합니다.

2. 설치와 기본 세팅

react-leaflet과 leaflet을 설치합니다. Leaflet CSS를 반드시 로드해야 아이콘이 정상 표시됩니다.

// 설치
npm install react-leaflet leaflet

// index.js 또는 App.jsx 상단에서 CSS 로드
import 'leaflet/dist/leaflet.css';

3. 기본 지도와 마커 렌더링

지도와 출발/도착 마커를 렌더링하는 컴포넌트를 작성합니다. 서울 좌표를 기본값으로 두고 마커를 드래그해서 위치를 조정할 수 있게 합니다.

// RouteFinder.jsx
import React, { useEffect, useRef, useState } from 'react';
import { MapContainer, TileLayer, Marker, Polyline } from 'react-leaflet';
import L from 'leaflet';

// Leaflet 기본 아이콘 경로 설정 (번들 환경에서 깨짐 방지)
const DefaultIcon = L.icon({
  iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
  iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
  shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});

export default function RouteFinder() {
  const [map, setMap] = useState(null);
  const [start, setStart] = useState({ lat: 37.5665, lng: 126.9780 }); // 서울 시청
  const [end, setEnd] = useState({ lat: 37.5512, lng: 126.9882 }); // 남산타워 근처
  const [route, setRoute] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const cacheRef = useRef(new Map());

  // OSRM을 이용해 경로 요청
  async function fetchRoute(s, e) {
    const key = `${s.lat.toFixed(5)},${s.lng.toFixed(5)}-${e.lat.toFixed(5)},${e.lng.toFixed(5)}`;
    if (cacheRef.current.has(key)) {
      setRoute(cacheRef.current.get(key));
      return;
    }
    setLoading(true);
    setError('');
    try {
      const url = `https://router.project-osrm.org/route/v1/driving/${s.lng},${s.lat};${e.lng},${e.lat}?overview=full&geometries=geojson`;
      const res = await fetch(url);
      if (!res.ok) throw new Error('OSRM 요청 실패');
      const data = await res.json();
      const coords = data.routes?.[0]?.geometry?.coordinates || [];
      if (!coords.length) throw new Error('경로를 찾을 수 없습니다');
      const latlngs = coords.map(([lng, lat]) => [lat, lng]);
      cacheRef.current.set(key, latlngs);
      setRoute(latlngs);
    } catch (err) {
      setError(err.message || '경로 요청 중 오류가 발생했습니다');
      setRoute(null);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    fetchRoute(start, end);
  }, [start, end]);

  useEffect(() => {
    if (map && route?.length) {
      const bounds = L.latLngBounds(route);
      map.fitBounds(bounds, { padding: [24, 24] });
    }
  }, [map, route]);

  return (
    
{error} {loading && 경로 계산 중...}
{ const p = e.target.getLatLng(); setStart({ lat: p.lat, lng: p.lng }); } }} /> { const p = e.target.getLatLng(); setEnd({ lat: p.lat, lng: p.lng }); } }} /> {route && ( )}
); } function AddressSearch({ onSetStart, onSetEnd }) { const [startQuery, setStartQuery] = useState(''); const [endQuery, setEndQuery] = useState(''); const [msg, setMsg] = useState(''); async function geocode(query) { const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`; const res = await fetch(url, { headers: { 'Accept': 'application/json' } }); if (!res.ok) throw new Error('지오코딩 요청 실패'); const data = await res.json(); if (!data.length) throw new Error('검색 결과가 없습니다'); const { lat, lon } = data[0]; return { lat: parseFloat(lat), lng: parseFloat(lon) }; } return (
setStartQuery(e.target.value)} style={{ flex: 1 }} />
setEndQuery(e.target.value)} style={{ flex: 1 }} />

{msg}

); }

4. 경로 요청 로직 이해하기

OSRM의 route API를 사용합니다. 좌표는 경도,위도 순서이며 geometries=geojson을 지정하면 GeoJSON 좌표 배열을 받을 수 있어 Leaflet Polyline에 바로 매핑 가능합니다. 응답을 lat,lng로 바꿔 그립니다. 마커 드래그 이벤트에서 좌표를 갱신하면 useEffect가 자동으로 경로를 다시 요청합니다.

5. 주소 검색과 내 위치 버튼

Nominatim은 간단한 주소 검색에 적합합니다. 다만 공개 서비스는 레이트 리밋이 있으니 생산 환경에서는 자체 캐시, 프록시, 상업 API로 전환을 고려합니다. 내 위치 버튼은 geolocation API로 출발지를 빠르게 설정하는 데 유용합니다.

6. 성능 최적화와 캐싱

짧은 거리 이동에도 라우팅 API를 매번 호출하면 비효율적입니다. 위 예제는 좌표 문자열을 키로 사용해 Map 캐시를 적용했습니다. 약간의 소수점 반올림으로 키 폭주를 줄였고, 동일 구간 이동에 즉시 재사용합니다. 추가로 다음을 고려합니다. API 호출 디바운싱, 최근 요청 취소(AbortController), 오류 재시도와 백오프, 폴리라인 simplify 적용.

7. 접근성(AEO) 체크포인트

실시간 상태 메시지에 aria-live를 적용해 화면읽기 사용자가 경로 계산 상태와 오류를 인지할 수 있게 합니다. 버튼과 입력에 의미 있는 aria-label을 지정합니다. 키보드만으로 출발지/도착지 검색과 경로 확인이 가능하도록 포커스 흐름을 단순화합니다. 색상 대비를 확보하고 경로 색상을 테마에 맞게 조정합니다.

8. URL 공유와 SEO 강화

경로를 공유하려면 좌표를 URL 쿼리로 동기화하면 됩니다. 페이지 진입 시 쿼리를 읽어 초기 출발/도착을 설정하고, 상태 변경 시 pushState로 URL을 갱신합니다. 검색엔진에 직접 노출되는 페이지라면 SSR 또는 프리렌더를 활용해 핵심 컨텐츠를 초기 HTML에 포함시키는 것이 좋습니다.

// URL 동기화 아이디어
useEffect(() => {
  const params = new URLSearchParams({
    s: `${start.lat},${start.lng}`,
    e: `${end.lat},${end.lng}`,
  });
  const url = `${window.location.pathname}?${params.toString()}`;
  window.history.replaceState(null, '', url);
}, [start, end]);

useEffect(() => {
  const sp = new URLSearchParams(window.location.search);
  const s = sp.get('s');
  const e = sp.get('e');
  if (s && e) {
    const [slat, slng] = s.split(',').map(Number);
    const [elat, elng] = e.split(',').map(Number);
    if (Number.isFinite(slat) && Number.isFinite(slng)) setStart({ lat: slat, lng: slng });
    if (Number.isFinite(elat) && Number.isFinite(elng)) setEnd({ lat: elat, lng: elng });
  }
}, []);

9. 운영 환경 주의사항

공개 OSRM 서버는 SLA가 없으며 일시적 장애가 발생할 수 있습니다. 상업 API 또는 자체 OSRM 서버 운영을 고려하세요. Nominatim의 공개 인스턴스는 강한 레이트 리밋 정책을 가지고 있어 서버 사이드 캐시, Referer 헤더 설정, 프록시를 통해 요청을 합리화하세요. 타일 서버도 트래픽 정책을 준수해야 하며, 대규모 트래픽은 상업 타일로 전환하는 것이 안전합니다.

10. 빠른 체크리스트

마커 드래그 시 경로 재계산이 되는지 확인합니다. 동일 구간 재요청 시 캐시가 동작하는지 확인합니다. 주소 검색 실패, 타임아웃, 네트워크 오류를 사용자에게 알리는지 확인합니다. 모바일에서 geolocation 권한 흐름이 자연스러운지 확인합니다. 경로와 마커 색상 대비가 충분한지 확인합니다. URL 공유가 정상 동작하는지 확인합니다.

이대로 구현하면 React 앱에 실용적인 경로 탐색 기능을 빠르게 추가할 수 있습니다. 서비스 규모가 커지면 상업 API, SSR, 캐시/큐레이팅 전략을 더해 성능과 안정성을 강화하세요.