웹 게임을 만들다가 문제가 있었습니다.
게임이다보니 이미지, 사운드같은 에셋들이 많았는데요

웹에서 그냥 하듯이 <img src="foo.png">처럼 하면 이렇게 유저 경험이 꽤 유쾌하지 못했는데
- 이미지가 준비되는 동안 화면이 하얗게 깜빡이고
- 레이아웃 시프트가 발생하기도 하며
- 이미지가 한번에 도착하지 못하여 점진적으로 보이게 되기도 했습니다.
또한 사운드의 경우, 효과음은 어떤 액션에 대해 바로 소리가 나오기를 기대하지만
재생해야 할 때가 되어서야 사운드 파일을 받아오기 시작한다면
들려야 할 타이밍보다 3초가 더 지나서야 소리가 갑자기 재생되는 불상사가 발생하기도 합니다.
이러한 문제를 개선하고 더 게임스럽게 경험을 제공할 방법을 생각했고
로딩 시간을 두어 미리 에셋을 준비하고 바로 쓸 수 있게 메모리에 올려두는 방법을 택했습니다.
실제로 이를 통해 아래와 같이 좀 더 매끄러운 게임 경험을 줄 수 있었고요(결과는 끝에서 공개)
게이머들이 기대하는 것
게임과 웹페이지는 로딩에 대한 기준이 좀 다르다고 느끼는데
웹에서는 보통 페이지/화면 전환 간에 필요할 때마다 필요한 만큼 필요한 것을 로딩을 표현하는 것이 보통입니다.
반면 게임은 본격적인 게임 시작에 앞서 로딩 화면을 지나는 것이 자연스럽게 납득됩니다.

게이머들도 이 로딩 단계를 자연스럽게 여기고 받아들이며,
대신 로딩화면을 기다린 후에는 지연을 느끼지 않는 경험을 기대합니다.

그래서 우리 게임도 게임을 시작할 때 로딩 화면을 지나고,
이 로딩화면에서 에셋들을 미리 로드해두기로 했습니다.

그러나 여기에는 별다른 딸깍이 있는게 아니고
- 당분간 사용할 에셋들을 전부, 정적 파일이든 CDN에서 원격으로 받아오는 이미지든, 모두 리스트업
- 에셋들을 미리 서버에서 가져와 브라우저 메모리에 올린다. (
fetch->blob->createObjectUrl(blob)) - 각 애셋마다 생성한
objectUrl들을 value로, key는 원본 url로 하여, Map객체에 넣어두고 꺼내 쓴다.
이런 과정을 모두 구현하고, 특히 React 라이프사이클에서 편하게 쓸 수 있도록 구현해야 합니다.
이번 글은 이러한 에셋 프리로딩을 구현한 과정을 다룹니다.
데이터를 미리 갖고있으려면?
고려해야 하는 리소스는 이미지, 사운드 등 여러 유형이 있지만, 이미지만 생각해봅시다.
웹 브라우저에서 이미지를 보여줄 때는 <img src="foo.png">처럼 하기 마련입니다
이 <img src="foo.png">는 네트워크 요청을 시작하고, foo.png에서 데이터를 받아오고, 화면에 보여줍니다.

<img>로 이미지를 받아오는 것은 "화면에 보여준다"를 전제로 합니다.
fetch -> blob
화면에 보여주지 않을건데 이미지가 궁금하면 fetch()해봅니다.
const res = await fetch("foo.png");
const fooBlob = await res.blob();받아온 결과물을 Blob으로 변환했습니다.
blob은 파일 같은 것들을 불변 바이너리 데이터로 나타낸 객체입니다.
blob -> objectUrl
이 blob 객체 자체는 데이터와 그 데이터 타입(MIME Type), 사이즈 정도의 정보만 가집니다.
또한 blob 객체의 데이터는 밀봉되어있어서, 이 상태로는 읽거나 수정할 수 없습니다.
이 blob 객체를 <img>에 src로 넣고 싶어지면, objectUrl을 발급해줍니다.
const fooObjectUrl = URL.createObjectUrl(fooBlob);
<img src={fooObjectUrl}>createObjectUrl은 blob 그 데이터에 접근할 수 있도록 허용된 키를 발급합니다.
이 키는 blob:https://sugnpaks.github.io/...와 같이 url처럼 생겼습니다.
이제 앞으로 이 키(objectUrl)를 url대신 보여주면 브라우저 메모리로부터 데이터를 받을 수 있습니다.
이제 objectUrl을 사용하면 네트워크 요청을 갔다오지 않고,
메모리에서 바로 꺼낼 수 있으므로 요청 갭이 거의 1ms 수준이 되어버립니다.
물건 가지러 집까지 갔다올 필요 없이 주머니에서 꺼내는 셈입니다. 마치 캐싱(caching) 같네요!

