화면에 필요한 타일만, 서버도 없이 — 목차를 품은 단일 파일, PMTiles

18

화면에 필요한 타일만, 서버도 없이 — 목차를 품은 단일 파일, PMTiles 이미지

지도 서버가, 통째로 사라졌다

전 세계가 담긴 지도를 웹에 띄웠습니다. 마우스로 드래그하면 부드럽게 움직이고, 줌을 당기면 골목까지 선명하게 나와요. 그런데 뒤를 열어보니 지도 서버가 없습니다. 데이터베이스도 없어요. S3 버킷에 파일이 딱 하나 놓여 있을 뿐입니다. 그 파일 크기는 무려 120GB. 그런데 내 브라우저가 실제로 받은 건 고작 수십 KB예요. 지도를 아무리 움직여도 서버로 요청이 가는 게 아니라, 그 거대한 파일의 특정 바이트 구간만 사르르 오갑니다. — 대체 무슨 마술일까요?

지난 FlatGeobuf 편은 이렇게 끝났습니다. FlatGeobuf는 원본 피처를 조각내 보냈고, 브라우저는 그걸 받아 매번 직접 그렸죠. 그러면서 숙제를 하나 남겼어요. "다 그려진 지도(타일) 자체를 조각내 보낼 수 있다면?" 그러면 클라이언트는 그리지도 않고 그냥 붙이기만 하면 됩니다. 오늘의 주인공이 바로 그 발상이에요. 지도 타일 수억 장을 단일 파일로 묶어, 지도 서버를 통째로 없애버리는 포맷 — PMTiles입니다.

잠깐, '타일'이 뭐죠?

본론에 들어가기 전에 짚고 갈 게 있어요. PMTiles의 P·M·T 중 T가 바로 타일(Tile) 인데, 정작 이 시리즈에선 타일을 아직 제대로 다루지 않았습니다. 그래서 딱 30초만 짚고 가겠습니다. (본격적인 타일 이야기는 Phase 11에서 따로 펼칩니다.)

카카오맵이나 구글맵을 상상해보세요. 지도를 드래그하면 화면이 채워지죠. 그 지도는 사실 한 장의 거대한 이미지가 아닙니다. 256×256 픽셀짜리 작은 네모 이미지 수백 장을 격자로 이어붙인 거예요. 이 작은 네모 한 장 한 장이 타일(tile) 입니다. 지도 창을 아주 천천히 움직이다 보면 네모들이 한 칸씩 늦게 뜨는 걸 본 적 있을 텐데, 그게 바로 타일이 도착하는 순간이에요.

그럼 줌은 어떻게 될까요? 여기서 줌 피라미드가 등장합니다.

그래서 어떤 타일이든 z/x/y 세 숫자로 주소가 매겨져요. "줌 레벨 z에서, 왼쪽부터 x번째, 위에서 y번째 칸"이라는 뜻이죠. 지도 라이브러리는 지금 화면에 걸리는 z/x/y만 골라 요청하고, 도착한 네모들을 착착 이어 붙입니다.

화면에 필요한 타일만, 서버도 없이 — 목차를 품은 단일 파일, PMTiles 이미지

💡 타일 한 장은 래스터(PNG·JPG 같은 그림)일 수도, 벡터(선·점 데이터를 담은 MVT)일 수도 있습니다. 벡터와 래스터의 차이가 여기서도 갈리는 셈이죠. 왜 굳이 미리 잘라 두냐고요? 사용자가 볼 때마다 지도를 새로 그리면 느리니까, 미리 그려서 네모로 구워 두고 요청이 오면 꺼내 주기만 하려는 거예요. 이 "미리 굽기"의 세계는 Phase 11에서 제대로 만납니다.

옛날 방식의 딜레마 — 파일 수억 개, 아니면 서버 한 대

자, 타일이 뭔지 알았으니 문제가 보이기 시작합니다. 이 수많은 타일을 대체 어디에, 어떻게 저장할까요?

