보이지 않던 크래시 1.42%: 포털 오류 수집을 켜고 잘린 wasm을 다시 받게 한 기록
CrazyGames 통계에 로드 크래시 1.42%가 찍혔지만 내 기록에는 아무것도 없었다. 포털 빌드에 오류 수집을 켜고, 처음 들어온 오류를 보고 wasm·pck 다운로드 재시도를 넣은 하루의 기록.

9월 26일자 CrazyGames 통계에 숫자 두 개가 찍혀 있었다. 로드 크래시 1.42%, 플레이 중 크래시 1.90%. 100명이 들어오면 한두 명은 게임이 뜨기도 전에, 또 한두 명은 하다가 멈춘다는 뜻이다. 그런데 내 쪽 오류 기록을 열어 보니 CrazyGames에서 온 오류가 0건이었다. 버그가 없어서가 아니라, 포털 빌드가 오류를 아예 보내지 않고 있었기 때문이다.
이 글은 9월 27일 하루 동안 이어서 올린 두 버전, 0.21.1(커밋 2c7e86c)과 0.21.3(커밋 f8666fc)의 기록이다. 앞의 것은 “보이게 만드는 일”, 뒤의 것은 “보이고 나서 처음 고친 일”이다.
왜 0건이었나
Salmonholic은 웹 셸(게임을 감싸는 HTML) 하나로 자체 웹과 여러 포털 빌드를 만든다. 포털마다 외부로 요청을 보내도 되는지가 달라서, 셸의 오류 전송 함수는 포털 빌드면 처음에 바로 빠져나오게 되어 있었다. 안전한 기본값이었지만 결과적으로 크래시가 가장 많이 잡히는 곳이 깜깜했다.
그 시점까지 자체 웹에 남은 오류는 9월 25일 이후 4건뿐이었다. 모두 iPhone의 Chrome에서 나온 Importing a module script failed.였고, 0.14.0에서 0.14.1로 넘어가던 배포 시각에 몰려 있었다. 두 건씩 1~4ms 간격으로 짝을 이뤄서, 오디오 worklet 파일 두 개가 한 실행에서 같이 실패한 것으로 보였다. 다만 스택도 실패한 주소도 없어서 원인을 확정하지는 않았다. 이 4건으로는 1.42%를 설명할 수 없었다.
0.21.1: 먼저 보이게 만들기
결정은 CrazyGames 빌드만 오류를 보내게 하는 것이었다. 다른 포털은 그대로 외부 전송을 하지 않는다. 받는 쪽은 CrazyGames 도메인에서 온 오류 전송만 허용하고, 쿠키와 Referer는 보내지 않게 했다. 저장하는 것은 브라우저·기기 종류, 포털, 버전과 빌드, 로드 중인지 플레이 중인지, 오류 분류 정도다. 원본 사용자 에이전트와 IP 관련 값은 저장하지 않고, 메시지와 스택에 섞인 주소 질의나 토큰처럼 보이는 값은 지운다. 한 세션이 보낼 수 있는 양과 서버 보관 기간에도 상한을 뒀다.
보이게 만드는 김에 재현되는 것들은 같이 고쳤다. 조건마다 수정 전후를 브라우저로 돌린 결과다.
| 조건 | 수정 전 | 수정 후 |
|---|---|---|
| 오디오 worklet 첫 다운로드 차단(WebKit) | Importing a module script failed. |
250ms 뒤 한 번 재시도, 미처리 오류 0 |
| SDK 방 갱신·퇴장 호출 실패 | 브라우저마다 미처리 오류 2건 | 0건 |
| WebGL 문맥 강제 소실 | 회색 화면에서 멈춤 | 9개 언어 새로고침 안내, 버튼으로 재부팅 |
WebGL 문맥 소실은 엔진 안에서 자동 복구한 게 아니라 안내를 띄운 것뿐이다. 경기 상태를 되살리는 일은 이번 범위 밖으로 남겼다. 작은 버그도 하나 나왔다. 빌드 번호를 넣는 치환이 비교용 문자열까지 바꿔서, 오류 기록의 빌드 칸이 늘 dev로 찍히고 있었다.
CrazyGames 공식 SDK 코드를 받아 읽어 보니, 전역 error와 unhandledrejection을 부모 프레임으로 넘기는 부분이 있었다. 그래서 전역 오류 처리기를 막는 대신, 내가 호출한 비동기 작업의 실패를 호출한 자리에서 받아 오류가 애초에 생기지 않게 했다. 모르는 오류까지 삼키면 진짜 버그가 안 보이기 때문이다.
처음 들어온 오류: 잘린 wasm
0.21.1을 올린 뒤 CrazyGames에서 첫 오류가 들어왔다. 엔진 wasm을 스트리밍으로 초기화하다가 “섹션이 모듈 끝을 넘어간다”며 실패한 기록이었다. 파일이 끝까지 오지 않은 채로 컴파일을 시작했다는 뜻이다. 로드 크래시의 유력한 후보였다.
기존 셸에는 이런 실패 뒤 파일을 다시 받는 단계가 없었다. Godot 프리로더에서 본문 스트림이 도중에 끊기면 부팅이 영원히 대기 상태에 머무는 경로도 있었다. 0.21.3에서 셸이 부팅용 wasm과 메인 pck 두 파일만 직접 받게 바꿨다.

