다음에 올 것을 만들어가는 기술에 대한 심층 기사.

스위스 전국을 복셀로: 지오데이터 파이프라인

오픈 지오데이터로 스위스 전체를 1:1 마인크래프트 월드로 만든 복셀 파이프라인의 타일링, LOD, 스트리밍 설계를 정리했습니다.

블록형 빙하와 계단식 큐브 봉우리가 있는 복셀 스타일의 알프스 계곡.
블록 하나가 1미터: 오픈 정부 지오데이터로 나라 전체를 복셀로 재구성했습니다.

스위스 정부의 오픈데이터 포털 어딘가에는 아이거 북벽을 바위의 질감이 느껴질 만큼 촘촘하게 샘플링한 높이맵이 있습니다. 한 개발자는 이런 파일들을 복셀 파이프라인에 넣어 실제 제품으로 내놓았습니다. 블록 하나가 1미터이고 마테호른이 실제와 같은 높이와 경사로 서 있는, 1:1 마인크래프트 월드로 스위스 전체를 옮긴 것입니다. 압축 다운로드 용량은 24GB이고, 무료 샘플 지역만 해도 약 2,400km²에 달합니다. 그 밑에 깔린 타일링, 레벨 오브 디테일, 지연 생성 기법은 거대한 공간 데이터를 전부 불태우지 않고 다루는 방법을 보여주는 좋은 사례입니다.

문제: 복셀로 된 나라는 메모리에 들어가지 않는다

같이 계산해 보죠. 스위스 면적은 41,285km²입니다. 제곱미터당 블록 하나로 잡으면, 구멍을 하나도 파기 전에 표면 기둥만 413억 개입니다. 기둥마다 돌, 흙, 호수 바닥을 포함해 100블록 깊이를 준다고 하면 4조 블록이 됩니다. 블록당 1바이트만 잡아도 원시 지오메트리가 4TB입니다. 그런데 완성된 월드는 24GB로 압축됩니다. 중요한 사실을 알려주는 대목입니다. 그 지오메트리 대부분은 실제로 지오메트리로 저장되지 않고, ‘레시피’로 저장된다는 것이죠.

수직 축도 문제를 일으킵니다. 바닐라 마인크래프트는 월드 빌드 높이를 384블록으로 제한합니다. 스위스는 마기오레 호수의 해발 193m에서 듀퍼스피체의 4,634m까지 이어지며, 이 높이 차는 4,441m입니다. 바닐라 제한의 11배가 넘는데, 광산을 하나 파기도 전에 이미 그렇습니다. 그래서 이 월드에는 Cubic Chunks 모드가 필요합니다. 이 모드는 기둥 단위 저장 방식을 16×16×16 큐브를 필요한 만큼 쌓는 방식으로 바꿉니다. 그런데 이 모드는 Minecraft Java 1.12.2에서만 안정적으로 동작하므로, 오늘 출시되는 프로젝트가 몇 년 전 게임 버전에 고정되어 있는 것입니다.

메모리가 세 번째 벽입니다. 플레이어가 처음 어떤 지역을 방문할 때 원본 데이터로부터 블록을 만드는 일은 연산 부담이 큽니다. 프로젝트는 최소 16GB RAM을 요구하고, Ryzen 7 7800X3D에 32GB를 장착한 머신 같은 환경에서 테스트합니다. 게임이나 GIS에서 스트리밍 예산과 씨름해 본 사람이라면 이 구조가 익숙할 겁니다. GPU VRAM이 바닥났을 때와 같은 종류의 트리아지입니다. 전부 들고 있을 수 없으니, 지금 무엇을 붙들고 있을지 잘 고르는 능력이 중요해집니다.

실패한 시도 1: 전체 월드를 미리 구워 두기

가장 뻔한 접근은 거대한 오프라인 작업 하나입니다. 지오데이터를 읽어 마인크래프트 리전 파일을 만들고(Anvil 포맷은 월드를 512×512블록 리전 단위로 저장합니다), 결과를 압축해 배포하는 방식이죠. 덴마크는 2014년 덴마크 지리데이터청이 나라 전체를 마인크래프트 맵으로 공개하면서 비슷한 일을 했습니다. 오픈데이터 시연으로서 이정표가 된 사례이고, 국가 규모의 맵이 가능하다는 것을 증명했습니다. 그런데 왜 이 방식을 택하지 않았을까요?

사전 베이킹에는 야심에 비례해 커지는 구조적 비용이 있기 때문입니다. 플레이 전에 모든 리전의 모든 블록을 생성하고 기록하고 압축해야 하므로, 데이터가 갱신될 때마다 전체 재빌드와 수십 GB의 재배포가 필요합니다. 플레이어는 결코 가지 않을 지형까지 잔뜩 내려받아야 하고, 41,000km² 대부분은 아무도 찾아가지 않을 곳입니다. 무엇보다 베이킹된 월드는 공개되는 순간 데이터 스냅샷에 고정됩니다. 한 번 쓰면 끝인 산출물이죠. 전부 다시 생성하고 다시 배포하지 않는 한 개선되지 않으며, 사용자는 지구 전체를 다시 내려받게 됩니다.