가장 순진한 방법은 z/x/y 그대로 폴더와 파일로 저장하는 거예요. tiles/3/4/2.png 같은 식으로요. 그런데 계산을 해봅시다. 줌 0부터 15까지 전 세계 타일을 다 구우면 몇 장일까요? 각 레벨이 4배씩 늘어나니 다 더하면 약 14억 장입니다. 파일 14억 개를 S3에 올린다고 상상해보세요. 업로드는 끝나지 않고, 객체 하나하나에 비용이 붙고, 관리는 지옥이 됩니다. 파일이 정적이라 서버는 필요 없지만, 개수가 폭발해요.

그래서 나온 대안이 MBTiles입니다. Mapbox가 만든 이 포맷은 발상이 단순하고 영리해요. 타일 전부를 SQLite 데이터베이스 파일 하나에 차곡차곡 담는 겁니다. GeoPackage 편에서 봤던 그 SQLite 맞아요. 파일이 하나로 뭉치니 "파일 폭발" 문제는 깔끔하게 사라집니다.

그런데 여기에 함정이 있습니다. MBTiles에서 타일 하나를 꺼내려면 SQLite에 쿼리를 날려야 해요. SELECT tile_data WHERE zoom=... AND ... 같은 걸요. 그 말은 곧, 이 파일을 웹에 서비스하려면 SQLite를 열고 쿼리를 돌려 줄 서버 프로세스가 상시 떠 있어야 한다는 뜻입니다. 결국 타일 서버를 한 대 돌려야 하죠.

💡 "어? 같은 SQLite인데 GeoPackage는 서버 없이도 읽었잖아?" 싶을 거예요. 맞습니다 — 둘 다 SQLite 파일이라 내 컴퓨터에서 프로그램으로 여는 데엔 서버가 필요 없어요(QGIS가 .gpkg.mbtiles도 바로 엽니다). 여기서 말한 '서버'는 브라우저에 타일을 뿌리는 상황 얘기예요. 브라우저는 .mbtiles를 열어 SQL을 돌릴 수 없으니, 누군가 대신 쿼리해서 꺼내 줘야 하고 그 '누군가'가 서버 프로세스죠. 사실 이 한계는 GeoPackage도 똑같아서, 지난 편이 "웹 스트리밍이 안 된다"는 숙제를 남겼던 겁니다. 그럼 둘의 진짜 차이는? 담는 내용물이에요. GeoPackage는 주로 원본 벡터 피처를 담아 공간 질의까지 하는 범용 컨테이너(Shapefile의 후계자)고, MBTiles는 오직 미리 그려 둔 타일만 담는 좁은 그릇입니다. 한쪽은 '데이터베이스', 다른 쪽은 '타일 묶음'인 셈이죠.

정리하면 딜레마는 이렇습니다.

둘 다 하나씩 아쉽습니다. "파일도 하나면서, 서버도 필요 없는" 길은 없을까요?

PMTiles의 한 방 — 파일 하나에 타일 + 목차를 함께

바로 그 빈칸을 채우는 게 PMTiles(.pmtiles)입니다. 핵심 아이디어는 한 문장이에요.

수억 장의 타일을 단일 파일로 묶되, "어느 타일이 이 파일의 몇 바이트째에 있는지" 알려주는 목차를 파일 안에 함께 심는다.

이 목차를 디렉터리(directory), 즉 인덱스라고 부릅니다. MBTiles가 "타일 위치를 알려면 SQLite에게 물어봐야" 했다면, PMTiles는 목차만 읽으면 위치가 곧바로 나옵니다. 물어볼 서버가 필요 없어요.

느낌이 오시나요? FlatGeobuf가 "공간 인덱스를 품은 바이너리"였다면, PMTiles는 "타일 인덱스를 품은 바이너리"입니다. 인덱스를 파일 바깥(DB·서버)에 두지 않고 파일 안에 붙박이로 넣는다는 발상이 똑같아요. 그래서 둘 다 "정적 파일 하나로 웹에서 조각내 읽기"가 가능해집니다.

뚜껑을 열면 — 헤더·목차·타일

