블로그에 서울 맛집 지도를 D3로 그린 이유

블로그에 서울 맛집 지도를 D3로 그린 이유

2026년 2월 4일

블로그에 맛집 지도 페이지를 만들고 싶었습니다. 서울 지도 위에 내가 다녀온 맛집을 핀으로 찍고, 클릭하면 리뷰를 볼 수 있는 페이지를 원했습니다.

처음엔 Leaflet이나 Google Maps를 쓰면 금방 끝날 줄 알았습니다. 하지만 “블로그 디자인과 맞아야 한다”는 조건 하나가 모든 선택을 바꿔놓았습니다.

문제 상황

개인 블로그에 맛집 지도 페이지를 추가하려고 했다. 요구사항은 이랬다.

  1. 서울 25개 구의 경계가 보이는 지도를 렌더링한다.
  2. 맛집 위치에 핀을 찍고, 카테고리별로 필터링한다.
  3. 핀을 클릭하면 드로어가 열리면서 리뷰를 보여준다.

프로젝트는 Astro + React 조합이었다. 정적 사이트 위에 인터랙티브한 지도를 올려야 하는 상황.

지도 라이브러리를 고르는 게 첫 번째 결정이었다.

내가 처음 한 생각

가장 먼저 떠오른 건 Leaflet이었다. 타일 레이어 기반으로 줌·팬이 기본 내장되어 있고, 마커 추가도 간단하다. react-leaflet이라는 래퍼도 있으니 React 환경에서도 무리 없을 거라 생각했다.

Leaflet → 타일 기반 지도 → 마커 올리기 → 끝?

그런데 실제로 Leaflet을 적용해보려니 문제가 보였다.

  1. 타일 레이어 의존: Leaflet은 OpenStreetMap 같은 타일 서버에서 이미지를 받아와서 지도를 그린다. 내가 원한 건 서울 구 경계만 깔끔하게 보이는 미니멀한 지도였지, 도로·건물·지명이 가득한 일반 지도가 아니었다.
  2. 스타일 커스텀의 한계: 블로그는 CSS 커스텀 프로퍼티(var(--bg), var(--border), var(--selection))로 테마를 관리한다. Leaflet 타일은 외부 이미지이기 때문에 이 테마 시스템에 맞추기가 어렵다. 다크모드로 전환하면 지도만 밝은 채로 남는 상황이 생길 수 있다.
  3. 번들 사이즈: Leaflet CSS + JS + 타일 로딩. 정적 블로그 한 페이지를 위해 가져오기엔 무겁다고 느꼈다.

결국 내가 원하는 건 “지도”가 아니라 “서울 행정구역 시각화”에 가까웠다.

그러다 react-southkorea-d3map을 발견했다. D3로 대한민국 행정구역을 시각화한 데모인데, 타일 없이 SVG만으로 깔끔하게 지도를 그린 결과물이 예뻤다. “이 방향이면 블로그 톤에 맞출 수 있겠다”는 확신이 생겼고, D3 쪽으로 방향을 틀었다.

실제로 해 본 선택

D3.js + GeoJSON 조합을 선택했다. 서울 25개 구의 경계 데이터(GeoJSON)를 직접 SVG로 그리는 방식이다.

1단계: 프로젝션 설정

D3의 geoMercator로 서울 중심 좌표에 맞춰 프로젝션을 만들었다.

const projection = d3
  .geoMercator()
  .center([127.0, 37.55])
  .scale(width * 70)
  .translate([width / 2, height / 2]);

const path = d3.geoPath().projection(projection);

이 프로젝션이 [경도, 위도] 좌표를 SVG의 [x, y] 픽셀 좌표로 변환해준다. 타일 서버 없이, 61KB짜리 GeoJSON 파일 하나로 서울 전체를 그릴 수 있었다.

2단계: 구 경계 렌더링 + 테마 연동

SVG path로 그리니까 CSS 커스텀 프로퍼티를 그대로 쓸 수 있었다.

g.selectAll("path")
  .data(geoData.features)
  .enter()
  .append("path")
  .attr("d", path)
  .attr("fill", "var(--selection)")
  .attr("stroke", "var(--border)")
  .on("mouseover", function () {
    d3.select(this).attr("fill", "var(--code-bg)");
  })
  .on("mouseout", function () {
    d3.select(this).attr("fill", "var(--selection)");
  });

Leaflet이었다면 불가능했을 부분이다.

3단계: 맛집 핀 배치

맛집 좌표도 같은 프로젝션으로 변환해서 SVG 위에 올렸다.

const pins = g
  .selectAll(".restaurant-pin")
  .data(filteredRestaurants)
  .enter()
  .append("g")
  .attr("class", "restaurant-pin")
  .attr("transform", (d) => `translate(${projection(d.coordinates)})`)
  .style("cursor", "pointer")
  .on("click", (_, d) => onSelectRestaurant(d));

여기서 첫 번째 문제가 터졌다. 줌을 구현하자 핀이 지도와 함께 확대되면서 화면을 뒤덮었다. D3의 줌은 <g> 태그 전체에 transform을 걸기 때문에, 핀도 같이 커지는 게 당연했다.

해결은 역스케일링이었다. 줌 배율의 역수로 핀 크기를 보정했다.