실패한 시도 2: 단일 해상도의 하나짜리 높이맵

두 번째 유혹은 정규화입니다. 모든 입력을 하나의 전역 래스터, 단일 해상도로 변환해서 파이프라인에 코드 경로를 하나만 두는 것이죠. 스위스 안에는 고해상도 지형 모델인 swissALTI3D가 있습니다. 국경 너머에는 30m마다 고도 점 하나씩인 Copernicus GLO-30 DEM이 있습니다. 균일하게 맞추려고 전부 30m로 낮추면 아이거 북벽은 계단식 모래더미가 됩니다. 반대로 가장 높은 해상도로 올리면 저장 공간과 처리량이 폭발하고, 국경 지역에는 여전히 실제 데이터가 없어서 결국 모르는 것을 업샘플링한 셈이 됩니다.

이질성은 고도뿐 아니라 의미 정보에도 똑같이 타격을 줍니다. swissBUILDINGS3D는 스위스 전역을 다루므로, 생성기는 실제 건물 footprint 위에 단순화된 복셀 건물을 세울 수 있습니다. 다만 국경 안에서만 가능합니다. 제네바 근처에서 국경을 넘으면 건물 레이어 자체가 없으니, 파이프라인은 합리적인 대안을 씁니다. 집이 있어야 할 자리에 나무를 심는 것이죠. 물은 별도의 소스가 필요하고(호수 바닥은 swissBATHY3D), 빙하, 숲, 포도밭도 마찬가지입니다. 단일 입력 파일이란 없습니다. 지오데이터에는 원래 그런 것이 없습니다.

이 교훈은 마인크래프트를 넘어서도 잘 통합니다. 해상도는 전역 상수가 아니라 타일별 메타데이터입니다. 입력이 균일하다고 가정하는 파이프라인은 충실도를 낭비하거나 충실도를 지어내게 됩니다. 그리고 지어낸 충실도는 솔직한 거친 해상도보다 나쁩니다. 내부에서는 누구도 그 차이를 알아챌 수 없기 때문입니다.

지도 타일 격자가 깔때기를 통과해 조립된 복셀 풍경으로 나타나는 모습.
핵심 발상의 전환: 데이터는 타일과 레시피로 보관하고, 블록은 실제로 사람이 가는 곳에서만 조립합니다.

실제로 작동하는 방식: 타일 기반 파이프라인과 지연 생성

스위스 프로젝트가 작동하는 이유는 ‘전부 미리 굽기’라는 본능을 뒤집었기 때문입니다. 완성된 월드를 내려받지 않고, 전처리된 지오데이터와 커스텀 생성기를 묶은 월드 패키지를 받습니다. 게임은 플레이어가 실제로 어떤 장소에 처음 가는 순간에 블록을 만듭니다. 새 지역의 첫 방문에는 몇 분이 걸리지만, 그 뒤로는 다른 마인크래프트 월드와 마찬가지로 청크가 캐시됩니다. 연산은 사전이 아니라 방문마다 지불되고, 아무도 밟지 않을 지형은 생성하지 않습니다. 타일은 저장, 캐싱, 생성, 장애 처리까지 모든 것의 단위입니다.

타일링은 스위스 공식 측량 좌표계인 LV95에 고정되어 있습니다. 각 타일에는 어떤 데이터셋이 덮고 있는지, 어떤 해상도인지가 기록됩니다. 측량 좌표를 블록 좌표로 바꾸는 것은 순수한 아핀 변환입니다. 프로젝트는 공식을 공개해서, 국가 지도 뷰어에서 좌표를 가져와 게임 안의 같은 지점으로 순간이동할 수 있게 했습니다:

def lv95_to_block(east: float, north: float, height: float) -> tuple[int, int, int]:
"""Convert Swiss LV95 survey coordinates to Minecraft block coordinates."""
x = int(east - 2_480_000)   # strip the LV95 easting offset
z = int(1_300_000 - north)  # Minecraft's z axis grows southward
y = int(height)             # one block = one meter above sea level
return x, y, z
# The summit of the Jungfrau, 4,158 m:
print(lv95_to_block(2_640_188, 1_154_072, 4158))
# -> (160188, 4158, 145928)
# x and z match the in-game teleport command exactly; the game just
# drops you a few meters higher so you land on the summit, not in it.

