프론트엔드

라이브러리 없이 GeoJSON으로 지도 그리기

2026. 06. 19. 금요일 오후 9시 33분

라이브러리 없이 GeoJSON으로 지도 그리기

지도를 화면에 띄우는 일은 보통 한 줄이면 끝납니다. leaflet이나 d3를 불러오고, GeoJSON을 던져주면 알아서 예쁜 지도가 나오죠. 저도 한동안은 그렇게 썼습니다. 그런데 어느 날 문득, 그 한 줄 안에서 도대체 무슨 일이 벌어지는지 궁금해졌습니다.

경도와 위도라는 숫자 덩어리가 어떻게 모니터의 픽셀 좌표로 바뀌는 걸까요? 한반도가 옆으로 눕거나 찌그러지지 않고 제 모양을 유지하는 비결은 뭘까요? 이 질문에 답하기 위해, 라이브러리를 전부 걷어내고 순수 JavaScript와 SVG만으로 대한민국 시도 지도를 직접 그려보기로 했습니다.

결과부터 보여드리면 이렇게 나옵니다.

순수 JavaScript와 SVG로 그린 대한민국 시도 경계 지도"순수 JavaScript와 SVG로 그린 대한민국 시도 경계 지도"

이 지도에는 라이브러리가 한 줄도 들어가지 않았습니다. 마우스를 올리면 지역이 강조되는 상호작용까지 직접 구현합니다. 이 과정을 따라오면, 평소 한 줄로 쓰던 지도 라이브러리가 내부에서 무슨 일을 대신 해주는지 손에 잡히게 됩니다.

큰 흐름은 네 단계입니다.

  1. 데이터가 차지하는 범위 구하기(bounding box)
  2. 경위도를 화면 좌표로 옮기기(투영)
  3. 도형을 SVG로 그리기
  4. 마우스 상호작용 붙이기

이 지도를 어떻게 만들었는지 차근차근 풀어보겠습니다.


GeoJSON이란 무엇인가

GeoJSON은 지리 데이터를 표현하기 위한 JSON 포맷입니다. 이름 그대로 그냥 JSON이라, 별도의 파서 없이 JSON.parse만으로 읽힙니다. 가장 바깥은 보통 FeatureCollection이고, 그 안에 여러 개의 Feature가 들어 있습니다. 시도 지도라면 서울, 부산, 경기도 같은 각 지역이 하나의 Feature가 됩니다.

Feature 하나의 구조를 들여다보면 이렇게 생겼습니다.

1{
2  "type": "Feature",
3  "properties": { "name": "서울특별시", "name_eng": "Seoul", "code": "11" },
4  "geometry": {
5    "type": "Polygon",
6    "coordinates": [
7      [
8        [127.022, 37.699],
9        [127.025, 37.699],
10        [127.026, 37.700]
11      ]
12    ]
13  }
14}
15
  • properties에는 지역명, 코드 같은 부가 정보가 들어갑니다. 화면에 라벨을 띄우거나 hover 이벤트에서 활용할 값들이죠.
  • geometry가 실제 도형 데이터입니다. typecoordinates로 이루어집니다.
  • 좌표는 [경도(longitude), 위도(latitude)] 순서입니다. 위도가 먼저가 아닙니다. 위도-경도 순으로 익숙한 분들이 가장 많이 실수하는 지점입니다.

Polygon과 MultiPolygon

지도를 그리려면 두 가지 geometry 타입만 알면 됩니다.

Polygon은 좌표 링(ring)의 배열입니다. 첫 번째 링은 바깥 경계선이고, 그 뒤에 오는 링들은 도형 안의 구멍(예: 호수처럼 비어 있는 영역, 혹은 광역시가 도(道) 안에 박혀 있어 도 폴리곤에서 그 부분을 빼낸 도넛 형태)을 뜻합니다. 그래서 좌표가 [[[...]]]처럼 세 겹으로 중첩됩니다.

MultiPolygon은 Polygon들의 배열입니다. 섬이 여러 개라 한 덩어리로 표현할 수 없는 지역이 여기 해당합니다. 제주도나 경상남도처럼 부속 섬이 많은 곳이 MultiPolygon이 되죠. 중첩이 한 단계 더 깊어 [[[[...]]]]가 됩니다.