이미지 url로 네트워크 요청 (좌) -> 약 351ms(네트워크 상황과 용량 등에 따라 변동),
objectUrl로 메모리에서 꺼냄 (우) -> 약 1ms
주의할 점은 이 때부터 키에 대한 책임이 생깁니다.
그 키에 대한 데이터가 더 이상 필요없다면 폐기해야 메모리 누수를 방지할 수 있습니다.
다만 탭을 닫거나 새로고침할 때는 알아서 정리되니 (데이터를 저장한 보관함 자체가 없어짐) 그거까진 신경쓰지 않아도 됩니다.
objectUrl -> decoding?
이제 url에 네트워크 요청해서 받아오는 것보다 objectUrl을 만들어두어
마치 캐싱하듯이 아주 빨라질 수 있음을 보았는데요
여기서 "메모리를 더 많이 쓰는 대신 리소스를 진짜 바로 사용할 수 있다" 는 속박을 걸 수도 있습니다.
아까 살펴본 objectUrl, 그리고 이것으로 접근할 수 있는 blob데이터는 아직 포장된 상태입니다
이것을 사용하려면 포장을 뜯는 과정이 필요한데 이것을 디코딩(Decoding) 이라고 부릅니다.
예를 들어, 이미지는 아래와 같이 디코딩해둘 수 있습니다.
const img = new Image();
img.src = objectUrl;
await img.decode();이제 사용할 때는 그냥 다시 objectUrl을 src로 넣으면 되는데,
디코딩된 데이터가 이미 objectUrl과 매핑된 채로 메모리 캐시에 올라가있기 때문입니다.
<img src={objectUrl} />이렇게 디코딩까지 미리 해두었다면 이미지를 새로 넣을 타이밍에 지연은 이론상 거의 0에 가까워질 수 있습니다.
그러나 이건 좀 트레이드 오프가 클 수 있어서 잘 결정하고 설계해야 하는데
미리 압축을 풀어두는 거라서, 메모리 부담이 매우 커집니다.
예를 들어 제가 1920x1080 이미지를 하나 랜덤픽해서 보니까 530KB였는데요
이걸 만약 디코딩하여 비트맵으로 미리 만들어둔다고 해봅시다.
비트맵 한 픽셀 당 4byte(rgba 하나씩 0~255 1byte 사용)이라고 생각해서 계산하면
1920x1080x4 = 8MB를 넘어버립니다.

사진 출처 SBS 뉴스
그러니 만약 디코딩까지 해서 캐싱한다면 정말 곧 필요한 리소스만 추려야겠습니다.
또는 중요도를 나누거나, 등 세부적인 캐싱 전략을 잘 세워야 할 것 같아요
또 하나 디코딩까지 해서 관리하는 경우 귀찮아지는 점은,
오디오 파일은 디코딩 후 objectUrl이 아닌
디코딩 결과로 받은 arrayBuffer를 저장해두고 사용해야 한다는 점입니다
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const arrayBuffer = await blob.arrayBuffer();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);이런 식으로 리소스 유형들을 한번에 objectUrl로 통일하여 관리할 수 없으므로,
관리포인트가 늘어난다는 점이 귀찮을 수도 있습니다.
오디오 파일은 decode까지, 이미지 파일은 objectUrl
디코딩까지 하면 정말 지연 없이 리소스를 사용할 수 있다는 점이 좋지만
위에서 살펴봤던 그 속박의 단점으로 메모리 사용량이 크게 늘어난다는 점이 부담이었습니다.
이로 인해 이미지들은 디코딩하지 않고 objectUrl까지만 가기로 했습니다.
사용되는 이미지가 꽤 많아서 디코딩까지 해둔다면 생각보다 큰 부담이 될 것 같다고 생각해서입니다.
물론 사용처를 분류하고, 그에 따른 캐싱 전략을 잘 설계하여 이것을 컨트롤할 수도 있지만,
이 작업을 할 때에는 이미 거기에 쏟을 시간적 여유가 없는 상태였습니다 ㅠㅠ