const zoom = d3
  .zoom<SVGSVGElement, unknown>()
  .scaleExtent([1, 8])
  .on("zoom", (event) => {
    g.attr("transform", event.transform);
    // 핀은 줌 배율의 역수로 스케일 → 항상 같은 크기 유지
    g.selectAll(".restaurant-pin").attr(
      "transform",
      (d: any) =>
        `translate(${projection(d.coordinates)}) scale(${1 / event.transform.k})`,
    );
  });

2배 줌이면 핀은 0.5배로 줄어들어 시각적으로 동일한 크기를 유지한다.

4단계: React 안에서 D3 다루기

가장 고민한 부분이다. React는 선언형이고, D3는 명령형이다. 둘 다 DOM을 직접 건드리려 하기 때문에 충돌이 생길 수 있다.

선택한 패턴은 “React는 컨테이너만, SVG 내부는 D3가 전담” 방식이다.

const svgRef = useRef<SVGSVGElement>(null);

useEffect(() => {
  if (!svgRef.current) return;
  const svg = d3.select(svgRef.current);
  svg.selectAll("*").remove(); // 기존 내용 제거 후 다시 그리기
  // ... D3로 SVG 내부 구성
}, [geoData, filteredRestaurants, dimensions]);

return <svg ref={svgRef} width={dimensions.width} height={dimensions.height} />;

React의 가상 DOM과 D3의 실제 DOM 조작이 같은 영역을 건드리면 안 되니까, <svg> 엘리먼트만 React가 관리하고, 그 안의 <g>, <path>, <circle> 등은 전부 D3가 useEffect 안에서 명령형으로 그린다. 의존성 배열이 바뀌면 selectAll("*").remove()로 싹 지우고 다시 그린다.

깔끔한 방법은 아니다. 하지만 D3의 enter-update-exit 패턴을 React 라이프사이클과 억지로 섞는 것보다는, 영역을 명확히 분리하는 게 덜 복잡했다.

결과

1

성공이다. 의도한 대로 동작한다.

  • 서울 구 경계가 블로그 테마에 맞춰 렌더링된다.
  • 카테고리 필터를 누르면 핀이 순차적으로 페이드인되면서 나타난다.
  • 줌·팬이 동작하고, 핀 크기는 줌 레벨과 무관하게 일정하다.
  • 핀 클릭 시 vaul 라이브러리로 만든 바텀시트 드로어가 열린다.

다만 트레이드오프는 있다.

  1. 코드량: SeoulMap 컴포넌트만 약 300줄이다. Leaflet이었다면 마커 추가까지 50줄 안에 끝났을 것이다.
  2. selectAll("*").remove() 패턴: 상태가 바뀔 때마다 SVG를 전부 지우고 다시 그린다. 맛집이 수십 개 수준이라 성능 문제는 없지만, 데이터가 수백 개를 넘으면 D3의 enter-update-exit 패턴으로 전환해야 할 수 있다.
  3. 접근성: SVG 기반이라 스크린 리더 지원이 제한적이다. 각 구에 <title> 태그로 이름을 넣어두긴 했지만, 지도 자체의 접근성은 부족하다.

지금 다시 해본다면

  • D3 선택은 유지한다. “외부 타일 서버 없이, 블로그 테마와 완전히 통합된 지도”라는 요구사항에는 D3 + GeoJSON이 맞다.
  • selectAll("*").remove() 대신 D3의 join() 패턴을 적용해본다. 데이터가 바뀔 때 DOM을 통째로 갈아끼우는 대신, 변경된 부분만 업데이트하는 방식이다.
  • 핀 애니메이션을 CSS @keyframes로 분리한다. 현재는 D3의 .transition().delay()로 처리하고 있는데, CSS로 분리하면 D3 의존도를 줄이면서 GPU 가속도 활용할 수 있다.
  • 반응형 처리를 ResizeObserver로 교체한다. 현재는 window.addEventListener("resize")인데, 컨테이너 크기 변화를 더 정확하게 감지할 수 있다.

비고

D3.js와 지도 라이브러리의 차이

구분D3.js + GeoJSONLeaflet / Mapbox
렌더링SVG / Canvas 직접 제어타일 레이어 기반
데이터GeoJSON 파일을 직접 로드타일 서버에서 이미지 수신
스타일링CSS / SVG 속성으로 완전 커스텀 가능타일 스타일은 서버 의존
줌/팬직접 구현 (d3.zoom)내장
마커SVG 엘리먼트로 직접 배치내장 Marker API
적합한 경우특정 지역 시각화, 커스텀 디자인범용 지도, 경로 탐색, 스트리트뷰 연동

React에서 D3를 사용하는 세 가지 패턴

  1. D3 전담 (이 프로젝트): React는 <svg> 컨테이너만 관리하고, 내부는 D3가 useEffect에서 명령형으로 그린다. 가장 단순하지만, React의 선언형 장점을 포기한다.
  2. React 전담: D3는 계산(스케일, 프로젝션)만 담당하고, 실제 SVG 엘리먼트는 React JSX로 렌더링한다. 선언형이지만 D3의 transition, zoom 같은 기능을 쓰기 어렵다.
  3. 하이브리드: 정적 요소는 React, 인터랙션(줌, 드래그)은 D3가 담당한다. 가장 유연하지만 경계 설정이 어렵다.