0xFF
지도 API 없이 내부망에서 자체 지도 페이지 구축하기 본문
인터넷 웹페이지에 지도 기능 삽입이 필요한 경우 어떻게 할까?
지도를 자체 구현하지 않는 한 구글맵이나 카카오 지도를 활용하면 매우 손쉽게 웹 프론트엔드에 지도를 띄울 수 있다.
하지만 내부망(인트라넷) 웹 페이지에 지도를 표시해야 한다면 어떻게 할까?
오픈소스 지도인 OpenStreetMap을 이용하는 것이 가장 쉽게 구현 가능한 방법이라고 생각되는데,
방법이 매우 간단해서 그 과정을 기록해 보고자 한다.
1. 지도 데이터 다운로드
OpenStreetMap(이하 OSM)은 오픈소스 지도 데이터베이스로, 나무위키처럼 누구나 도로와 건물등을 추가하여 프로젝트에 참여할 수 있다.

오픈소스이므로 이렇게 완성된 지도 파일 또한 OSM 공식 위키에서 배포되고 있다.
아무래도 전세계의 지리정보이다 보니 그 용량이 현재 시점에서 pbf 포맷 기준 87GB나 된다고 한다.
포맷은 XML과 PBF(protobuf)의 두 가지로 배포되는데, 확실히 바이너리 포맷이 용량 측면에서 효율적이라는 것을 알 수 있었다.

이외에도 서드파티에서 전세계 지도가 필요없는 사람들을 위해 특정 지역만의 지도 파일을 배포하는 경우도 있다.
위 링크는 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 © 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과 같은 디렉토리) 지도가 잘 표시되는 것을 볼 수 있다.
'개발 및 프로젝트 > Safecast (졸업작품)' 카테고리의 다른 글
| 전국 통합대피소(한파쉼터, 무더위쉼터, 지진옥외대피장소, 지진해일긴급대피장소) 데이터셋 (0) | 2026.07.13 |
|---|---|
| RTL-SDR로 지상파 DMB 재난메시지 수신해보기 - 2 (0) | 2026.07.08 |
| Safecast 하우징 디자인 및 부품 정리 (0) | 2026.07.02 |
| RTL-SDR로 지상파 DMB 재난메시지 수신해보기 - 1 (0) | 2026.07.01 |