첫 로딩 18.5MB를 7MB로 줄인 방법: Godot 웹 게임 다이어트
AvoidNow 웹 빌드의 첫 로딩을 약 18.5MB에서 7MB로 줄였다. 엔진 경량 빌드, 색 변형 셰이더, 손실 WebP, BGM 재인코딩, 해시 캐시까지 한 커밋에 담은 과정을 정리했다.

AvoidNow를 처음 운영 서버에 올린 커밋(cda1fc7)은 9월 26일 오후 1시에 들어갔다. 그때 첫 방문에서 받는 양이 약 18.5MB였다. 발자국 피하는 게임 한 판 하자고 받기엔 너무 무거웠다. 그날 밤 10시 21분에 올린 커밋 a419130의 제목이 이 글의 요약이다. “Cut first load from ~18.5MB to ~7MB and add a loading screen.” 무엇을 어떻게 뺐는지 순서대로 적는다.
무엇이 무거웠나
Godot 웹 빌드는 크게 세 덩어리다. 엔진 자체인 .wasm, 게임 데이터(그림·소리·스크립트)를 묶은 .pck, 그리고 이 둘을 불러오는 작은 JS다. 기본 웹 템플릿으로 내보낸 엔진 wasm은 압축 전 39.5MB, brotli로 압축해도 6.9MB였다. 나머지 대부분은 pck였는데, 그 안에서 눈에 띈 건 두 가지였다. 캐릭터 색 변형 시트 여덟 장과 배경 음악이다.
AvoidNow는 3D도, 물리 엔진도 쓰지 않는다. 장판 판정은 원과 타원 계산으로 직접 하고, 화면은 2D 스프라이트뿐이다. 그런데도 기본 템플릿에는 3D 렌더러, 두 종류의 물리 엔진, 내비게이션, XR, 동영상 재생까지 다 들어 있었다. 쓰지 않는 기능의 값을 모든 방문자가 치르고 있었다.
엔진을 다시 빌드했다: wasm 39.5MB → 21MB
Godot는 소스에서 웹 템플릿을 직접 빌드할 수 있고, 빌드 프로필 파일에서 기능과 모듈을 하나씩 끌 수 있다. 같은 구조를 먼저 쓴 Salmonholic의 프로필을 가져와서 AvoidNow에 맞게 고쳤다. 끈 것은 이렇다.
- 3D 전체, 고급 GUI 컨트롤, 2D·3D 물리, 2D·3D 내비게이션, XR
- 복잡한 글자 셰이핑 서버. 한글은 완성형 음절이라 단순한 폴백 텍스트 서버로도 폰트가 제대로 그려진다
- 게임 코드에서 한 번도 쓰지 않는 모듈 수십 개(glTF, JPG, MP3, 정규식, 멀티플레이 API, WebRTC 등)
남긴 것도 이유를 하나씩 적어 두었다. WebSocket은 온라인 대전에, freetype은 기본 폰트에, WebP는 임포트된 텍스처에, SVG는 기본 테마 아이콘에 필요하다. OGG와 Vorbis는 효과음과 BGM이 전부 OGG라서 켰다. 프로필에 “AvoidNow:“라는 주석을 달아 새로 켠 부분이다. 프로필을 그대로 베꼈다면 소리가 나지 않는 빌드가 나왔을 것이다.
결과는 커밋 메시지 그대로다. wasm이 39.5MB에서 21MB로, brotli 압축본은 6.9MB에서 3.5MB로 줄었다. 대신 대가가 하나 생겼다. 나중에 새 엔진 기능을 쓰면 프로필에서 모듈을 켜고 템플릿을 다시 빌드해야 한다. 빌드에 2~7분 걸린다. 프로젝트 안내 문서에 이 주의점을 한 줄로 남겼다.
색 변형 시트 여덟 장 대신 셰이더 하나
캐릭터는 넷이고, 공룡 알을 30개, 100개 모으면 파란 옷과 보라 옷이 열린다. 처음에는 색을 바꾼 스프라이트 시트를 통째로 따로 만들었다. 캐릭터 4명 × 색 2개 = 시트 8장이다. 시트 한 장이 960×1440 크기라 무시할 수 없는 양이었다.
왼쪽부터 기본, 파랑, 보라. 지금은 옷 부분만 셰이더가 색을 바꾼다
생각해 보면 바뀌는 건 옷(튜닉)뿐이다. 그래서 캐릭터마다 옷이 칠해진 픽셀만 표시한 흑백 마스크를 한 장씩 만들고, 셰이더가 마스크가 있는 픽셀의 색상(hue)만 바꾸게 했다. 색상 값은 예전 시트에서 직접 측정해서 옮겼다. 파랑은 hue 0.58, 보라는 0.813이고 채도와 명도도 조금씩 낮춘다. 마스크는 흑백이라 한 장에 6~13KB다. 셰이더 주석에 적어 둔 절약량은 v1/v2 시트 대비 약 4.4MB다. 옛 시트 여덟 장은 지우지 않고 게임이 읽지 않는 폴더로 옮겨 두었다.
그림은 손실 WebP로, 음악은 낮은 비트레이트로
Godot는 텍스처를 무손실로 가져오는 게 기본이다. 40KB가 넘는 큰 그림, 즉 캐릭터 시트, 공룡, 바닥, 로고 같은 것만 손실 WebP로 바꿨다. 품질은 0.82로 두었다. 이 게임 그림은 굵은 외곽선에 평면 색이라 손실 압축의 흔적이 잘 보이지 않는다. 반대로 옷 마스크와 UI는 무손실로 남겼다. 마스크 가장자리가 뭉개지면 옷 색이 피부로 번질 수 있어서다.
BGM은 76.8초짜리 이음매 없는 루프다. 원본이 약 160kbps였는데, Vorbis 품질 2(약 50~60kbps)로 다시 인코딩했다. 파일은 1,547,016바이트에서 435,724바이트로, 약 1.5MB에서 0.43MB가 됐다. 길이를 그대로 두었기 때문에 루프 이음매와 박자가 유지된다. 이 게임은 공룡 발이 100 BPM 박자에 맞춰 떨어지기 때문에, 음악 길이가 조금이라도 바뀌면 판정과 소리가 어긋난다. 재인코딩 전후로 길이부터 확인했다.
| 항목 | 전 | 후 |
|---|---|---|
| 엔진 wasm (압축 전) | 39.5MB | 21MB |
| 엔진 wasm (brotli) | 6.9MB | 3.5MB |
| BGM | 약 1.5MB | 약 0.43MB |
| 색 변형 | 시트 8장 | 마스크 4장 + 셰이더 |
| 첫 로딩 합계 | 약 18.5MB | 약 7MB |
지금 배포 폴더를 보면 압축된 wasm이 약 3.55MB, 압축된 pck가 약 3.53MB, JS가 약 0.25MB다. 더하면 7MB 남짓으로 커밋 때 숫자와 맞는다.
두 번째 방문은 거의 즉시: 해시 이름과 1년 캐시
첫 방문만큼 중요한 게 두 번째 방문이다. 배포 스크립트가 엔진·데이터 파일 이름에 내용 해시 10자리를 붙이게 했다. 예를 들어 index-<해시 10자리>.wasm 같은 식이다. 내용이 바뀌면 이름이 바뀌니, 이름이 같은 파일은 영원히 같은 파일이다. 그래서 해시가 붙은 파일은 브라우저에 1년 동안 그대로 쓰라고(immutable) 알리고, 어떤 파일을 받을지 적힌 index.html만 매번 새로 확인하게 했다. 새 버전을 올리면 html이 새 이름을 가리키니 캐시 때문에 옛 게임이 뜨는 일도 없다.
wasm과 pck는 brotli로 미리 압축해 두고, 호스팅 쪽 코드가 브라우저가 brotli를 받을 수 있으면 압축본을 그대로 내려준다. Cloudflare Workers 정적 에셋에는 파일당 25MiB 제한이 있어서, 원래 39MB이던 wasm은 압축하지 않으면 올릴 수조차 없었다. 경량 빌드 이후로는 원본도 제한 안에 들어온다.
기다리는 시간도 게임처럼
용량을 줄여도 느린 망에서는 몇 초가 걸린다. 그래서 하얀 화면 대신 로딩 화면을 직접 만들었다. 돌 글씨 로고가 살짝 흔들리고, 돌판 모양 진행 막대에 퍼센트가 찬다. 그 아래로 팁 문장이 3.2초마다 바뀐다. “충격파는 대시 무적으로 뚫고 지나갈 수 있어요”, “공룡 발은 음악 박자에 맞춰 쿵! 찍혀요” 같은 문장이다. 기다리는 동안 규칙 하나를 배우고 들어가게 하려는 의도다.
로딩이 끝나면 바로 타이틀. 여기까지 첫 방문 약 7MB다
돌아보면 순서가 거꾸로였다. 배포를 먼저 하고 용량을 나중에 봤다. 첫 배포 커밋과 이 커밋 사이가 약 9시간이다. 그동안 들어왔다가 로딩 막대만 보고 나간 사람이 있었는지는 알 수 없다. 로딩 시간을 재는 기능도 이 다음 커밋(3bbdaab)에서야 붙였다. 다음 게임부터는 배포 전에 첫 로딩 크기를 먼저 재 볼 생각이다. 같은 날의 다른 작업은 한 달에 게임 네 개를 만든 회고에 있고, 실제로 얼마나 빨리 열리는지는 AvoidNow 게임 페이지에서 확인할 수 있다.