타일 격자 위에서 생성은 열 단위로 평가되는 레이어 스택입니다. 기본 높이는 swissALTI3D에서 가져옵니다. 물, 숲, 도로, 철도 노선, 댐, 눈사태 방지 구조물 같은 표층 피복은 swissTLM3D에서 옵니다. 호수 바닥은 swissBATHY3D, 나무 높이는 swissSURFACE3D, 빙하 윤곽은 1850년까지 거슬러 올라가는 GLAMOS 목록, 얼음 두께는 ETH 취리히의 데이터셋에서 가져옵니다. 덕분에 알레치 빙하는 바위 위에 흰 텍스처를 씌운 것이 아니라 실제로 깎여 나간 얼음입니다. 목초지, 애추(돌무더기), 포도밭은 연방 서식지 지도에서, 취리히의 가로수까지도 도시 자체의 수목 대장에서 가져옵니다. 전부 합쳐 약 15개의 공개 데이터셋이며 모두 무료입니다. swisstopo는 2021년 상업적 이용을 포함해 데이터를 공개했습니다.

복셀 월드는 지형의 그림이 아닙니다. 파이프라인이 제곱미터마다 내린 수백만 개의 작은 결정이 쌓인 캐시입니다. 그러니 월드의 품질은 곧 그 결정들의 품질입니다.

레벨 오브 디테일은 렌더링 트릭이 아니라 데이터 문제다

마인크래프트의 기본 렌더 거리는 수백 미터 정도만 보여줍니다. 프로젝트 스크린샷에는 알레치 빙하가 수 킬로미터 밖까지 굽이쳐 가는 모습이 나옵니다. 이 먼 시점(“Fernsicht”)은 게임이 첫 실행 때 한 번 만들어 두는 별도의 저충실도 표현입니다. 이것은 전형적인 레벨 오브 디테일이지만, 중요한 결정은 LOD가 어디에 존재하느냐입니다. 렌더러가 즉석에서 흐리게 처리하는 것이 아니라, 수집(ingest) 단계에서 원본 데이터로부터 계산됩니다.

맵 타일 피라미드를 만들어 본 적이 있다면 계산은 익숙할 겁니다. 줌 레벨마다 두 축 모두 해상도가 절반이 되므로, 전체 피라미드는 1 + 1/4 + 1/16 + … ≈ 4/3, 즉 베이스 레이어의 약 1.33배 비용이 듭니다. 모든 축척에서 부드럽게 동작시키는 대가로 33%의 저장 비용을 더 내는 셈입니다. 복셀 지형의 LOD는 이 피라미드를 세워 놓은 것과 같습니다. 먼 지형은 거친 줌 레벨이고, 가까운 지형은 촘촘한 레벨입니다. 진짜 관건은 올바른 셀을 빠르게 가져올 수 있는 키 설계입니다. 지오해시와 제대로 된 공간 인덱스 사이의 트레이드오프에서도 같은 질문을 마주쳤습니다. 키 선택이 그 아래 모든 단계의 조회 비용을 결정하니까요.

제가 가장 좋아하는 LOD 결정은 시각적인 것이 아닙니다. 무료 다운로드는 2,400km² 규모의 슬라이스로, 아이거, 묀히, 융프라우, 알프스에서 가장 큰 빙하가 있는 유네스코 융프라우-알레치 지역입니다. 유료 전국 버전과 똑같은 파이프라인으로 돌아갑니다. 이것은 제품 기능으로서의 카나리아 릴리스입니다. 용량은 24GB가 아니라 1.1GB이고, 가입도 필요 없습니다. 여러분의 컴퓨터에서 이것이 돌아간다면, 전체 맵도 돌아갈 겁니다. 대용량 데이터 제품을 만든다면 이 방식을 그대로 가져가세요. 무료 티어는 가장 쉬운 지형을 잘라낸 불구 데모가 아니라, 가장 어려운 지형을 대표하는 슬라이스여야 합니다.

앞쪽의 작고 촘촘한 블록에서 흐릿한 원경의 거대한 굵은 블록으로 바뀌는 복셀 산맥.
먼 지형은 같은 피라미드의 거친 줌 레벨일 뿐입니다. LOD는 렌더러가 아니라 수집 단계에서 결정됩니다.

브라우저에서 공간 타일 스트리밍하기

같은 원칙이 프로젝트의 소박한 웹 페이지에서도 드러납니다. 개요 지도에는 슬라이더를 끌어 복셀 월드와 실제 항공사진을 비교하는 기능이 있는데, 이미지는 프로젝트 서버에서 오지 않습니다. 슬라이더를 움직인 뒤에야 브라우저가 swisstopo의 서비스에서 SWISSIMAGE 타일을 직접 가져옵니다. 지연 로딩, 원본 직접 요청, 캐시 친화적인 구조입니다. 무거운 대역폭은 바로 이런 목적으로 존재하는 공공 인프라가 감당하고, 프로젝트의 호스팅 비용은 거의 0에 가깝게 유지됩니다.