FlatGeobuf 파일이 "네 칸 서랍장"이었죠. PMTiles도 뜯어보면 구획이 나뉘어 있습니다. 순서대로 이래요.

  1. 헤더(Header) — 맨 앞 127바이트로 크기가 고정돼 있습니다. 앞부분에 매직 바이트 PMTiles와 버전 번호(현재 3)가 적혀 있어, 프로그램이 "이건 PMTiles v3구나" 하고 바로 알아봅니다. 그리고 나머지 구획들이 파일의 몇 바이트째에서 시작하는지 오프셋이 전부 담겨 있어요.
  2. 루트 디렉터리(Root Directory) — 목차의 첫 장입니다. 아래에서 자세히 봅니다.
  3. 메타데이터(Metadata) — 이 타일셋의 설명서(줌 범위, 경계, 스타일 힌트 등).
  4. 리프 디렉터리(Leaf Directories) — 목차가 너무 길 때 쓰는 "목차의 2권, 3권".
  5. 타일 데이터(Tile Data) — 진짜 타일들이 줄지어 있는 본문.

목차(디렉터리)가 하는 일은 단순합니다. 타일 하나에 번호(TileID)를 매기고, 그 번호가 파일의 어디(오프셋)에 몇 바이트(길이)로 들어 있는지를 적어 둡니다. TileID 8142 → 3,201,540바이트째부터 412바이트 같은 식으로요.

여기서 반가운 얼굴이 하나 등장합니다.

💡 또 힐베르트 곡선이에요. TileID를 매기는 순서가 바로 FlatGeobuf 편에서 피처를 줄 세우던 그 힐베르트 곡선입니다. 그때는 건물·도로 같은 피처를 줄 세웠는데, 여기선 타일을 줄 세워요. 곡선이 점프 없이 옆 칸으로만 훑고 지나가니, 지도에서 가까운 타일은 파일에서도 가까이 놓입니다. 화면을 옆으로 조금 옮기면 필요한 타일들이 파일 안에서도 옹기종기 붙어 있어서, 한 번의 요청으로 뭉텅이째 가져올 수 있죠. 같은 아이디어가 벡터에서 타일로 넘어와도 그대로 통하는 겁니다.

그런데 타일이 14억 장이면 목차도 어마어마하게 길어지지 않을까요? PMTiles는 이걸 두 가지로 다룹니다.

화면에 필요한 타일만, 서버도 없이 — 목차를 품은 단일 파일, PMTiles 이미지

필요한 타일만 뜯어온다 — 다시, HTTP Range

목차를 파일에 심은 것만으로는 아직 절반입니다. 진짜 한 방은, 그 목차를 네트워크 너머에서 그대로 쓴다는 데 있어요. 비결은 지난 편에서 만난 그 기술, HTTP Range 요청입니다. "이 파일에서 1,000번째 바이트부터 200바이트만 주세요" 라고 콕 집어 받아오는 웹의 기본 기능이죠.

고정된 구획과 붙박이 목차가 깔려 있으니, 클라이언트는 이렇게 움직입니다.

  1. 헤더 + 루트 디렉터리를 먼저 받는다. 파일 맨 앞 16KB 정도만 한 번 Range로 요청하면, 헤더(구획 위치)와 목차의 큰 줄기가 손에 들어옵니다. 이제 어느 타일이 파일 어디에 있는지 계산할 준비가 끝났어요.
  2. 필요한 타일 번호를 계산한다. 지금 화면·줌에 걸리는 타일들의 TileID를 구하고, 목차에서 그 위치를 찾습니다. (목차가 리프까지 내려가야 하면 그 조각만 조금 더 받아요.)
  3. 그 타일의 바이트 구간만 뜯어온다. 목차가 알려준 위치대로, 화면에 걸리는 타일들의 바이트 범위만 Range로 콕 집어 받아 화면에 붙입니다. 힐베르트 정렬 덕에 이웃 타일은 파일에서도 붙어 있어, 여러 장을 한 요청으로 가져올 수 있죠.

눈치채셨나요? 원래 타일 서버가 하던 일z/x/y 요청을 받아 타일을 꺼내 주는 일 — 을 이제 클라이언트가 목차를 읽고 직접 합니다. 그러니 서버 쪽엔 아무 로직도 필요 없어요. 파일을 그냥 바이트 단위로 잘라 줄 수 있는 정적 저장소면 충분합니다.

