우토바이 지도 데이터
우토바이의 지도는 OpenStreetMap 데이터에서 길·물·지명만 골라 줄여 만들었어요.
OpenStreetMap 데이터를 고쳐서 나눠 줄 때는 고친 데이터나 고친 방법을 공개해야 해요. 그래서 받은 파일, 남기고 뺀 것, 만든 스크립트를 여기에 적어 둬요.
출처와 라이선스
© OpenStreetMap 기여자
OpenStreetMap 데이터는 Open Database License(ODbL) 1.0으로 제공돼요. 우토바이의 지도 데이터도 같은 ODbL 1.0을 따라요.
받은 파일
Geofabrik이 OpenStreetMap 한국 데이터를 Shortbread 1.0 형식의 지도 조각(벡터 타일)으로 묶어 둔 파일이에요.
- 주소: https://download.geofabrik.de/asia/south-korea-shortbread-1.0.mbtiles
- 받은 날: 2026-10-07
- 크기: 477,224,960바이트
- SHA-256:
d99603e6785c61096f3d6e495753be43278e0ad3874769a00f1d0accccefbc4a
이 파일에는 OpenStreetMap 데이터가 언제 기준인지 적혀 있지 않아요. 그래서 파일을 받은 날을 지도 데이터 기준일로 적었어요. 실제 데이터는 이날보다 오래된 것일 수 있어요. 받을 때 서버가 알려 준 이 파일의 마지막 수정 시각은 2026-10-07 03:03 (UTC)이에요.
같은 주소의 파일은 Geofabrik이 새로 만들 때마다 바뀌어요. 지금 받으면 위 파일과 내용이 다를 수 있어요.
남긴 것
지도 확대 단계(줌)는 0부터 12까지 넣었어요. 그보다 더 확대하면 줌 12 자료를 늘려서 그려요.
받은 파일은 담긴 것에 따라 여러 층으로 나뉘어 있어요. 그중 아래 층만 남겼어요.
| 층 | 담긴 것 | 줌 |
|---|---|---|
streets | 길(아래 표의 7가지) | 5~12 |
street_ | 고속도로, 고속화도로, 일반국도의 이름과 번호 | 10~12 |
place_ | 지명(도시·마을·동네·섬 같은 이름) | 4~12 |
water_ | 강·호수·저수지 같은 물 | 4~12 |
ocean | 바다 | 0~12 |
아래 표는 streets 층에 남긴 길 종류예요. '한국 도로로 치면' 칸은 OpenStreetMap 한국 위키의 한국의 도로 분류를 따른 대략적인 짝이에요.
| 한국 도로로 치면 | OSM 값 | 줌 |
|---|---|---|
| 고속도로 | motorway | 5~12 |
| 고속화도로 | trunk | 6~12 |
| 일반국도 | primary | 8~12 |
| 지방도, 특별·광역시도 | secondary | 9~12 |
| 시도·군도 | tertiary | 10~12 |
| 등급 없는 작은 도로 | unclassified | 12 |
| 동네 길 | residential | 12 |
뺀 것
받은 파일에 있던 나머지 층 21개는 모두 뺐어요. 건물, 가게·시설, 땅 쓰임새, 행정 경계, 주소 같은 것들이에요.
- 뺀 층:
boundaries,boundary_labels,addresses,water_lines,water_lines_labels,dam_lines,dam_polygons,pier_lines,pier_polygons,bridges,street_polygons,streets_polygons_labels,ferries,street_labels_points,aerialways,public_transport,buildings,water_polygons_labels,land,sites,pois
길 가운데 철길, 활주로, 버스 전용 도로, 보행로·계단·자전거길 같은 것은 뺐어요. 아파트 단지 안 길이나 주차장 통로 같은 서비스 도로도 뺐어요.
- 뺀 길 종류:
bridleway,bus_guideway,busway,cycleway,footway,funicular,light_rail,miniature,monorail,narrow_gauge,path,pedestrian,platform,rail,runway,service,steps,subway,taxiway,tram
임도·농로와 주택가 골목(track, living_street)도 이 지도에는 없어요. 스크립트가 빼는 건 아니에요. 받은 파일에 줌 13부터 들어 있는데, 이 지도는 줌 12까지만 넣었어요.
만든 방법
받은 파일에 아래 스크립트를 한 번 돌려서 만들었어요. Python 3에 들어 있는 기능만 써서 따로 설치할 것이 없어요.
- 스크립트: dev_map_tiles.py (9,991바이트)
- SHA-256:
5b599a47918dfd50c486666507069f96b50c8f32be413cebfb59cd75ae2b26d7 - 돌리는 법:
python3 dev_map_tiles.py <받은 파일> --out <폴더> --date 2026-10-07 - 결과: 지도 조각 11,027개, 모두 46,197,277바이트(압축 전)
2026-10-08에 위 받은 파일로 이 스크립트를 다시 돌려 봤어요. 앱에 넣는 지도 조각 11,027개가 바이트까지 똑같이 나왔어요.
스크립트 전문 보기 (dev_map_tiles.py, 9,991바이트)
#!/usr/bin/env python3
"""우토바이 '지나간 길 지도' 오프라인 지도 자료 만들기 (dev 스파이크 t_4cb94f78, 명세 docs/spec/route_map.md R-2·R-9·R-17).
이 파일은 ODbL 4.6 '만든 방법 공개'의 원본이다(소개 사이트 /map-data 에 그대로 옮긴다). 좌표·주행 기록은 다루지 않는다.
입력
Geofabrik 한국 Shortbread 1.0 벡터 타일 묶음(OpenStreetMap 데이터, ODbL 1.0)
URL : https://download.geofabrik.de/asia/south-korea-shortbread-1.0.mbtiles
스파이크에서 쓴 파일: 477,224,960 B, HTTP Last-Modified 2026-10-07 03:03:18 GMT (받은 날짜 2026-10-07)
메타데이터 원문: name=Shortbread, version=1.0, maxzoom=14, license="Open Database License 1.0",
author="OpenStreetMap contributors, Geofabrik GmbH"
(같은 층 이름·kind 값을 가진 Shortbread 1.0 파일이면 OSM 원본 .osm.pbf 에서 tilemaker + shortbread-tilemaker
설정으로 직접 만든 파일도 그대로 넣을 수 있다 — 아래 '남기는 것'은 Shortbread 스키마 이름만 쓴다.)
남기는 것 (명세 R-2)
줌 : 0~12 (13 이상은 엔진이 12 자료를 늘려 그린다)
층 : streets(아래 DROP 종류를 뺀 선), water_polygons, ocean, place_labels, street_labels
버리는 층: 위 다섯 밖 전부(land, water_lines, bridges, street_polygons, buildings, pois, boundaries 등)
streets 에서 빼는 kind(DROP): 보행로·계단·산책로·자전거길·승마로·보행자 거리·서비스 도로·승강장·철도 종류·
공항 활주로/유도로·버스 전용 도로. 임도(track)는 남긴다.
타일 안 각 층의 keys/values 표는 원본 그대로 둔다(안 쓰는 값이 조금 남음).
출력 (--out <폴더>)
<폴더>/tiles/{z}/{x}/{y}.pbf XYZ(구글식) 번호, 압축하지 않은 MVT. APK 가 zip 으로 압축한다.
<폴더>/tiles.json TileJSON 2.2: bounds·minzoom·maxzoom·attribution·license·만든 날짜·입력 URL.
ODbL 메타데이터(명세 R-9 3번) = attribution "© OpenStreetMap 기여자",
license "https://opendatacommons.org/licenses/odbl/1-0/".
--mbtiles <파일> 을 주면 같은 내용의 mbtiles(타일 gzip, metadata 표 포함)도 만든다(비교용).
사용
python3 tools/dev_map_tiles.py <south-korea-shortbread-1.0.mbtiles> --out <폴더> [--mbtiles out.mbtiles] [--date 2026-10-07]
"""
import argparse
import collections
import datetime
import gzip
import json
import os
import sqlite3
import sys
SRC_URL = 'https://download.geofabrik.de/asia/south-korea-shortbread-1.0.mbtiles'
MAXZOOM = 12
KEEP_LAYERS = {'water_polygons', 'ocean', 'place_labels', 'street_labels'}
DROP = {'footway', 'steps', 'path', 'cycleway', 'bridleway', 'pedestrian', 'service', 'platform',
'rail', 'narrow_gauge', 'tram', 'light_rail', 'funicular', 'subway', 'monorail', 'miniature',
'runway', 'taxiway', 'busway', 'bus_guideway'}
ATTRIBUTION = '© OpenStreetMap 기여자'
LICENSE = 'https://opendatacommons.org/licenses/odbl/1-0/'
# ── 최소 protobuf(MVT) 읽기·쓰기 ──
def _varint(b, i):
r = s = 0
while True:
x = b[i]
i += 1
r |= (x & 0x7F) << s
s += 7
if not x & 0x80:
return r, i
def _fields(b):
i = 0
while i < len(b):
k, i = _varint(b, i)
f, w = k >> 3, k & 7
if w == 0:
v, i = _varint(b, i)
elif w == 2:
n, i = _varint(b, i)
v = b[i:i + n]
i += n
elif w == 5:
v = b[i:i + 4]
i += 4
elif w == 1:
v = b[i:i + 8]
i += 8
else:
raise ValueError(w)
yield f, v
def _packed(b):
out, i = [], 0
while i < len(b):
v, i = _varint(b, i)
out.append(v)
return out
def _v(n):
out = bytearray()
while True:
b = n & 0x7F
n >>= 7
if n:
out.append(b | 0x80)
else:
out.append(b)
return bytes(out)
def _ld(field, payload):
return _v((field << 3) | 2) + _v(len(payload)) + payload
def _vi(field, val):
return _v(field << 3) + _v(val)
def parse_layer(lv):
name, keys, vals, val_raw, feats, ext, ver = None, [], [], [], [], 4096, 2
for lf, x in _fields(lv):
if lf == 1:
name = x.decode()
elif lf == 3:
keys.append(x.decode())
elif lf == 4:
s = None
for vf, vv in _fields(x):
if vf == 1:
s = vv.decode()
vals.append(s)
val_raw.append(x)
elif lf == 2:
feats.append(x)
elif lf == 5:
ext = x
elif lf == 15:
ver = x
return name, keys, vals, val_raw, feats, ext, ver
def feat_kind(fb, keys, vals):
for ff, fv in _fields(fb):
if ff == 2:
t = _packed(fv)
for i in range(0, len(t), 2):
if keys[t[i]] == 'kind':
return vals[t[i + 1]]
return None
def rebuild_streets(lv, dropped):
name, keys, vals, val_raw, feats, ext, ver = parse_layer(lv)
kept = []
for fb in feats:
k = feat_kind(fb, keys, vals)
if k in DROP:
dropped[k] += 1
else:
kept.append(fb)
if not kept:
return b''
body = _vi(15, ver) + _ld(1, name.encode())
body += b''.join(_ld(2, fb) for fb in kept)
body += b''.join(_ld(3, k.encode()) for k in keys)
body += b''.join(_ld(4, v) for v in val_raw)
body += _vi(5, ext)
return body
def filter_tile(raw, dropped, layer_count):
parts = []
for f, lv in _fields(raw):
if f != 3:
continue
nm = parse_layer(lv)[0]
if nm == 'streets':
s = rebuild_streets(lv, dropped)
if s:
parts.append(_ld(3, s))
layer_count[nm] += 1
elif nm in KEEP_LAYERS:
parts.append(_ld(3, lv))
layer_count[nm] += 1
return b''.join(parts)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('src')
ap.add_argument('--out', required=True)
ap.add_argument('--mbtiles')
ap.add_argument('--date', default=datetime.date.today().isoformat(), help='지도 데이터 기준일(입력 파일 받은 날)')
a = ap.parse_args()
src = sqlite3.connect(a.src)
meta = dict(src.execute('SELECT name, value FROM metadata').fetchall())
tiles_dir = os.path.join(a.out, 'tiles')
os.makedirs(tiles_dir, exist_ok=True)
mb = None
if a.mbtiles:
if os.path.exists(a.mbtiles):
os.remove(a.mbtiles)
mb = sqlite3.connect(a.mbtiles)
mb.execute('CREATE TABLE metadata (name text, value text)')
mb.execute('CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)')
mb.execute('CREATE UNIQUE INDEX tile_index on tiles (zoom_level, tile_column, tile_row)')
per_zoom = collections.Counter()
per_zoom_n = collections.Counter()
empty = 0
dropped = collections.Counter()
layer_count = collections.Counter()
for z, x, y_tms, data in src.execute(
'SELECT zoom_level, tile_column, tile_row, tile_data FROM tiles WHERE zoom_level <= ? '
'ORDER BY zoom_level, tile_column, tile_row', (MAXZOOM,)):
raw = gzip.decompress(data) if data[:2] == b'\x1f\x8b' else data
body = filter_tile(raw, dropped, layer_count)
if not body:
empty += 1
continue
y = (1 << z) - 1 - y_tms # mbtiles(TMS) → XYZ
d = os.path.join(tiles_dir, str(z), str(x))
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, f'{y}.pbf'), 'wb') as fh:
fh.write(body)
per_zoom[z] += len(body)
per_zoom_n[z] += 1
if mb is not None:
mb.execute('INSERT INTO tiles VALUES (?,?,?,?)', (z, x, y_tms, gzip.compress(body, 6)))
bounds = [float(v) for v in meta.get('bounds', '124.3188,32.36076,132.3386,38.64966').split(',')]
tilejson = {
'tilejson': '2.2.0',
'name': 'utobai-korea-osm',
'scheme': 'xyz',
'format': 'pbf',
'minzoom': 0,
'maxzoom': MAXZOOM,
'bounds': bounds,
'attribution': ATTRIBUTION,
'license': LICENSE,
'source_url': SRC_URL,
'source_schema': f"Shortbread {meta.get('version', '?')}",
'data_date': a.date,
'layers_kept': sorted(KEEP_LAYERS | {'streets'}),
'streets_kinds_dropped': sorted(DROP),
}
with open(os.path.join(a.out, 'tiles.json'), 'w', encoding='utf-8') as fh:
json.dump(tilejson, fh, ensure_ascii=False, indent=1)
if mb is not None:
for k, v in [('name', 'utobai-korea-osm'), ('format', 'pbf'), ('minzoom', '0'), ('maxzoom', str(MAXZOOM)),
('bounds', ','.join(map(str, bounds))), ('attribution', ATTRIBUTION), ('license', LICENSE),
('description', f'Shortbread 1.0 filtered: {SRC_URL} @ {a.date}')]:
mb.execute('INSERT INTO metadata VALUES (?,?)', (k, v))
mb.commit()
mb.close()
print(f'입력: {os.path.basename(a.src)} (Shortbread {meta.get("version")}, license="{meta.get("license")}")')
print(f'지도 데이터 기준일: {a.date}')
print('줌 | 타일 수 | 이 줌 바이트(압축 전) | 누적')
acc = 0
for z in sorted(per_zoom):
acc += per_zoom[z]
print(f'{z} | {per_zoom_n[z]} | {per_zoom[z]} | {acc}')
print(f'타일 파일 합: {sum(per_zoom_n.values())}개, 압축 전 {acc} B, 빈 타일(안 씀) {empty}개')
print('층별 남은 타일 수:', dict(sorted(layer_count.items())))
print('streets 에서 뺀 선 수(kind):', dict(dropped.most_common()))
if __name__ == '__main__':
main()
시·군·구·도 이름
앱 지도에 뜨는 시·군·구·도 이름은 위 지도 조각이 아니라, 따로 만든 작은 파일 하나로 그려요. 받은 지도 조각에는 군 이름이 없어서 OpenStreetMap에서 시·군·구·도 위치 점만 따로 받아 골랐어요. 이 파일도 OpenStreetMap 데이터라 같은 ODbL 1.0을 따라요.
점은 세 번에 나눠 받았어요. 처음 받은 점에 빠진 구가 있어서 두 번 더 받아 채웠어요.
① 시·군·구·도 점
- 받은 곳: OpenStreetMap 데이터를 찾아 주는 Overpass API 서버
overpass-api.de(안 될 때overpass.private.coffee) - 받은 때: 2026-10-14 02:11 (한국 시각)
- 받은 범위: 위도 33~39, 경도 124.5~131.1 네모를 위도 1° × 경도 2.2° 칸 18개로 나눠 물었어요.
칸마다 보낸 조회식이에요. {} 네 자리에 칸의 남쪽·서쪽·북쪽·동쪽 끝이 들어가요.
[out:json][timeout:90];node({},{},{},{})[place~"^(county|borough|city|province|state)$"];out;
② 특별·광역시의 구 점
어떤 구는 OpenStreetMap에 구(borough)가 아니라 동네(suburb)로 적혀 있어서 ①에 들어오지 않았어요. 그래서 서울·인천·부산·대구·광주·대전·울산 7곳을 도시마다 네모 하나로 묶어, 이름이 '구'로 끝나는 점을 다시 받았어요.
- 받은 곳: Overpass API 서버
overpass.kumi.systems(안 될 때overpass-api.de,overpass.private.coffee) - 받은 때: 2026-10-14 04:51 (한국 시각)
도시마다 보낸 조회식이에요. {} 네 자리는 ①과 같아요.
[out:json][timeout:60];node({},{},{},{})["place"~"^(suburb|borough)$"]["name"~"구$"];out;
받은 점 74개 가운데 suburb 점 3개(대구 동구·부산 동구·부산 서구)만 구로 보탰어요. 나머지 borough 점 71개는 ①에서 이미 받은 점이에요.
③ 경계선으로만 있는 구
그래도 빠진 구는 OpenStreetMap에 위치 점 없이 경계선으로만 있어요. 대부분 2024년 이후에 새로 생긴 구예요. 그래서 구 이름마다 그 경계를 찾아, 경계의 대표점을 받았어요. 손으로 찍은 위치는 없어요.
- 받은 구 12개: 수원 팔달구·부천 원미구·부천 소사구·부천 오정구·화성 만세구·화성 효행구·화성 병점구·화성 동탄구·인천 제물포구·인천 영종구·인천 서해구·인천 검단구
- 받은 곳: OpenStreetMap 검색 서비스 Nominatim
nominatim.openstreetmap.org(이름 찾기·대표점), OpenStreetMap APIapi.openstreetmap.org(경계 이름 확인) - 받은 때: 2026-10-14 05:26 (한국 시각)
- 구 이름마다 3번(이름 찾기 → 경계 확인 → 대표점), 1.5초 간격으로 물었어요.
이렇게 받은 점은 ① 356개에 ②·③에서 보탠 15개를 더해 371개였어요. 그중 아래처럼 빼거나 고쳐서 287개를 남겼어요.
| 급 | OSM 값 | 받음 | 남김 |
|---|---|---|---|
| 도 | province, state | 15 | 8 |
| 특별·광역시, 세종 | city | 136 | 8 |
| 시 | city | (위에 포함) | 80 |
| 군 | county | 105 | 82 |
| 구 | borough | 100 + 보탠 15 | 109 |
뺀 점과 고친 점이에요.
- 이름에 한글이 없는 점 69개: 네모 안에 걸린 일본 지명이라 뺐어요.
- 북한의 도 3곳: 강원도·황해남도·황해북도. 북한 강원도가 남쪽 강원특별자치도와 헷갈리지 않게
state값은 모두 뺐어요. - 구로 적혀 있지만 이름이 '구'로 끝나지 않는 점 1개: 신봉동. 뺐어요.
- 이름이 '구청'으로 끝나는 점 1개: 중구청. '중구'로 고쳐 남겼어요.
- 2026년 7월 1일 행정구역 개편으로 없어진 이름 4개: 인천 중구·인천 동구·인천 서구·전라남도. OpenStreetMap에는 아직 남아 있지만 지금은 쓰지 않는 이름이라 뺐어요. 인천의 새 구는 ③으로 채웠어요. 전라남도는 광주광역시와 하나로 합쳐졌는데, 합친 이름의 점이 OpenStreetMap에 아직 없어서 그 자리는 비워 뒀어요.
- 같은 개편으로 이름이 바뀐 점 1개: 광주광역시. 받은 파일에는 옛 이름으로 들어 있어서, OpenStreetMap에서 지금 쓰는 이름 '광주'로 고쳤어요.
- 앱 지도 범위 밖 점 2개: 남포시·송림시. 땅이 그려지지 않는 곳이라 뺐어요.
- 같은 급에 같은 이름이 15km 안에 또 있는 점 5개: 한 곳에 한 점만 남기고 뺐어요.
없어진 이름과 바뀐 이름의 근거 주소는 고르는 스크립트 안에 함께 적어 뒀어요.
남긴 점마다 이름, 급, 인구(OSM에 적힌 값, 없으면 0), 그리는 순서, 위치만 담았어요. 파일 안에도 출처(© OpenStreetMap 기여자)와 ODbL 1.0 주소를 적어 뒀어요.
같은 식으로 지금 다시 물으면 그동안 OpenStreetMap이 바뀐 만큼 다른 점이 나올 수 있어요. 그래서 받는 스크립트, 받은 파일, 고르는 스크립트, 앱에 넣은 파일을 모두 그대로 올려 둬요.
- 받는 스크립트 ①: osm_admin_places.py (3,773바이트)
- SHA-256:
fa1190936765eef275f51b051901509457182f5ab9e27bab44dcf118e07758ed
- 받는 스크립트 ②: osm_gu_check.py (3,459바이트)
- SHA-256:
10b0c7e8923fb2c4025cc6b874bdbc09569b2b16b54c07a581bec5cd27e610a2
- 받는 스크립트 ③: osm_gu_relations.py (5,161바이트)
- SHA-256:
ff8441bf0016592dbecd4a2df2719616c4b3a35bd8ed8e8db35365bfe2cc0cd3
- 받은 파일 ①: kr_admin_places.json (42,303바이트)
- SHA-256:
a0801b09fca02dc02b26d7b6298e2552eb5a07023f0d575be92c67280a6d28cb
- 받은 파일 ②: kr_gu_suburbs.json (8,440바이트)
- SHA-256:
0456ac0a59c2be2786e507186ca6631f013f0064cdf813a0989c8875859ee073
- 받은 파일 ③: kr_gu_2026.json (4,544바이트)
- SHA-256:
44619080df6f6383eba70b247a8b58e4873f4d6a613c5d37ebae60ff15bb96fa
- 고르는 스크립트: admin_labels.py (12,665바이트)
- SHA-256:
6ae0ff6394510d19cd148dca5fc03eaaf372ff5f7fedd55bbe9603585a2cd7fd
돌리는 법: 고르는 스크립트와 받은 파일 세 개를 한 폴더에 두고 이 한 줄을 돌려요.
python3 admin_labels.py --src kr_admin_places.json --sup kr_gu_suburbs.json --sup kr_gu_2026.json --bounds 124.3188,32.36076,132.3386,38.64966 --out admin_labels.geojson
--bounds 뒤 네 숫자는 앱 지도 범위예요. 차례로 서쪽 끝 경도, 남쪽 끝 위도, 동쪽 끝 경도, 북쪽 끝 위도예요.
- 앱에 넣은 파일: admin_labels.geojson (43,537바이트)
- SHA-256:
442c63a27052c2d80512515b7b8cc62184ea2a393f75cd2d5c5f219b2723488d
2026-10-14에 고르는 스크립트와 받은 파일 세 개만 빈 폴더에 두고 위 한 줄을 돌려 봤어요. 그 폴더에 앱에 넣은 파일과 바이트까지 똑같은 파일 하나만 생겼고, 폴더 밖에는 아무 파일도 생기지 않았어요.
지도를 만들고 그리는 데 쓴 것
| 무엇 | 이름 | 라이선스 |
|---|---|---|
| 지도 데이터 | OpenStreetMap 기여자. 받은 파일은 Geofabrik GmbH가 묶은 것 | ODbL 1.0 (Geofabrik 안내) |
| 시·군·구·도 이름 | OpenStreetMap 기여자. Overpass API, Nominatim, OpenStreetMap API로 받은 것 | ODbL 1.0 |
| 지도 형식 | Shortbread 1.0 (형식 설명) | CC0, 출처 표시 의무 없음 (shortbread-docs) |
| 지도를 그리는 엔진 | MapLibre Native Android 13.6.1 | BSD 2-Clause (LICENSE.md) |
| 지도 글꼴(영문·숫자) | Noto Sans Regular, Klokan Technologies 패치판 (openmaptiles/ | SIL OFL 1.1 (LICENSE) |
| 지도 글꼴(한글) | 폰에 들어 있는 글꼴로 그려요. 앱에는 넣지 않았어요. | 해당 없음 |