흐름은 이렇다. 개념만 옮긴 코드다.
async function fetchFull(url, expectedBytes) {
for (let attempt = 0; attempt < 3; attempt++) {
const u = attempt === 0 ? url : withRetryQuery(url, attempt);
try {
const buf = await (await fetch(u, { cache: attempt ? 'no-store' : 'default' })).arrayBuffer();
if (buf.byteLength === expectedBytes) return buf; // 끝까지 받았는지 확인
report('load_retry', attempt);
} catch (e) { report('load_retry', attempt); }
}
report('load_fail'); showErrorScreen();
}
첫 요청이 실패하면 최대 두 번 더 받는다. 재시도 때는 주소에 구분용 질의를 붙이고 캐시를 쓰지 않아, 잘린 응답이 캐시에서 다시 나오지 않게 했다. 파일당 실제 요청은 세 번을 넘지 않는다. 스트리밍 초기화만 실패하면 같은 바이트로 버퍼 초기화를 한 번 더 해 본다. 재시도하는 동안에는 로딩 화면을 그대로 두고, 마지막 실패에서만 위 오류 화면을 띄운다. 전송하는 진단은 파일 종류, 시도 횟수, 오류 분류뿐이다.
일부러 망가뜨려서 확인하기
잘린 다운로드는 내 회선에서 저절로 재현되지 않는다. 그래서 실제 내보내기 결과를 내려 주는 로컬 서버를 만들고, 응답을 절반만 보내거나, 본문 중간에 연결을 끊거나, 503을 돌려주거나, 길이는 같은데 내용이 깨진 wasm을 주는 식으로 16가지 장애를 넣었다. CrazyGames·Poki·itch처럼 파일이 하위 경로에 놓이는 포털 구조도 따로 넣었다. Chromium과 WebKit을 창을 띄워 실제 GPU로 돌려 32건이 모두 통과했다.

복구되는 경우는 정상 부팅과 비슷하게 0.6~1초 안에 끝났다. 최종 실패 세 종류가 4.6초 안팎인 것은 느려서가 아니라, 오류 화면을 띄운 뒤에도 4.5초를 더 지켜보며 Godot 내부가 다시 요청을 보내거나 처리되지 않은 오류를 내지 않는지 확인하기 때문이다. 요청 기록을 보면 절반만 온 wasm은 두 번째 요청에서 복구되고, 끝까지 실패하는 경우도 요청은 정확히 세 번에서 멈춘다.
대가도 있다. 이제 정상 wasm도 끝까지 받은 뒤에 초기화하므로, 다운로드와 컴파일이 겹치던 이점이 줄고 잠깐 메모리에 버퍼가 하나 더 생긴다. 또 엔진 설정의 훅 하나를 바꿔 쓰는 방식이라, Godot 로더 버전을 올리면 이 회귀 검사를 반드시 다시 돌려야 한다. 이 두 가지는 작업 문서에 적어 두었다.
남은 것
1.42%가 얼마나 줄었는지는 아직 말할 수 없다. 이전에는 기록이 없었으니 비교할 기준도 없다. 실제 iPhone에서의 탭 숨김·복귀와 진짜 메모리 부족 종료도 재현하지 못했다. 그래도 이번엔 다음 오류가 어떤 종류인지는 알 수 있다. 크래시 비율을 줄이는 첫 단계가 그 비율 안에 무엇이 들었는지 보는 일이라는 걸, 수집을 켠 뒤 들어온 첫 오류 한 건이 알려 줬다.
포털 빌드 구조는 웹 게임 포털 일곱 곳에 맞추기에, wasm 크기를 줄인 과정은 Godot 웹 내보내기 글에 적었다. 이 로더로 도는 게임은 Salmonholic 게임 페이지에서 바로 해 볼 수 있다.


