0xFF

지도 API 없이 내부망에서 자체 지도 페이지 구축하기 본문

개발 및 프로젝트/Safecast (졸업작품)

지도 API 없이 내부망에서 자체 지도 페이지 구축하기

캡스락 2026. 7. 3. 20:58

인터넷 웹페이지에 지도 기능 삽입이 필요한 경우 어떻게 할까?

지도를 자체 구현하지 않는 한 구글맵이나 카카오 지도를 활용하면 매우 손쉽게 웹 프론트엔드에 지도를 띄울 수 있다.

하지만 내부망(인트라넷) 웹 페이지에 지도를 표시해야 한다면 어떻게 할까?

오픈소스 지도인 OpenStreetMap을 이용하는 것이 가장 쉽게 구현 가능한 방법이라고 생각되는데,

방법이 매우 간단해서 그 과정을 기록해 보고자 한다.

 

1. 지도 데이터 다운로드

OpenStreetMap(이하 OSM)은 오픈소스 지도 데이터베이스로, 나무위키처럼 누구나 도로와 건물등을 추가하여 프로젝트에 참여할 수 있다.

오픈소스이므로 이렇게 완성된 지도 파일 또한 OSM 공식 위키에서 배포되고 있다.

아무래도 전세계의 지리정보이다 보니 그 용량이 현재 시점에서 pbf 포맷 기준 87GB나 된다고 한다.

포맷은 XML과 PBF(protobuf)의 두 가지로 배포되는데, 확실히 바이너리 포맷이 용량 측면에서 효율적이라는 것을 알 수 있었다.

이미지 클릭시 south-korea-latest-non-military.osm.pbf 다운로드

이외에도 서드파티에서 전세계 지도가 필요없는 사람들을 위해 특정 지역만의 지도 파일을 배포하는 경우도 있다.

위 링크는 osm.kr에서 배포하는 국내 지도 데이터인데 용량이 265MB밖에 안되는 것을 확인할 수 있다.

다운로드한 osm.pbf 파일은 도로나 건물과 같은 지리적 요소들을 node와 way, 그리고 이들의 상관관계를 나타낸 relation이라는 3가지 protobuf message를 이용해 정의해 둔 것에 불과하다. 따라서 우리가 보는 지도 이미지를 얻기 위해서는 .osm.pbf를 .pmtiles라는 지도 데이터 형식으로 렌더링해야 한다.

2. Plenetiler로 .pmtiles 생성

Planetiler는 .osm.pbf 파일을 이용하여 .pmtiles 포맷의 벡터 지도 타일을 생성하는 툴이다.

사용법이 정말 간단한데 jar 다운로드 후 아래와 같은 명령어 하나면 변환이 완료된다.

java -jar planetiler.jar \
  --osm-path=./south-korea-latest-non-military.osm.pbf \
  --output=korea.pmtiles \
  --minzoom=5 \
  --download

여기서 --download 옵션은 지도를 렌더링하는 과정에 필요한 추가 데이터셋을 자동으로 다운로드 받아주는 옵션이다.

--minzoom 옵션은 최소 배율 설정인데, 필자는 5로 지정했다.

실행해보면 꽤 오랜 시간이 걸리는데, 대부분 데이터셋 다운로드 시간이고 변환 자체는 몇 초 걸리지 않았다.

작성자의 경우 10분 정도 걸린 것 같다.

다음으로 웹 페이지에서 아래 코드로 지도 표시 여부를 점검했다.

