← 소개 페이지

우토바이 지도 데이터

지도 데이터 기준일: 2026-10-07

우토바이의 지도는 OpenStreetMap 데이터에서 길⁠·⁠물⁠·⁠지명만 골라 줄여 만들었어요.
OpenStreetMap 데이터를 고쳐서 나눠 줄 때는 고친 데이터나 고친 방법을 공개해야 해요. 그래서 받은 파일, 남기고 뺀 것, 만든 스크립트를 여기에 적어 둬요.

출처와 라이선스

© OpenStreetMap 기여자
OpenStreetMap 데이터는 Open Database License⁠(⁠ODbL⁠) 1.0으로 제공돼요. 우토바이의 지도 데이터도 같은 ODbL 1.0을 따라요.

받은 파일

Geofabrik이 OpenStreetMap 한국 데이터를 Shortbread 1.0 형식의 지도 조각⁠(⁠벡터 타일⁠)⁠으로 묶어 둔 파일이에요.

이 파일에는 OpenStreetMap 데이터가 언제 기준인지 적혀 있지 않아요. 그래서 파일을 받은 날을 지도 데이터 기준일로 적었어요. 실제 데이터는 이날보다 오래된 것일 수 있어요. 받을 때 서버가 알려 준 이 파일의 마지막 수정 시각은 2026-10-07 03:03 (⁠UTC⁠)⁠이에요.

같은 주소의 파일은 Geofabrik이 새로 만들 때마다 바뀌어요. 지금 받으면 위 파일과 내용이 다를 수 있어요.

남긴 것

지도 확대 단계⁠(⁠줌⁠)⁠는 0부터 12까지 넣었어요. 그보다 더 확대하면 줌 12 자료를 늘려서 그려요.

받은 파일은 담긴 것에 따라 여러 층으로 나뉘어 있어요. 그중 아래 층만 남겼어요.

층담긴 것줌
streets길⁠(⁠아래 표의 7가지⁠)5~12
street_labels고속도로, 고속화도로, 일반국도의 이름과 번호10~12
place_labels지명⁠(⁠도시⁠·⁠마을⁠·⁠동네⁠·⁠섬 같은 이름⁠)4~12
water_polygons강⁠·⁠호수⁠·⁠저수지 같은 물4~12
ocean바다0~12

아래 표는 streets 층에 남긴 길 종류예요. '한국 도로로 치면' 칸은 OpenStreetMap 한국 위키의 한국의 도로 분류를 따른 대략적인 짝이에요.

한국 도로로 치면OSM 값줌
고속도로motorway5~12
고속화도로trunk6~12
일반국도primary8~12
지방도, 특별⁠·⁠광역시도secondary9~12
시도⁠·⁠군도tertiary10~12
등급 없는 작은 도로unclassified12
동네 길residential12

뺀 것

받은 파일에 있던 나머지 층 21개는 모두 뺐어요. 건물, 가게⁠·⁠시설, 땅 쓰임새, 행정 경계, 주소 같은 것들이에요.

길 가운데 철길, 활주로, 버스 전용 도로, 보행로⁠·⁠계단⁠·⁠자전거길 같은 것은 뺐어요. 아파트 단지 안 길이나 주차장 통로 같은 서비스 도로도 뺐어요.

임도⁠·⁠농로와 주택가 골목⁠(track, living_street)⁠도 이 지도에는 없어요. 스크립트가 빼는 건 아니에요. 받은 파일에 줌 13부터 들어 있는데, 이 지도는 줌 12까지만 넣었어요.

만든 방법

받은 파일에 아래 스크립트를 한 번 돌려서 만들었어요. Python 3에 들어 있는 기능만 써서 따로 설치할 것이 없어요.

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을 따라요.

점은 세 번에 나눠 받았어요. 처음 받은 점에 빠진 구가 있어서 두 번 더 받아 채웠어요.

① 시⁠·⁠군⁠·⁠구⁠·⁠도 점

칸마다 보낸 조회식이에요. {} 네 자리에 칸의 남쪽⁠·⁠서쪽⁠·⁠북쪽⁠·⁠동쪽 끝이 들어가요.

[out:json][timeout:90];node({},{},{},{})[place~"^(county|borough|city|province|state)$"];out;

② 특별⁠·⁠광역시의 구 점

어떤 구는 OpenStreetMap에 구⁠(borough)⁠가 아니라 동네⁠(suburb)⁠로 적혀 있어서 ①에 들어오지 않았어요. 그래서 서울⁠·⁠인천⁠·⁠부산⁠·⁠대구⁠·⁠광주⁠·⁠대전⁠·⁠울산 7곳을 도시마다 네모 하나로 묶어, 이름이 '구'로 끝나는 점을 다시 받았어요.

도시마다 보낸 조회식이에요. {} 네 자리는 ①과 같아요.

[out:json][timeout:60];node({},{},{},{})["place"~"^(suburb|borough)$"]["name"~"구$"];out;

받은 점 74개 가운데 suburb 점 3개⁠(⁠대구 동구⁠·⁠부산 동구⁠·⁠부산 서구⁠)⁠만 구로 보탰어요. 나머지 borough 점 71개는 ①에서 이미 받은 점이에요.

③ 경계선으로만 있는 구

그래도 빠진 구는 OpenStreetMap에 위치 점 없이 경계선으로만 있어요. 대부분 2024년 이후에 새로 생긴 구예요. 그래서 구 이름마다 그 경계를 찾아, 경계의 대표점을 받았어요. 손으로 찍은 위치는 없어요.

이렇게 받은 점은 ① 356개에 ②⁠·⁠③에서 보탠 15개를 더해 371개였어요. 그중 아래처럼 빼거나 고쳐서 287개를 남겼어요.

급OSM 값받음남김
도province, state158
특별⁠·⁠광역시, 세종city1368
시city(⁠위에 포함⁠)80
군county10582
구borough100 + 보탠 15109

뺀 점과 고친 점이에요.

없어진 이름과 바뀐 이름의 근거 주소는 고르는 스크립트 안에 함께 적어 뒀어요.

남긴 점마다 이름, 급, 인구⁠(⁠OSM에 적힌 값, 없으면 0⁠)⁠, 그리는 순서, 위치만 담았어요. 파일 안에도 출처⁠(⁠© OpenStreetMap 기여자⁠)⁠와 ODbL 1.0 주소를 적어 뒀어요.

같은 식으로 지금 다시 물으면 그동안 OpenStreetMap이 바뀐 만큼 다른 점이 나올 수 있어요. 그래서 받는 스크립트, 받은 파일, 고르는 스크립트, 앱에 넣은 파일을 모두 그대로 올려 둬요.

돌리는 법: 고르는 스크립트와 받은 파일 세 개를 한 폴더에 두고 이 한 줄을 돌려요.

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 뒤 네 숫자는 앱 지도 범위예요. 차례로 서쪽 끝 경도, 남쪽 끝 위도, 동쪽 끝 경도, 북쪽 끝 위도예요.

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.1BSD 2-Clause (LICENSE.md)
지도 글꼴⁠(⁠영문⁠·⁠숫자⁠)Noto Sans Regular, Klokan Technologies 패치판 (openmaptiles/fonts)SIL OFL 1.1 (LICENSE)
지도 글꼴⁠(⁠한글⁠)폰에 들어 있는 글꼴로 그려요. 앱에는 넣지 않았어요.해당 없음