실제로 제가 사용한 데이터를 세어보니 17개 시도 중 7개가 Polygon, 10개가 MultiPolygon이었습니다. 두 타입을 모두 다뤄야 한다는 뜻입니다. 두 타입이 좌표 깊이가 다르다는 점만 통일해주면, 나머지 처리는 똑같이 할 수 있습니다.

1// Polygon과 MultiPolygon을 "폴리곤들의 배열"이라는 같은 형태로 통일한다.
2function extractPolygons(geometry) {
3  if (geometry.type === 'Polygon') {
4    // Polygon 하나를 길이 1짜리 배열로 감싸 형태를 맞춘다.
5    return [geometry.coordinates];
6  }
7  if (geometry.type === 'MultiPolygon') {
8    // MultiPolygon은 이미 폴리곤들의 배열이라 그대로 반환한다.
9    return geometry.coordinates;
10  }
11  // 점/선 등 이 데모에서 다루지 않는 타입은 무시한다.
12  return [];
13}
14

이렇게 한 번 감싸두면, 이후 코드는 geometry 타입을 신경 쓰지 않고 "폴리곤 → 링 → 좌표"라는 일관된 중첩 루프로 처리할 수 있습니다. 여러 지역(Feature)을 한꺼번에 다룰 땐 여기에 Feature 순회가 하나 더 붙어 "feature(지역) → polygon(덩어리) → ring(경계선) → [경도, 위도] 점" 네 단계가 됩니다.


1단계: 좌표의 영역을 구한다 (bounding box)

데이터에 들어 있는 좌표는 경도 124132, 위도 3338 사이의 실수들입니다. 이걸 그대로 화면에 찍으면 좌상단 구석에 점 하나로 뭉쳐버립니다. 화면에 맞추려면 먼저 "이 데이터가 차지하는 지리적 범위"가 얼마인지 알아야 합니다. 경도와 위도 각각의 최소·최대값, 즉 bounding box를 구하는 일입니다.

특별한 기교는 없습니다. 모든 좌표를 한 번 훑으면서 최솟값과 최댓값을 갱신하면 됩니다.

1// FeatureCollection 전체를 순회해 경도/위도의 최소·최대값을 구한다.
2function computeBounds(features) {
3  let minLng = Infinity;
4  let minLat = Infinity;
5  let maxLng = -Infinity;
6  let maxLat = -Infinity;
7
8  for (const feature of features) {
9    for (const polygon of extractPolygons(feature.geometry)) {
10      for (const ring of polygon) {
11        for (const [lng, lat] of ring) {
12          // 좌표가 [경도, 위도] 순서임을 기억하자.
13          if (lng < minLng) minLng = lng;
14          if (lng > maxLng) maxLng = lng;
15          if (lat < minLat) minLat = lat;
16          if (lat > maxLat) maxLat = lat;
17        }
18      }
19    }
20  }
21  return { minLng, minLat, maxLng, maxLat };
22}
23

제가 쓴 데이터로 돌려보니 경도는 124.61 ~ 131.87, 위도는 33.11 ~ 38.61이 나왔습니다. 제주도 남쪽 끝에서 강원도 북쪽 끝, 그리고 동쪽 끝 울릉도까지를 모두 감싸는 사각형이죠. 이 사각형을 화면 크기에 맞게 펼쳐 넣는 것이 다음 단계입니다.


2단계: 경위도를 화면 좌표로 투영한다

여기가 이 글의 핵심이자, 제가 가장 오래 헤맨 부분입니다.

지구는 둥근 공이고 화면은 평평합니다. 둥근 표면의 좌표를 평면에 펼치는 작업을 **투영(projection)**이라고 부릅니다. 귤껍질을 칼집 내서 책상에 납작하게 누르는 장면을 떠올리면 비슷합니다. 어떻게 누르느냐에 따라 모양이 조금씩 달라지죠.