Protomaps를 이용해 벡터 pmtiles 지도를 출력하는 가장 간단한 예시이다.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
  <style>
    html, body, #map {
      height: 100%;
      margin: 0;
    }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="error"></div>

  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
  <script src="https://unpkg.com/protomaps-leaflet@5.1.0/dist/protomaps-leaflet.js"></script>
  <script>
    const L = window.L;
    const P = window.protomapsL;
    const params = new URLSearchParams(location.search);
    const pmtilesUrl = params.get("url") || "map.pmtiles";

    const colors = {
      background: "#eef2f5",
      water: "#9fc7e8",
      waterway: "#7fb3dc",
      green: "#cfe5c9",
      park: "#bfe3b8",
      landuse: "#e7e8df",
      building: "#d5d3cc",
      buildingStroke: "#bdbab1",
      roadMinor: "#ffffff",
      roadMajor: "#f6c47d",
      roadCasing: "#d6aa66",
      rail: "#8e939a",
      boundary: "#8f8aa2",
    };

    function width(z, stops) {
      if (z <= stops[0][0]) return stops[0][1];
      for (let i = 0; i < stops.length - 1; i++) {
        const [z0, v0] = stops[i];
        const [z1, v1] = stops[i + 1];
        if (z >= z0 && z <= z1) return v0 + ((v1 - v0) * (z - z0)) / (z1 - z0);
      }
      return stops[stops.length - 1][1];
    }

    function roadWidth(z, feature) {
      const kind = feature.props.class;
      if (["motorway", "trunk", "primary"].includes(kind)) {
        return width(z, [[5, 0.6], [10, 1.8], [14, 4], [18, 16]]);
      }
      if (["secondary", "tertiary"].includes(kind)) {
        return width(z, [[9, 0.5], [13, 2], [16, 5], [18, 10]]);
      }
      return width(z, [[12, 0.4], [15, 1.4], [17, 4], [18, 7]]);
    }

    function isRoad(feature) {
      return !["rail", "transit", "ferry", "path", "track", "footway", "pedestrian"].includes(feature.props.class);
    }

    const paintRules = [
      {
        dataLayer: "landcover",
        symbolizer: new P.PolygonSymbolizer({ fill: colors.green }),
        filter: (z, f) => ["wood", "forest", "grass", "grassland", "meadow", "park", "scrub"].includes(f.props.class) ||
          ["wood", "forest", "grass", "grassland", "meadow", "park", "scrub"].includes(f.props.subclass),
      },
      {
        dataLayer: "landuse",
        symbolizer: new P.PolygonSymbolizer({ fill: colors.landuse }),
      },
      {
        dataLayer: "park",
        symbolizer: new P.PolygonSymbolizer({ fill: colors.park, opacity: 0.75 }),
      },
      {
        dataLayer: "water",
        symbolizer: new P.PolygonSymbolizer({ fill: colors.water }),
      },
      {
        dataLayer: "waterway",
        symbolizer: new P.LineSymbolizer({
          color: colors.waterway,
          width: z => width(z, [[9, 0.5], [13, 1.4], [16, 3], [18, 5]]),
        }),
      },
      {
        dataLayer: "transportation",
        minzoom: 9,
        symbolizer: new P.LineSymbolizer({
          color: colors.rail,
          width: z => width(z, [[9, 0.5], [13, 1], [16, 2], [18, 3]]),
          dash: [3, 3],
          dashColor: colors.rail,
        }),
        filter: (z, f) => f.props.class === "rail",
      },
      {
        dataLayer: "transportation",
        minzoom: 6,
        symbolizer: new P.LineSymbolizer({
          color: colors.roadCasing,
          width: (z, f) => roadWidth(z, f) + 1.5,
          lineCap: "round",
          lineJoin: "round",
        }),
        filter: (z, f) => isRoad(f) && ["motorway", "trunk", "primary", "secondary", "tertiary"].includes(f.props.class),
      },
      {
        dataLayer: "transportation",
        minzoom: 6,
        symbolizer: new P.LineSymbolizer({
          color: (z, f) => ["motorway", "trunk", "primary", "secondary", "tertiary"].includes(f.props.class)
            ? colors.roadMajor
            : colors.roadMinor,
          width: roadWidth,
          lineCap: "round",
          lineJoin: "round",
        }),
        filter: (z, f) => isRoad(f),
      },
      {
        dataLayer: "building",
        minzoom: 13,
        symbolizer: new P.PolygonSymbolizer({
          fill: colors.building,
          stroke: colors.buildingStroke,
          width: 0.4,
        }),
      },
      {
        dataLayer: "boundary",
        minzoom: 4,
        symbolizer: new P.LineSymbolizer({
          color: colors.boundary,
          width: z => width(z, [[4, 0.5], [8, 1], [12, 1.8]]),
          dash: [4, 3],
          dashColor: colors.boundary,
        }),
      },
    ];

    function showError(message) {
      const error = document.getElementById("error");
      error.textContent = message;
    }

    if (!L || !P) {
      showError("leaflet.js or protomaps-leaflet.js was not loaded.");
    } else {
      const map = L.map("map", {
        center: [37.5665, 126.978],
        zoom: Number(params.get("zoom")) || 12,
        minZoom: 5,
        maxZoom: 18,
      });

      map.attributionControl.setPrefix(false);
      map.attributionControl.addAttribution("Map data &copy; OpenStreetMap contributors");
      L.control.scale({ imperial: false }).addTo(map);

      P.leafletLayer({
        url: pmtilesUrl,
        paintRules,
        backgroundColor: colors.background,
        maxDataZoom: 14,
      }).addTo(map);

      window.addEventListener("error", event => {
        if (String(event.message || "").toLowerCase().includes("pmtiles")) showError(event.message);
      });
      window.addEventListener("unhandledrejection", event => showError(String(event.reason || event)));
    }
  </script>
</body>
</html>

pmtiles 파일의 경로만 맞춰주면(html과 같은 디렉토리) 지도가 잘 표시되는 것을 볼 수 있다.