그래서 서버가 사라진다 — 패러다임 변화

이 지점이 PMTiles가 "서버 없는 지도"라 불리는 이유입니다. 두 세계를 나란히 놓아보죠.

전통 방식 — GeoServer 같은 타일 서버든, MBTiles + 서버 프로세스든, 컴퓨터 한 대가 상시 켜져 요청을 받아 타일을 꺼내 줍니다. 트래픽이 몰리면 서버를 늘려야 하고(스케일링), 그동안 고정비가 계속 나가요. 지도 하나 띄우겠다고 인프라를 운영해야 하는 겁니다.

PMTiles 방식.pmtiles 파일 하나를 S3나 CDN, Cloudflare R2 같은 정적 저장소에 올리면 끝입니다. 돌아가는 서버가 없어요(compute 0). 요청이 폭주해도 CDN이 알아서 흡수하고, 검색 로직은 전부 브라우저 안에서 돕니다. 지도 서버 인프라가 통째로 증발하는 거죠.

💡 특히 Cloudflare R2가 PMTiles의 단짝으로 자주 거론됩니다. 데이터를 내보낼 때 드는 egress(전송) 요금이 없기 때문이에요. 지도는 사용자가 많아질수록 타일 전송량이 폭증하는데, 그 비용이 0이니 궁합이 좋습니다. 물론 공짜 점심은 아니에요. 이 마법이 성립하려면 뒤에서 말할 두 가지 — 작은 자바스크립트 한 조각Range를 지원하는 저장소 — 가 필요합니다.

화면에 필요한 타일만, 서버도 없이 — 목차를 품은 단일 파일, PMTiles 이미지

Protomaps — 지구 전체가 파일 한 개

이 발상을 가장 극단적으로 보여주는 게 Protomaps 프로젝트입니다. 브랜던 리우(Brandon Liu)가 만든 이 프로젝트는, OpenStreetMap 기반의 전 세계 베이스맵을 통째로 단일 .pmtiles 파일로 배포합니다.

그 파일 크기가 얼마냐고요? 줌 0–15의 지구 전체가 약 120GB입니다(2025년 8월 기준 최신 빌드가 125.55GB). 이 파일 하나를 내 S3나 R2에 올려두는 것만으로, 서버 한 대 없이 "나만의 전 세계 지도 타일 인프라" 가 완성됩니다. 예전 같으면 지도 서버를 구성하고 타일을 굽고 캐시를 얹느라 며칠이 걸렸을 일이, 파일 업로드 하나로 끝나는 거예요. 전 세계가 부담스러우면 유럽만(30–50GB), 한국만 잘라 쓸 수도 있고요.

이게 얼마나 가벼운지 직접 확인해볼 수 있습니다. pmtiles CLI로, Protomaps가 공개해 둔 30GB짜리 지형 타일 파일에 명령 한 줄만 걸어 보세요. 파일을 통째로 받지 않고 메타데이터가 즉시 튀어나옵니다.

bash
# 원격 30GB 파일을 통째로 받지 않고, 헤더/메타데이터만 즉시 조회
# (내부적으로 앞부분 수십 KB만 Range로 받아온다)
pmtiles show https://r2-public.protomaps.com/protomaps-sample-datasets/terrarium_z9.pmtiles

그러면 이런 정보가 곧바로 튀어나옵니다(실제 출력을 일부만 추렸어요).

text
pmtiles spec version: 3
tile type: png
bounds: (long: -180, lat: -85) (long: 180, lat: 85)
min zoom: 0
max zoom: 9
addressed tiles count: 349525
clustered: true
internal compression: gzip

이게 전부 30GB 파일 하나에서 나온 정보인데, 실제로 받은 건 앞부분 수십 KB(헤더 + 목차) 뿐이에요. 방금 배운 "헤더만 Range로 콕 집어 받기"가 실제로 돌아가는 순간이죠. 특히 눈여겨볼 두 줄이 있습니다. clustered: true는 타일이 힐베르트로 뭉쳐 정렬돼 있다는 뜻이고, internal compression: gzip목차를 압축해 넣었다는 뜻이에요. 우리가 위에서 뜯어본 그 구조가 파일에 진짜로 박혀 있다는 증거입니다.