나중엔 게임 단계별로 사용되는 이미지들을 미리 분류해두고,
이에 따라 로딩화면을 차등적으로 지나게 한다면,
여기에 디코딩까지 추가하여 완전해질 수 있도록 개선할 수 있어보이네요
오디오의 경우, BGM은 프리로딩하지 않고, 효과음만 프리로드합니다.
게다가 효과음은 디코딩까지 선행해두기로 했습니다.
효과음은 매우 짧기 마련이고, 종류도 많지 않아서(5~10개) 큰 부담이 되지 않을거라 생각했어요
또한 바빠지기 전에 GPT 5 Codex랑 범용 사운드 모듈을 만들어뒀었는데
이 때 preload를 디코딩까지 다 포함해 구현해뒀어서 추가 작업이 필요없었습니다.
구현하기
이제부터 리소스 목록을 가져다 미리 로딩하고
이를 objectUrl로 저장하며
저장소에서 objectUrl을 편하게 꺼내 쓸 수 있도록 구현하고자 합니다.
assetStore (with Zustand)
프리로드한 에셋들을 objectUrl형태로 저장하는 저장소를 만들건데요
어디서든 글로벌 스코프에서 사용해야 하므로 zustand store로 구현해봅니다.
import { create } from "zustand";
type AssetStatus = "idle" | "loading" | "loaded" | "error";
interface AssetEntry {
status: AssetStatus;
blob?: Blob;
objectUrl?: string;
error?: string;
}
interface AssetState {
entries: Map<string, AssetEntry>;
setLoading: (url: string) => void;
setLoaded: (url: string, blob: Blob, objectUrl: string) => void;
setError: (url: string, error: string) => void;
getObjectUrl: (url: string) => string | undefined;
revoke: (url: string) => void;
revokeAll: () => void;
getObjectUrl: (url: string) => string | undefined;
}
export const useAssetStore = create<AssetState>((set, get) => ({
entries: new Map(),
setLoading: url =>
set(s => {
const next = new Map(s.entries);
next.set(url, { status: "loading" });
return { entries: next };
}),
setLoaded: (url, blob, objectUrl) =>
set(s => {
const next = new Map(s.entries);
next.set(url, { status: "loaded", blob, objectUrl });
return { entries: next };
}),
setError: (url, error) =>
set(s => {
const next = new Map(s.entries);
next.set(url, { status: "error", error });
return { entries: next };
}),
revoke: url =>
set(s => {
const next = new Map(s.entries);
const e = next.get(url);
if (e?.objectUrl) URL.revokeObjectURL(e.objectUrl);
next.delete(url);
return { entries: next };
}),
revokeAll: () =>
set(s => {
for (const [, e] of s.entries)
if (e.objectUrl) URL.revokeObjectURL(e.objectUrl);
return { entries: new Map() };
}),
getObjectUrl: url => get().entries.get(url)?.objectUrl || url
}));- 에셋들을 Map 객체에 key-value쌍으로 저장합니다.
- key는 원본 url입니다.
- value에는 objectUrl, blob(혹시 또 쓸 데가 있을 수도), status 등의 값들을 유지합니다.
- 로딩을 시작할 때
setLoading(url)로 "이 url은 현재 로딩중"표시합니다. - 로딩이 끝났다면
setLoaded(url, blob, objectUrl)로 "로딩 완료" + "objectUrl/blob을 저장"합니다. - 특정 url, 또는 모든 url을 메모리에서 폐기(revoke)하는 메서드도 만들어둡니다.
- url에 대응하는 objectUrl을 얻는
getObjectUrl메서드를 만들어 값에 접근할 수 있게 합니다.- 만약 해당 url이 캐싱되지 않았어도(objectUrl이 없어도) url을 그대로 반환합니다.
이렇게 해서 에셋 캐싱 저장소가 만들어졌습니다.
usePreloadAsset
이제 url들을 프리로딩하고 objectUrl을 뽑아서, assetStore에 차곡차곡 넣어봅시다.
import { useEffect } from "react";
import { useAssetStore } from "./assetStore";
interface Options {
concurrency?: number;
onProgress?: (done: number, total: number) => void;
signal?: AbortSignal;
requestInit?: RequestInit;
}
/**
* @description urls를 미리 받아 Blob/ObjectURL로 스토어에 저장
* @param urls 미리 받을 파일 경로 배열
* @param opts 옵션
*/
export function usePreloadAssets(urls: string[], opts: Options = {}) {
const { setLoading, setLoaded, setError, entries } = useAssetStore();
const { concurrency = 4, onProgress, signal, requestInit } = opts;
useEffect(() => {
if (!urls?.length) return;
const uniq = Array.from(new Set(urls));
const pending = uniq.filter(u => entries.get(u)?.status !== "loaded");
if (!pending.length) return;
let done = 0;
let aborted = false;
const ac = new AbortController();
const sig = signal ?? ac.signal;
const run = async () => {
const queue = pending.slice();
const workers = Array.from(
{ length: Math.min(concurrency, queue.length) },
async () => {
while (!aborted && queue.length) {
const url = queue.shift()!;
try {
setLoading(url);
const res = await fetch(url, {
signal: sig,
...requestInit
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
setLoaded(url, blob, objectUrl);
} catch (e: unknown) {
const error = e as Error;
if (error?.name !== "AbortError")
setError(url, error?.message ?? "fetch failed");
} finally {
done += 1;
onProgress?.(done, pending.length);
}
}
}
);
await Promise.all(workers);
};
run();
return () => {
aborted = true;
ac.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(urls)]);
}- url 배열을 받아서, 중복을 제거하고 pending배열을 만듭니다. 이제부터 pending배열에 있는 url모두를 preload
- concurrency(동시성)옵션에서 지정한 만큼 워커를 만들고 병렬처리합니다.
- 각 워커들은 pending을 복사한 배열 queue에서 url을 꺼내 처리
- 각 url을 fetch -> blob -> objectUrl
AbortController를 두어, cleanup에서 네트워크 요청이 정리될 수 있게 준비- 또는 외부에서
signal을 옵션으로 전달받아 외부에서 중단 가능
- 또는 외부에서
done++,onProgress(done, pending.length)로 "100개 중 15개 완료"처럼 표시할 수 있습니다.- 의존성은 url의 실제 내용이 변해야만 반응하도록 stringify로 잡았고, 옵션은 처음 옵션에서 바뀌지 않는다고 생각했습니다.
오디오 파일 preload
오디오 시스템을 관장하는 싱글톤 클래스가 이미 있었는데요
export class AudioManager {
private static instance: AudioManager | undefined;
...
}대충 이렇게 생겼고, 여기에 BGM과 사운드의 채널 분리, 플레이와 전환 등의 메서드 등등을 포함합니다.
또한 버퍼 캐시 해시맵도 포함하고 있고, 아래와 같이 preload라는 메서드가 구현됩니다.
async preload(url: string): Promise<AudioBuffer> {
if (this.bufferCache.has(url)) {
return this.bufferCache.get(url)!;
}
if (this.loadingBuffers.has(url)) {
return this.loadingBuffers.get(url)!;
}
const job = (async () => {
const response = await fetch(url, { mode: 'cors' });
if (!response.ok) {
throw new Error(`Failed to fetch audio: ${url}`);
}
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this.ctx.decodeAudioData(arrayBuffer.slice(0));
this.bufferCache.set(url, audioBuffer);
this.loadingBuffers.delete(url);
return audioBuffer;
})().catch(error => {
this.loadingBuffers.delete(url);
console.error(error);
throw error;
});
this.loadingBuffers.set(url, job);
return job;
}- url들을 가져다가 fetch하고, 이번엔 arrayBuffer로 변환합니다.
ctx.decodeAudioData로 arrayBuffer를 디코딩합니다. 이렇게 디코딩되면 바로 재생가능한 상태가 됩니다.- 나중에 어떤 url을 재생하는 메서드를 실행할 때, 여기에 있으면 그대로 가져다 사용하고 없으면 위 로딩 과정을 거쳐 재생합니다.
활용하기
이제 로딩 화면에서, 이렇게 한 줄만 추가하면 에셋 프리로딩을 시작합니다.
// assets = ["foo.cdn.com/1.png", "foo.cdn.com/2.webp", ..]
usePreloadAssets(assets, {});주로 이미지들은 cdn에 등록되어 있으므로,
백엔드에 cdn 리소스 목록을 반환하는 엔드포인트를 요청했습니다.
지금은 이게 그냥 문자열의 배열인데, 게임 단계별로 분류되게 개선하거나
에셋들에 우선순위를 표시하도록 개선하거나, 하면 효율적이겠네요.
로딩률은 아래와 같이 계산하면 될 것 같은데요
const assetEntries = useAssetStore(useShallow(state => state.entries));
const total = assets.length;
const loaded = Array.from(assetEntries.values()).filter(
entry => entry.status === "loaded"
).length;
const progressPercent = (loaded / total) * 100;생각해보면 assetStore의 헬퍼함수로 넣어버려도 괜찮을 듯.
이제 이미지를 사용할 때는요
const { itemName, itemImageUrl } = item;
const getObjectUrl = useAssetStore(getObjectUrlSelector);
return <img alt={itemName} src={getObjectUrl(itemImageUrl)} />;아주 쉽네요
itemImageUrl이 캐시되지 않아 objectUrl이 없는 경우라도
url 그대로 들어가기때문에 따로 신경쓸 필요가 없습니다.
끝
이렇게 완성한 로딩화면을 게임 시작 시 지나게 하고
objectUrl을 사용하도록 적용하면 확실히 나아집니다.

이만 마칩니다.