가장 단순한 투영은 경도를 x로, 위도를 y로 그냥 갖다 쓰는 방식(equirectangular)입니다. 적도 근처에서는 그럭저럭 봐줄 만하지만, 우리나라처럼 위도가 높은 지역은 가로로 길쭉하게 늘어나 보입니다. 그래서 웹 지도 타일이 쓰는 구면 메르카토르(spherical Mercator) 와 같은 공식을 적용했습니다. 위도가 높아질수록 세로 간격을 점점 벌려주는 방식이죠. 정확히는 실제 Web Mercator 좌표계(EPSG:3857)가 갖는 지구 반지름·타일 스케일 상수는 생략하고 공식의 모양만 가져온 형태라, 타일 지도와 좌표가 호환되진 않습니다. 공식은 다음과 같습니다.

1// 경도는 라디안으로 변환해 x 값으로 쓴다.
2const lngToX = (lng) => (lng * Math.PI) / 180;
3
4// 위도를 Web Mercator의 y 값으로 변환한다(고위도일수록 간격이 벌어진다).
5const latToY = (lat) => {
6  const rad = (lat * Math.PI) / 180;
7  return Math.log(Math.tan(Math.PI / 4 + rad / 2));
8};
9

이 두 함수는 뒤에 나오는 createProjector에서도 호출하므로, 같은 모듈 상단에 두거나 createProjector 안에 함께 넣어두세요. 그래야 투영 코드만 떼어 써도 lngToX is not defined 같은 오류가 나지 않습니다.

참고로 latToYMath.log(Math.tan(Math.PI / 4 + rad / 2))는 Web Mercator의 표준 정의 그대로입니다(Math.PI / 4는 45도의 라디안 값). 유도 과정은 이 글의 범위를 넘으니 결과 식만 그대로 쓰는데, 핵심은 위도가 커질수록 log·tan 조합이 y를 점점 빠르게 키운다는 점입니다. 당장 이해되지 않아도 넘어가도 됩니다.

이 두 함수 덕분에 한 번 호되게 당했습니다. 처음에 저는 x는 경도를 그대로(degree) 쓰고, y만 위의 Mercator 공식으로 계산했습니다. 그랬더니 지도가 가로로 길게 늘어진 한 줄로 찌부러져 버리더군요. 원인은 두 축의 스케일이 어긋난 것이었습니다. 가로축은 큰 숫자 그대로인데 세로축만 아주 작은 값으로 계산돼, 세로 방향이 거의 0으로 짓눌린 것이죠.

숫자로 보면 이렇습니다. x는 degree 그대로라 범위가 약 7.3(경도 폭 131.87−124.61≈7.3도)인데, y는 Mercator의 log 변환을 거치며 약 0.12라는 무차원 값(이 위도 범위를 latToY에 통과시킨 폭)이 됩니다. 같은 scale을 곱하니 세로가 가로보다 60배쯤 작게 그려진 겁니다. 여기서 헷갈리기 쉬운데, y가 작은 진짜 원인은 "라디안이라서"가 아니라 Mercator log 변환을 거친 값이기 때문입니다. 교훈은 단순합니다. x와 y는 반드시 같은 공간(메르카토르)에서 계산해 스케일을 맞춰야 합니다. 그래서 경도도 같은 공간으로 옮기는 lngToX를 따로 둔 것이죠. 이걸 깨닫고 나니 지도가 단번에 제 모양을 찾았습니다.

투영 함수와 bounding box가 준비되면, 이제 화면 크기에 맞게 스케일과 위치를 계산합니다.

