IndexedDB로 게임 상태 저장하고 불러오기
IndexedDB는 브라우저에서 영구 게임 데이터를 다루는 가장 좋은 방법입니다. 대용량 데이터를 처리하고, 오프라인에서 동작하며, 메인 스레드를 막지 않습니다.
1) 왜 localStorage 대신 IndexedDB인가?
| 기능 | localStorage | IndexedDB |
|---|---|---|
| 저장 용량 한도 | 약 5-10 MB | 50+ MB (보통 GB 단위) |
| 데이터 타입 | 문자열만 | 객체, blob, 배열 |
| 비동기 | 아니요 (막힘) | 예 |
| 인덱스 쿼리 | 아니요 | 예 |
게임이라면 IndexedDB가 거의 항상 옳은 선택입니다.
2) 데이터베이스 열기
js
function openGameDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('MyGame', 1)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (event) => {
const db = event.target.result
// 오브젝트 스토어 생성
if (!db.objectStoreNames.contains('saves')) {
db.createObjectStore('saves', { keyPath: 'slot' })
}
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' })
}
}
})
}3) 게임 상태 저장하기
js
async function saveGame(slot, gameState) {
const db = await openGameDB()
return new Promise((resolve, reject) => {
const tx = db.transaction('saves', 'readwrite')
const store = tx.objectStore('saves')
const saveData = {
slot,
state: gameState,
timestamp: Date.now(),
}
const request = store.put(saveData)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}4) 게임 상태 불러오기
js
async function loadGame(slot) {
const db = await openGameDB()
return new Promise((resolve, reject) => {
const tx = db.transaction('saves', 'readonly')
const store = tx.objectStore('saves')
const request = store.get(slot)
request.onsuccess = () => resolve(request.result?.state || null)
request.onerror = () => reject(request.error)
})
}5) 모든 저장 목록 보기
js
async function listSaves() {
const db = await openGameDB()
return new Promise((resolve, reject) => {
const tx = db.transaction('saves', 'readonly')
const store = tx.objectStore('saves')
const request = store.getAll()
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}6) 저장 삭제하기
js
async function deleteSave(slot) {
const db = await openGameDB()
return new Promise((resolve, reject) => {
const tx = db.transaction('saves', 'readwrite')
const store = tx.objectStore('saves')
const request = store.delete(slot)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}7) 완전한 GameStorage 클래스
js
class GameStorage {
constructor(dbName = 'GameData', version = 1) {
this.dbName = dbName
this.version = version
this.db = null
}
async init() {
this.db = await this.openDB()
}
openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (e) => {
const db = e.target.result
if (!db.objectStoreNames.contains('saves')) {
db.createObjectStore('saves', { keyPath: 'slot' })
}
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' })
}
if (!db.objectStoreNames.contains('assets')) {
db.createObjectStore('assets', { keyPath: 'url' })
}
}
})
}
async save(slot, data) {
const tx = this.db.transaction('saves', 'readwrite')
tx.objectStore('saves').put({ slot, data, timestamp: Date.now() })
return tx.complete
}
async load(slot) {
const tx = this.db.transaction('saves', 'readonly')
const result = await this.promisify(tx.objectStore('saves').get(slot))
return result?.data || null
}
async setSetting(key, value) {
const tx = this.db.transaction('settings', 'readwrite')
tx.objectStore('settings').put({ key, value })
}
async getSetting(key, defaultValue = null) {
const tx = this.db.transaction('settings', 'readonly')
const result = await this.promisify(tx.objectStore('settings').get(key))
return result?.value ?? defaultValue
}
promisify(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
}8) 바이너리 데이터 저장하기 (텍스처, 오디오)
IndexedDB는 Blob과 ArrayBuffer를 그대로 다룹니다.
js
async function cacheAsset(url, blob) {
const tx = db.transaction('assets', 'readwrite')
tx.objectStore('assets').put({ url, blob, cached: Date.now() })
}
async function getCachedAsset(url) {
const tx = db.transaction('assets', 'readonly')
const result = await promisify(tx.objectStore('assets').get(url))
return result?.blob || null
}9) 자동 저장 패턴
js
class AutoSave {
constructor(storage, interval = 60000) {
this.storage = storage
this.interval = interval
this.timer = null
this.dirty = false
}
markDirty() {
this.dirty = true
}
start(getState) {
this.timer = setInterval(async () => {
if (this.dirty) {
await this.storage.save('autosave', getState())
this.dirty = false
console.log('Autosaved')
}
}, this.interval)
}
stop() {
clearInterval(this.timer)
}
}10) 에러 처리와 폴백
js
async function safeLoad(slot, defaultState) {
try {
const saved = await loadGame(slot)
if (saved) {
// 필요하면 오래된 저장을 검증하거나 마이그레이션
return migrateSave(saved)
}
} catch (err) {
console.warn('Failed to load save:', err)
}
return defaultState
}
function migrateSave(save) {
// 오래된 저장 포맷 처리
if (!save.version) {
save.version = 1
save.settings = save.settings || {}
}
return save
}11) 저장이 삭제되지 않게 하기
IndexedDB는 계속 남아 있는다는 보장이 없습니다. 기본적으로 오리진은 "최선 노력(best-effort)" 저장소를 쓰고, 디스크가 가득 차거나, Safari/WebKit에서는 사용자가 한동안 사이트와 상호작용하지 않으면 브라우저가 데이터를 삭제할 수 있습니다. 게임 저장이라면 절대 잃고 싶지 않은 바로 그 데이터죠.
영구 저장소를 요청하면 명시적인 사용자 동작 없이는 브라우저가 데이터를 지우지 않습니다.
js
async function makeStoragePersistent() {
if (navigator.storage && navigator.storage.persist) {
const persisted = await navigator.storage.persist()
console.log(persisted ? 'Saves are protected from eviction' : 'Saves may be evicted under storage pressure')
return persisted
}
return false
}브라우저는 참여도 신호(사용자가 사이트와 얼마나 상호작용하는지, PWA로 설치되어 있는지 등)를 바탕으로 허용 여부를 결정하니, 항상 성공한다고 가정하지 마세요. 대용량 저장이나 캐시된 에셋을 쓰기 전에 남은 공간이 얼마나 되는지 확인할 수도 있습니다.
js
async function checkStorage() {
if (navigator.storage && navigator.storage.estimate) {
const { usage, quota } = await navigator.storage.estimate()
console.log(`Using ${usage} of ${quota} bytes`)
}
}두 API 모두 보안 컨텍스트(HTTPS 또는 localhost)가 필요합니다.
관련 글
- 오프라인 게임을 위한 PWA
- 게임 캐싱을 위한 Service Worker
- 빠르게 로딩되는 웹 게임 배포하기
- 스트리밍 에셋 로딩 — IndexedDB를 에셋 캐시로 사용하기
- 웹 게임 분석 — 저장/불러오기 패턴을 추적해 플레이어 행동 이해하기
외부 자료
- MDN: IndexedDB API — 전체 API 레퍼런스
- MDN: Using IndexedDB — 단계별 가이드
- idb library — Promise 기반의 아주 작은 IndexedDB 래퍼