40MB wasm 을 25MiB 상한 무료 호스팅에 올리는 법
Godot 웹 빌드의 index.wasm 은 약 40MB 인데 Cloudflare Pages 는 파일 하나에 25MiB 까지만 받는다. 엔진은 그대로 두고, 압축본을 브라우저가 직접 풀게 했다.
Godot 로 만든 방치형 게임을 웹으로 내보내면 index.wasm 하나가 약 40MB 다. 친구들에게 돌려 볼 공유판은 Cloudflare Pages 무료 프로젝트에 올리기로 했다. 그런데 Pages 는 파일 하나에 25MiB 까지만 받는다. 40MB 는 들어가지 않는다.
엔진을 깎아 크기를 줄일까, 상한이 넉넉한 다른 호스팅을 찾을까? 둘 다 하지 않았다. 압축한 파일을 올리고, 브라우저가 그걸 받아 제 손으로 풀게 했다. 필요한 건 스무 줄이 안 되는 스크립트 하나였다.
상한에 걸린 건 파일 하나뿐이었다
Godot 웹 내보내기는 파일 여러 개를 낸다. 10월 14일에 내보낸 배포 폴더를 보면 이렇다.
| 파일 | 크기 |
|---|---|
index.html | 6,461 바이트 |
index.js | 279,815 바이트 |
index.pck | 6,451,908 바이트 |
index.wasm.gzbin | 10,054,758 바이트 |
게임 데이터가 든 index.pck 는 6MB 남짓이라 문제가 없다. 오디오 워클릿 스크립트와 아이콘도 작다. 상한을 넘는 건 엔진 본체인 index.wasm 하나였다. 폴더 전체 용량이 아니라 파일 하나의 크기가 문제라서, 그 하나만 줄이면 된다.
wasm 은 압축이 잘 된다. gzip -9 로 눌렀더니 약 40MB 가 10,054,758 바이트, 10MB 남짓으로 내려갔다. 25MiB 상한 아래로 넉넉하게 들어간다.
남은 문제는 엔진이다. Godot 가 만든 index.js 는 index.wasm 이라는 이름으로 파일을 부른다. 서버에는 그 파일이 없고 압축본만 있다. 엔진 쪽 코드를 고치면 내보낼 때마다 다시 고쳐야 한다. 그래서 엔진은 그대로 두고, 엔진이 파일을 부르는 길목에 서기로 했다.
fetch 를 가로채서 대신 풀어 준다
tools/web_shim.js 전문이다.
// Cloudflare Pages 는 파일 하나에 25 MiB 상한이 있어 index.wasm(약 40MB)을 그대로 못 올린다.
// 그래서 gzip 으로 줄인 index.wasm.gzbin 을 올리고, 엔진이 index.wasm 을 부르면 여기서 받아 풀어 준다.
// 확장자를 .gzbin 으로 둔 이유: .gz 이면 서버가 Content-Encoding 을 붙여 브라우저가 먼저 풀어 버릴 수 있다.
(function () {
var of = window.fetch.bind(window);
window.fetch = function (u, o) {
var s = typeof u === 'string' ? u : (u && u.url) || '';
if (/\.wasm(\?|$)/.test(s) && 'DecompressionStream' in window) {
return of(s.replace(/\.wasm(\?|$)/, '.wasm.gzbin$1'), o).then(function (r) {
if (!r.ok) return of(u, o);
var body = r.body.pipeThrough(new DecompressionStream('gzip'));
return new Response(body, { status: 200, headers: { 'Content-Type': 'application/wasm' } });
});
}
return of(u, o);
};
})();하는 일은 단순하다. 원래 fetch 를 of 에 붙잡아 두고 window.fetch 를 바꿔 끼운다. 요청 주소가 .wasm 으로 끝나면(뒤에 ? 쿼리가 붙어도) 주소를 .wasm.gzbin 으로 바꿔 받는다. 받은 응답의 본문 스트림을 DecompressionStream('gzip') 에 흘려 푼다. 그리고 풀린 스트림으로 새 Response 를 만들어 엔진에 돌려준다. 엔진 입장에서는 그냥 index.wasm 을 받은 것과 똑같다.
새 응답에 Content-Type: application/wasm 을 일부러 박아 넣은 데는 이유가 있다. 브라우저의 WebAssembly.instantiateStreaming 은 응답의 Content-Type 이 application/wasm 이 아니면 받지 않는다. 압축본은 서버가 아무 타입이나 붙여 보낼 수 있으니, 돌려줄 때 타입을 확실히 정해 둔다.
.wasm 이 아닌 요청은 손대지 않고 원래 fetch 로 그대로 보낸다. index.pck 같은 나머지 파일은 이 스크립트를 의식할 일이 없다.
압축본이 없을 때의 퇴로
두 군데에 퇴로를 열어 뒀다.
하나는 if (!r.ok) return of(u, o); 다. .gzbin 을 받으려다 404 같은 실패가 나면 원래 주소로 다시 부른다. 압축하지 않은 index.wasm 이 그대로 있는 환경, 예를 들어 상한이 없는 로컬 서버라면 이 줄 덕에 그냥 돈다.
다른 하나는 'DecompressionStream' in window 검사다. 이 API 가 없는 브라우저에서는 가로채지 않고 원래 요청을 보낸다. 다만 이건 반쪽짜리 퇴로다. Pages 에는 index.wasm 원본이 없으니, 그런 브라우저에서는 결국 받을 파일이 없다. 그 브라우저까지 살리려면 원본을 따로 둘 곳이 있어야 한다. 공유판은 친구 테스트용이라 거기까지는 가지 않았다.
확장자를 .gz 로 두면 두 번 풀린다
처음 떠오르는 이름은 index.wasm.gz 다. 이 이름은 피했다.
서버는 .gz 로 끝나는 파일을 보면 응답에 Content-Encoding: gzip 을 붙일 수 있다. 그 헤더가 붙으면 브라우저는 본문을 스크립트에 넘기기 전에 알아서 푼다. 스크립트는 이미 풀린 wasm 을 받는다. 그걸 다시 DecompressionStream('gzip') 에 넣으면? gzip 이 아닌 데이터를 gzip 으로 풀려다 깨진다. 두 번 풀리는 셈이다.
반대로 서버가 그 헤더를 안 붙이면 아무 문제가 없다. 서버 설정에 따라 되기도 하고 안 되기도 한다는 게 제일 곤란하다. 그래서 서버가 알아보지 못할 확장자 .gzbin 을 붙였다. 서버 눈에는 정체불명의 바이너리일 뿐이라 헤더를 건드리지 않는다. 푸는 일은 처음부터 끝까지 스크립트 하나가 맡는다.
스레드를 끄니 헤더가 필요 없어졌다
Godot 웹 내보내기에는 스레드 지원 옵션이 있다. 이 게임은 variant/thread_support=false 로 껐다.
스레드를 켠 웹 빌드는 SharedArrayBuffer 를 쓰고, 브라우저는 페이지가 교차 출처 격리 상태일 때만 그걸 허락한다. 격리하려면 응답마다 COOP·COEP 헤더를 붙여야 한다. 정적 호스팅에서 헤더를 챙기는 것도 일이고, 그 헤더 때문에 다른 출처 자원을 부르는 데 제약이 생긴다.
스레드를 끄면 이 헤더가 다 필요 없다. 파일을 올리기만 하면 돈다. 스레드로 얻을 것과 헤더 없이 아무 데나 올려도 도는 것 중에 뒤쪽을 골랐다. 내보내기 설정을 보면 variant/extensions_support 도 꺼져 있다.
내보내기 스크립트 하나로 묶어 두기
압축하고, 원본을 지우고, shim 을 끼우는 걸 손으로 하면 언젠가 하나를 빼먹는다. tools/export_web.sh 가 그 순서를 고정한다.
"$GODOT" --headless --path . --export-release "Web" build/web/index.html >/dev/null 2>&1
test -f build/web/index.wasm
gzip -9 -n -c build/web/index.wasm > build/web/index.wasm.gzbin
rm build/web/index.wasmtest -f 는 내보내기가 조용히 실패했을 때 바로 멈추게 한다(스크립트 맨 위에 set -e 가 있다). gzip 의 -n 은 원본 파일 이름과 시각을 압축본에 넣지 않는 옵션이라, 같은 wasm 이면 매번 같은 압축본이 나온다. 그다음 원본 index.wasm 을 지운다. 남겨 두면 배포할 때 그 40MB 짜리가 같이 올라가려다 상한에 걸린다.
마지막으로 파이썬 몇 줄이 index.html 의 </head> 바로 앞에 web_shim.js 내용을 <script> 로 끼워 넣는다. 엔진 스크립트보다 먼저 fetch 를 바꿔 둬야 하니 자리가 중요하다. 내보내기 설정의 html/head_include 에는 검색에 안 잡히게 하는 noindex 메타 태그만 들어 있고, shim 은 이 단계에서 따로 끼운다.
배포는 이렇게 만든 폴더를 그대로 올리면 끝이다. 엔진 코드는 한 줄도 건드리지 않았다. 다시 내보낼 때마다 같은 스크립트가 같은 순서로 돈다.
핵심 한 줄상한은 폴더가 아니라 파일 하나에 걸린다. 큰 파일 하나만 압축해 올리고, 푸는 일은 브라우저에 맡긴다.
자주 묻는 것
Cloudflare Pages 25MiB 넘는 Godot wasm 올리는 법
gzip 으로 줄인 index.wasm.gzbin 만 올리고, index.html 에 fetch 를 가로채는 스크립트를 넣는다. 엔진이 index.wasm 을 부르면 .gzbin 을 받아 DecompressionStream 으로 풀고 Content-Type 을 application/wasm 으로 돌려준다.
압축한 wasm 확장자를 .gz 로 하면 안 되는 이유
서버가 .gz 파일에 Content-Encoding: gzip 을 붙이면 브라우저가 먼저 풀어 버린다. 스크립트가 그걸 다시 풀려다 깨진다. 서버가 알아보지 못하는 .gzbin 을 쓰면 푸는 일을 스크립트 하나가 맡는다.