1function createProjector(bounds, width, height, padding) {
2  const { minLng, minLat, maxLng, maxLat } = bounds;
3
4  // bounding box의 모서리를 Mercator 공간으로 옮긴다.
5  const xMin = lngToX(minLng);
6  const yMin = latToY(minLat);
7  const yMax = latToY(maxLat);
8
9  const geoWidth = lngToX(maxLng) - lngToX(minLng);
10  const geoHeight = yMax - yMin; // max - min 순서라 항상 양수
11
12  // 가로/세로 중 더 빡빡한 쪽에 맞춰 스케일을 정해 비율이 찌그러지지 않게 한다.
13  const usableW = width - padding * 2;
14  const usableH = height - padding * 2;
15  const scale = Math.min(usableW / geoWidth, usableH / geoHeight);
16
17  // 남는 여백을 반씩 나눠 지도를 가운데로 모은다.
18  const offsetX = padding + (usableW - geoWidth * scale) / 2;
19  const offsetY = padding + (usableH - geoHeight * scale) / 2;
20
21  return ([lng, lat]) => {
22    const x = (lngToX(lng) - xMin) * scale + offsetX;
23    // Mercator y는 위로 갈수록 커지지만 화면 y는 아래로 커진다. 그래서 yMax 기준으로 뒤집는다.
24    const y = (yMax - latToY(lat)) * scale + offsetY;
25    return [x, y];
26  };
27}
28

코드에서 짚어둘 포인트가 두 가지 있습니다.

  • scale을 가로·세로 비율 중 더 작은 값으로 잡았습니다. 한쪽에만 맞추면 다른 축이 넘쳐 잘리거나 비율이 망가집니다. 작은 쪽에 맞춰야 도형 전체가 안에 들어오면서 가로세로 비율도 보존됩니다.
  • 화면 좌표계는 위쪽이 0이고 아래로 갈수록 y가 커집니다. 반대로 위도는 위로 갈수록 큽니다. 그래서 yMax - latToY(lat)로 위아래를 뒤집어줘야 북쪽이 화면 위로 갑니다. 이 부호 하나 빠뜨리면 지도가 거꾸로 뒤집힙니다.

3단계: 폴리곤을 SVG path로 변환한다

투영 함수까지 나오면 가장 어려운 산은 넘은 셈입니다. 이제 각 지역의 좌표 링을 SVG pathd 속성 문자열로 바꾸기만 하면 됩니다.

path의 문법은 의외로 간단합니다. 첫 점은 M(move to)으로 펜을 옮기고, 이후 점들은 L(line to)로 선을 잇고, 마지막에 Z로 도형을 닫습니다.

1// 좌표 링 하나를 SVG path의 d 속성 문자열로 만든다.
2function ringToPath(ring, project) {
3  return ring
4    .map(([lng, lat], i) => {
5      const [x, y] = project([lng, lat]);
6      // 첫 점은 M, 나머지는 L. 소수점 2자리로 줄여 path를 가볍게.
7      return `${i === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`;
8    })
9    .join(' ')
10    .concat(' Z');
11}
12

MultiPolygon은 서브 경로가 여러 개라는 점만 다릅니다. 각 링을 path 문자열로 만든 뒤 공백으로 이어 붙이면, 하나의 <path> 안에 여러 도형이 함께 그려집니다. 각 링은 M ... Z로 끝나므로 이어 붙이면 M ... Z M ... Z 형태가 되는데, SVG는 M이 나올 때마다 새 서브패스(도형)로 인식합니다.

여기엔 한 가지 전제가 숨어 있습니다. 구멍(내부 링)이 제대로 뚫리려면 구멍 링이 바깥 링과 반대 방향으로 감겨(winding) 있어야 합니다. 바깥 링을 반시계로 그렸다면 구멍 링은 시계처럼 서로 반대로 그려야 SVG가 안쪽을 구멍으로 인식하고, 둘이 같은 방향이면 구멍이 메워져 통째로 칠해집니다.

이는 SVG의 기본 fill-rulenonzero가 감김 방향으로 안팎을 판정하기 때문입니다. GeoJSON 표준(RFC 7946)도 바깥 링과 구멍 링을 반대 방향으로 감도록 정해두고 있고요. 다만 핵심은 "RFC를 따라서"가 아니라 바깥과 구멍이 서로 반대로만 감겨 있으면 구멍이 뚫린다는 점입니다(실제 방향은 데이터마다 다를 수 있습니다). 방향을 신경 쓰고 싶지 않다면 <path>fill-rule="evenodd"를 주면 됩니다. 감김 방향과 상관없이 교차 횟수로 안팎을 판정하니 더 안전하죠. 참고로 이 글의 시도 데이터에는 호수 같은 구멍이 거의 없어 어느 쪽이든 결과는 같습니다.