웹에서 타일 주소 체계는 이미 풀린 문제이며, 공간 정보 작업을 조금이라도 한다면 확실히 알아 둘 가치가 있습니다:

import math
def latlon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
"""Standard slippy-map (XYZ) tile addressing used by OSM and most WMTS fronts."""
n = 2 ** zoom
x = int((lon + 180.0) / 360.0 * n)
lat_rad = math.radians(lat)
y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
return x, y
# At zoom 15, any point on Earth lands in one of 1,073,741,824 tiles.
# Fetch the few that intersect the viewport. Skip the other billion.

공식보다 중요한 두 가지 세부 사항이 있습니다. 첫째, 요청은 사용자의 의도가 분명해질 때까지 미뤄집니다. 슬라이더가 실제로 움직여야 요청이 나갑니다. 이것은 대역폭 최적화이자, 공공 예산으로 운영되는 무료 공공 서비스를 배려하는 예의이기도 합니다. 둘째, 출처 정보가 타일과 함께 전달됩니다. 페이지는 지도 바로 옆에 SWISSIMAGE, swissBOUNDARIES3D, swissNAMES3D를 표기합니다. 제품이 오픈데이터의 파생 저작물이라면, 표시 지점에서의 출처 표기는 장식이 아닙니다. 라이선스가 의도대로 작동하는 모습입니다.

스위스 파이프라인에서 훔쳐 올 것들

반론부터 짚겠습니다. 실무자들이 좋은 반론을 내놓았습니다. Build the Earth 같은 수작업 프로젝트는 수천 명의 자원봉사자를 투입해 실제 건물을 모델링하는데, 자동화된 파이프라인은 그 충실도에 근접하지 못합니다. 복셀화된 swissBUILDINGS3D 건물 footprint는 조잡한 근사치일 뿐이며, 건축에 관심이 있다면 지형만 내보낸 결과물이 오히려 더 깔끔한 산출물일 수 있습니다. 맞는 말입니다. 하지만 범위와 충실도는 직접 맞바꿔야 하는 관계입니다. 자동화는 오늘 41,285km²를 내주고, 수작업은 언젠가 아름다운 구시가지를 내줍니다. 제 입장은 이렇습니다. 데이터 제품에서는 출처를 공개한 자동 생성이 이기고, 중요한 곳에는 큐레이션된 수작업 랜드마크를 그 위에 얹으면 됩니다.

그리고 출처는 모두가 건너뛰는 부분입니다. 이 프로젝트는 알려진 결함 목록을 함께 배포합니다. 30m Copernicus 격자만 닿는 국경 근처의 공백, 얼룩지거나 평평한 지점, 잘못되었거나 빠진 다리와 도로, 1미터 단위가 아니라 샘플링으로 이루어진 QA가 그것입니다. 이 목록은 실질적인 역할을 합니다. ‘이 월드가 여기서 틀렸다’는 버그 리포트를 ‘그 데이터셋은 여기서 끝난다’는 문서로 바꿔 줍니다. 파생 데이터 제품이라면 모두 출처 매트릭스와 알려진 오류 목록을 공개해야 합니다. 그렇지 않은 제품이 더 정확한 것이 아닙니다. 그저 덜 정직할 뿐입니다.

  • 전부 타일로 나누세요. 타일은 저장, 캐싱, 생성, 장애의 단위입니다. 모놀리스는 우아하게 열화되지 않습니다.
  • 수집 단계에서 다운샘플링하지 마세요. 소스별 원본 해상도를 유지하고 렌더링 시점에 리샘플링하세요.
  • 지연 생성하세요. 월드가 누구의 플레이 타임보다 커지면, 방문마다 연산하는 방식이 사전 베이킹을 이깁니다.
  • 수집 단계에서 LOD 피라미드를 만드세요. 약 33%의 오버헤드를 예산에 넣고, 모든 거리에서 부드러운 스케일링을 사세요.
  • 카나리아 슬라이스를 배포하세요. 가장 어려운 데이터를 대표하는 무료 조각은 어떤 사양표보다 강력합니다.
  • 출처와 알려진 결함을 공개하세요. 이것들은 제품을 변명하는 글이 아니라 제품의 일부입니다.

전국 버전은 앞으로의 모든 버전을 포함해 15 CHF에 판매되며, 법적인 이유로 스위스 거주자에게만 제공됩니다. 순수한 디지털 상품치고는 묘하게 매력적인 제약입니다. 하지만 진짜 제품은 지도가 아닙니다. 오픈 데이터, 타일 기반 파이프라인, 정직한 문서만 있으면 한 사람이 대부분의 팀이 가진 지오데이터 인프라를 능가하는 결과물을 내놓을 수 있다는 증명입니다. 여러분의 데이터셋은 아마 41,285km²는 아닐 겁니다. 변명거리도 그만큼 작아지죠.