💡 pmtiles는 Go로 만든 단일 실행 파일이에요. go-pmtiles 릴리스 페이지(글 끝 참고 링크)에서 OS에 맞는 바이너리를 받아 PATH에 두면 되고, macOS라면 brew install pmtiles 한 줄이면 됩니다. 윈도우에서 Git Bash로 쓸 땐 pmtiles.exe를 PATH에 두거나, 파일이 있는 폴더에서 ./pmtiles.exe show ... 로 실행하세요.

들여다보기

원리는 됐고, 직접 만들어 봅시다. 이미 MBTiles가 있다면 변환은 한 줄입니다.

bash
# MBTiles → PMTiles (한 줄로 변환)
pmtiles convert area.mbtiles area.pmtiles

# 내 파일도 다운로드 없이 들여다보기
pmtiles show area.pmtiles

# 로컬에서 잠깐 띄워 확인 (개발용)
pmtiles serve .

💡 여기서 "그럼 애초에 MBTiles(타일 묶음)는 어떻게 만드나요?"라는 질문이 나올 텐데, 큰 GeoJSON·FlatGeobuf를 타일로 굽는 일은 tippecanoe 같은 도구의 몫이고, 이건 Phase 11에서 제대로 다룹니다. 지금은 "이미 구워진 타일 묶음을 PMTiles로 옮겨 담는다" 정도로만 봐 두세요.

브라우저에서 쓰는 법도 궁금하실 겁니다. 앞에서 예고한 그 "작은 자바스크립트 한 조각" 이 여기 등장해요. pmtiles 라이브러리를 지도 라이브러리(MapLibre GL JS 등)에 프로토콜로 물려 주면 됩니다.

js
import { Protocol } from 'pmtiles';

// pmtiles:// 라는 새 프로토콜을 지도 엔진에 등록한다
const protocol = new Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);

// 이제 지도 소스 URL을 이렇게 주면, 나머지(목차 읽기 + Range 요청)는 라이브러리가 알아서
// source: { type: 'vector', url: 'pmtiles://https://my-bucket.s3.../area.pmtiles' }

이 몇 줄이 하는 일은, 지도 엔진이 z/x/y 타일을 달라고 할 때마다 서버에 요청하는 대신, 라이브러리가 목차를 보고 파일의 해당 바이트 구간만 Range로 받아 건네주는 겁니다. 지도 엔진 입장에선 평범한 타일 서버가 있는 줄 알아요. 실은 정적 파일 하나뿐인데 말이죠.

💡 지도 라이브러리(MapLibre) 자체를 세팅하는 전체 과정은 아직 배우지 않은 영역이라, 여기선 "이렇게 물린다"는 감만 잡고 넘어갑니다. MapLibre는 Phase 13에서, PMTiles의 v3 바이트 구조와 실전 통합은 심화 편에서 본격적으로 다룰게요. 그리고 실무 함정 하나 — 저장소가 HTTP Range(Accept-Ranges)를 지원해야 하고, 다른 도메인에서 읽는다면 FlatGeobuf 편에서 본 그 CORS 설정도 챙겨야 마법이 통합니다.

그래도 만능은 아니다

멋져 보이지만, PMTiles에도 뚜렷한 경계선이 있습니다. 약점을 알아야 제대로 씁니다.

① 미리 구워야 한다. PMTiles는 이미 타일로 구워진 결과물이에요. 원본 데이터를 타일로 굽는 과정(tippecanoe 등)이 반드시 선행하고, 여기엔 시간과 디스크가 듭니다. "원본을 그대로 던져 넣으면 알아서 되는" 포맷이 아니에요.

② 갱신은 통째로 다시 굽기. 타일 한 장만 살짝 고치기가 어렵습니다. FlatGeobuf가 그랬듯 PMTiles도 "한 번 굽고 여러 번 읽는" 정적 포맷이에요. 데이터가 실시간으로 바뀌는 상황이라면, 그건 벡터타일 서버나 PostGIS(Phase 6)의 몫입니다.