1function featureToPath(feature, project) {
2  const d = extractPolygons(feature.geometry)
3    .flatMap((polygon) => polygon.map((ring) => ringToPath(ring, project)))
4    .join(' ');
5
6  const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
7  path.setAttribute('d', d);
8  path.setAttribute('class', 'region');
9  // 지역명을 data 속성에 담아 hover/클릭에서 꺼내 쓴다.
10  path.dataset.name = feature.properties.name ?? '';
11  return path;
12}
13

여기서 <path>를 만들 때 createElement가 아니라 createElementNS를 쓴 점에 주의하세요. SVG 요소는 일반 HTML과 달리 네임스페이스를 지정해 만들어야 합니다. document.createElement('path')로 만들면 화면에 아무것도 그려지지 않는 흔한 함정에 빠집니다.

그리고 properties.namedata-name 속성에 박아둔 게 뒤에서 빛을 발합니다. 도형과 지역명을 한 몸으로 묶어두면, 이벤트에서 별도 조회 없이 곧장 어느 지역인지 알 수 있거든요.

마지막으로 모든 Feature를 순회해 <svg>에 붙이면 지도가 완성됩니다.

1export function renderMap(geojson, { width = 720, height = 820, padding = 28 } = {}) {
2  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
3  svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
4
5  const bounds = computeBounds(geojson.features);
6  const project = createProjector(bounds, width, height, padding);
7
8  for (const feature of geojson.features) {
9    svg.appendChild(featureToPath(feature, project));
10  }
11  return svg;
12}
13

생각보다 간단하죠? bounding box → 투영 → path 변환, 이 세 단계가 전부입니다.

이제 GeoJSON을 불러와 화면에 붙이기만 하면 됩니다. fetch.json을 읽어 renderMap에 넘기고, 반환된 <svg>를 문서에 끼워 넣습니다.

1const res = await fetch('/korea-sido.json'); // GeoJSON 파일을 불러온다.
2const geojson = await res.json(); // 그냥 JSON이라 별도 파서 없이 파싱된다.
3
4const svg = renderMap(geojson); // 위에서 만든 함수로 SVG를 만든다.
5document.querySelector('#app').appendChild(svg); // 문서에 붙이면 화면에 나타난다.
6

한 가지 실행 전제가 있습니다. 위 await fetch(...)는 최상위 await<script type="module">에서만 동작하고, index.htmlfile://로 그냥 열면 fetch가 CORS로 막힙니다. vitenpx serve 같은 로컬 서버로 띄워야 합니다.


4단계: hover로 지역 강조하기

지도가 그려졌으니 약간의 상호작용을 더해봅니다. 마우스를 올린 지역의 색을 바꾸고, 이름을 화면에 띄우는 정도로 충분합니다.

지역이 17개뿐이라 path마다 리스너를 다는 것도 가능하지만, 저는 SVG 하나에만 리스너를 달고 이벤트 위임으로 처리했습니다. 이벤트 위임은 부모 하나에 리스너를 달아두고, 이벤트가 자식에서 부모로 버블링되는 성질을 이용해 e.target으로 실제 대상을 가려내는 기법입니다. path가 수백 개로 늘어나도 리스너는 하나면 되니까요.

한 가지 주의할 점은 mouseenter/mouseleave가 아니라 mouseover/mouseout을 써야 한다는 것입니다. mouseenter/mouseleave는 버블링되지 않아 위임이 동작하지 않습니다.

1svg.addEventListener('mouseover', (e) => {
2  const target = e.target;
3  if (!(target instanceof SVGPathElement)) return;
4  // hover한 지역에 강조 클래스를 붙여 채움색을 바꾼다.
5  target.classList.add('is-active');
6  label.textContent = target.dataset.name ?? '';
7});
8
9svg.addEventListener('mouseout', (e) => {
10  const target = e.target;
11  if (!(target instanceof SVGPathElement)) return;
12  target.classList.remove('is-active');
13});
14

e.target instanceof SVGPathElement 가드는 SVG 자체나 path가 아닌 다른 요소 위로 마우스가 지나갈 때를 걸러내기 위한 것입니다. 위임이다 보니 path가 아닌 곳에서도 이벤트가 잡히는데, 그런 경우엔 그냥 무시하면 됩니다.

