블로그에 서울 맛집 지도를 D3로 그린 이유
블로그에 맛집 지도 페이지를 만들고 싶었습니다. 서울 지도 위에 내가 다녀온 맛집을 핀으로 찍고, 클릭하면 리뷰를 볼 수 있는 페이지를 원했습니다.
처음엔 Leaflet이나 Google Maps를 쓰면 금방 끝날 줄 알았습니다. 하지만 “블로그 디자인과 맞아야 한다”는 조건 하나가 모든 선택을 바꿔놓았습니다.
문제 상황
개인 블로그에 맛집 지도 페이지를 추가하려고 했다. 요구사항은 이랬다.
- 서울 25개 구의 경계가 보이는 지도를 렌더링한다.
- 맛집 위치에 핀을 찍고, 카테고리별로 필터링한다.
- 핀을 클릭하면 드로어가 열리면서 리뷰를 보여준다.
프로젝트는 Astro + React 조합이었다. 정적 사이트 위에 인터랙티브한 지도를 올려야 하는 상황.
지도 라이브러리를 고르는 게 첫 번째 결정이었다.
내가 처음 한 생각
가장 먼저 떠오른 건 Leaflet이었다. 타일 레이어 기반으로 줌·팬이 기본 내장되어 있고, 마커 추가도 간단하다. react-leaflet이라는 래퍼도 있으니 React 환경에서도 무리 없을 거라 생각했다.
Leaflet → 타일 기반 지도 → 마커 올리기 → 끝?
그런데 실제로 Leaflet을 적용해보려니 문제가 보였다.
- 타일 레이어 의존: Leaflet은 OpenStreetMap 같은 타일 서버에서 이미지를 받아와서 지도를 그린다. 내가 원한 건 서울 구 경계만 깔끔하게 보이는 미니멀한 지도였지, 도로·건물·지명이 가득한 일반 지도가 아니었다.
- 스타일 커스텀의 한계: 블로그는 CSS 커스텀 프로퍼티(
var(--bg),var(--border),var(--selection))로 테마를 관리한다. Leaflet 타일은 외부 이미지이기 때문에 이 테마 시스템에 맞추기가 어렵다. 다크모드로 전환하면 지도만 밝은 채로 남는 상황이 생길 수 있다. - 번들 사이즈: 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 라이프사이클과 억지로
섞는 것보다는, 영역을 명확히 분리하는 게 덜 복잡했다.
결과

성공이다. 의도한 대로 동작한다.
- 서울 구 경계가 블로그 테마에 맞춰 렌더링된다.
- 카테고리 필터를 누르면 핀이 순차적으로 페이드인되면서 나타난다.
- 줌·팬이 동작하고, 핀 크기는 줌 레벨과 무관하게 일정하다.
- 핀 클릭 시 vaul 라이브러리로 만든 바텀시트 드로어가 열린다.
다만 트레이드오프는 있다.
- 코드량: SeoulMap 컴포넌트만 약 300줄이다. Leaflet이었다면 마커 추가까지 50줄 안에 끝났을 것이다.
selectAll("*").remove()패턴: 상태가 바뀔 때마다 SVG를 전부 지우고 다시 그린다. 맛집이 수십 개 수준이라 성능 문제는 없지만, 데이터가 수백 개를 넘으면 D3의enter-update-exit패턴으로 전환해야 할 수 있다.- 접근성: 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 + GeoJSON | Leaflet / Mapbox |
|---|---|---|
| 렌더링 | SVG / Canvas 직접 제어 | 타일 레이어 기반 |
| 데이터 | GeoJSON 파일을 직접 로드 | 타일 서버에서 이미지 수신 |
| 스타일링 | CSS / SVG 속성으로 완전 커스텀 가능 | 타일 스타일은 서버 의존 |
| 줌/팬 | 직접 구현 (d3.zoom) | 내장 |
| 마커 | SVG 엘리먼트로 직접 배치 | 내장 Marker API |
| 적합한 경우 | 특정 지역 시각화, 커스텀 디자인 | 범용 지도, 경로 탐색, 스트리트뷰 연동 |
React에서 D3를 사용하는 세 가지 패턴
- D3 전담 (이 프로젝트): React는
<svg>컨테이너만 관리하고, 내부는 D3가useEffect에서 명령형으로 그린다. 가장 단순하지만, React의 선언형 장점을 포기한다. - React 전담: D3는 계산(스케일, 프로젝션)만 담당하고, 실제 SVG 엘리먼트는 React JSX로 렌더링한다. 선언형이지만 D3의 transition, zoom 같은 기능을 쓰기 어렵다.
- 하이브리드: 정적 요소는 React, 인터랙션(줌, 드래그)은 D3가 담당한다. 가장 유연하지만 경계 설정이 어렵다.