③ 왕복이 잦다. Range 요청이 여러 번 오가니, 대역폭보다 네트워크 지연(latency) 에 민감합니다. 목차 캐싱과 힐베르트 클러스터링이 이를 상당히 완화하지만, CDN 튜닝은 여전히 중요해요.

④ 브라우저엔 작은 도우미가 필요. 순수 정적 파일이지만, 목차를 해석하는 자바스크립트(위의 그 조각)가 있어야 합니다. 표준 이미지 태그(<img src>)에 타일 URL을 바로 꽂는 방식과는 다르죠. (다만 서버 앞단에 pmtiles 어댑터를 두면, 평범한 z/x/y URL로도 뽑아 쓸 수 있습니다.)

클라우드 네이티브 지도, 다시

지난 편에서 봤던 그 흐름, 기억나시나요? 클라우드 네이티브 지오(cloud-native geospatial) — "지도 데이터를 서버 없이 정적 저장소에 올려두고, HTTP Range로 필요한 조각만 읽는다." PMTiles는 이 철학의 타일 담당입니다. FlatGeobuf 편의 그 가족표에 PMTiles의 자리를 다시 새겨 봅시다.

포맷담는 것한 줄 요약
COG래스터(위성영상)큰 TIFF에서 보이는 영역만 뜯어오기
FlatGeobuf벡터 원본 피처큰 벡터에서 bbox 조각만 뜯어오기
PMTiles미리 그린 지도 타일타일 묶음을 목차째 단일 파일로
GeoParquet벡터(분석용)컬럼 지향으로 대량 분석

특히 FlatGeobuf와 PMTiles는 경쟁이 아니라 짝꿍이에요. FlatGeobuf는 가공 안 된 원본 피처를 건네고 그림은 클라이언트가 그립니다. PMTiles는 미리 그려 둔 타일을 건네죠. 그래서 실무에선 GeoJSON이나 FlatGeobuf를 입력으로 받아 tippecanoe로 구워서 PMTiles로 만드는 흐름이 흔합니다.

💡 표준 지위도 짚어 둘게요. PMTiles는 아직 OGC 정식 표준은 아닙니다. Protomaps 커뮤니티가 만든 스펙이에요. 그럼에도 MapLibre·Leaflet·OpenLayers·GDAL 등 주요 도구가 이미 폭넓게 지원하니, 실무에서 쓰는 데엔 부족함이 없습니다.

정리 — 한 문장으로

PMTiles는 '목차를 품은 단일 파일 타일 아카이브'다. 힐베르트로 줄 세운 수억 장의 타일과 내장 디렉터리 덕에, 지도 서버 없이 정적 저장소에 올려두고 HTTP Range로 화면에 필요한 타일만 뜯어온다.

개별 z/x/y 파일타일 서버(GeoServer 등)MBTilesPMTiles
파일 구성파일 수억 개(서버가 생성)SQLite 1개바이너리 1개
서버 필요아니오예 (상시 구동)예 (쿼리 프로세스)아니오
저장소정적서버 + DB서버정적 (S3/CDN)
갱신파일 교체동적DB 갱신다시 굽기 (정적)
약점파일 폭발운영·비용서버 상주굽기 선행·정적

흐름이 보이시죠. 개별 파일(수억 개) → MBTiles(단일이지만 서버 필요) → PMTiles(단일 + 서버리스) 로 오면서, 타일을 담는 방식은 점점 "파일 하나로, 서버 없이" 쪽으로 진화해 왔습니다. FlatGeobuf가 원본 피처를 조각내 보냈다면, PMTiles는 다 그려진 타일을 조각내 보내며 지도 서버 자체를 지워 버린 거예요.

그런데 지금까지의 포맷들은 모두 "지도에 그려서 보여주기" 가 목적이었습니다. 만약 목적이 그림이 아니라 분석이라면 어떨까요? 수억 행짜리 공간 데이터를 지도가 아니라 노트북 한 대에서 SQL로 휘젓고 싶다면? 다음 편은 바로 그 무대입니다. FlatGeobuf 편에서 "용량을 정말로 줄이고 싶으면 이쪽"이라고 예고했던, 컬럼 지향으로 빅데이터 시대를 여는 포맷 — GeoParquet입니다.

참고