색을 바꾸는 일은 CSS에 맡깁니다. JavaScript는 클래스만 토글하고, 실제 스타일은 .region.is-active로 분리해두면 코드가 깔끔해집니다.

1.region {
2  fill: #dbeafe;
3  stroke: #60a5fa;
4  stroke-width: 0.8;
5  transition: fill 0.12s ease;
6  cursor: pointer;
7}
8.region.is-active {
9  fill: #f59e0b; /* hover 시 주황색으로 강조 */
10}
11

data-name에 지역명을 미리 넣어둔 덕분에, 이벤트 핸들러는 target.dataset.name만 읽으면 됩니다. 결과는 이렇습니다.

경상북도에 마우스를 올려 주황색으로 강조된 지도. 부속 섬인 울릉도까지 함께 강조되고 하단에 지역명이 표시된다"경상북도에 마우스를 올려 주황색으로 강조된 지도. 부속 섬인 울릉도까지 함께 강조되고 하단에 지역명이 표시된다"

경상북도에 마우스를 올리니 본토뿐 아니라 동쪽의 울릉도까지 함께 강조됩니다. 두 도형이 하나의 MultiPolygon으로 묶여 같은 <path>에 들어 있기 때문이죠. 앞서 Polygon과 MultiPolygon을 같은 형태로 통일해둔 처리가 여기서 자연스럽게 맞아떨어집니다.


정리

라이브러리 없이 GeoJSON을 지도로 그리는 과정을 다시 짚어보면 이렇습니다.

  • GeoJSON은 그냥 JSON이고, 좌표는 [경도, 위도] 순서다. Polygon과 MultiPolygon만 알면 지도 대부분을 그릴 수 있다.
  • 모든 좌표를 훑어 bounding box를 구하고, 그걸 화면 크기에 맞춰 스케일·정렬한다.
  • 투영의 핵심은 x와 y를 같은 공간(메르카토르)에서 계산해 스케일을 맞추는 것. 스케일이 어긋나면 지도가 찌부러진다.
  • 화면 y축은 아래로 커지므로 위도를 뒤집어야 북쪽이 위로 간다.
  • 각 도형을 M/L/Z 문법의 SVG path로 바꾸고, data 속성과 이벤트 위임으로 상호작용을 붙인다.

마치며

막상 직접 만들어 보니, 평소 한 줄로 쓰던 지도 라이브러리가 내부에서 무얼 해주고 있었는지 손에 잡히는 느낌이었습니다. 좌표를 픽셀로 옮기는 투영, 비율을 지키는 스케일링, 위아래를 뒤집는 부호 처리. 라이브러리가 이런 자잘하지만 틀리기 쉬운 일들을 대신 해주고 있었던 거죠.

물론 실무에서 진짜 지도가 필요하면 저도 leaflet이나 d3를 씁니다. 줌, 타일, 좌표계 변환까지 직접 만드는 건 비효율적이니까요. 다만 원리를 한 번 들여다보고 나니, 그 도구들이 던지는 옵션 이름이나 에러 메시지가 전보다 훨씬 잘 읽힙니다. 도구를 더 잘 쓰기 위해 바닥을 한 번 긁어보는 일은, 생각보다 자주 남는 장사인 것 같습니다. ☺️

혹시 직접 해보고 싶다면, 아래 저장소의 코드를 받아 돌려보거나 다른 나라·도시의 GeoJSON을 넣어 같은 과정을 시도해보길 권합니다. 데이터만 바꿔도 투영과 스케일링이 그대로 동작하는 걸 보는 재미가 쏠쏠합니다.

긴 글 읽어주셔서 감사합니다.


이 글의 예제 코드와 단순화한 GeoJSON 데이터는 저장소의 playground/geojson-viz에 있습니다. 지도 데이터 출처는 southkorea/southkorea-maps(통계청 2018년 시도 경계)이며, 원본(약 7.5MB)을 Douglas-Peucker 알고리즘으로 약 158KB까지 단순화해 사용했습니다.