Skip to content

게임 에셋 캐싱을 위한 service worker

service worker는 에셋이 어떻게 캐싱되고 제공되는지를 직접 제어하게 해줍니다. 게임에서는 재방문 시 즉시 로드되고 오프라인 지원도 받는다는 뜻입니다.

1) Service worker 기초

루트에 sw.js를 만드세요.

js
// sw.js
self.addEventListener('install', (event) => {
  console.log('Service worker installing')
})

self.addEventListener('activate', (event) => {
  console.log('Service worker activated')
})

self.addEventListener('fetch', (event) => {
  // 모든 네트워크 요청을 가로챕니다
})

등록하세요.

js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(reg => console.log('SW registered'))
    .catch(err => console.error('SW registration failed:', err))
}

2) 캐시 우선 전략 (게임 에셋에 가장 좋음)

캐시에서 읽고, 캐시에 없을 때만 가져옵니다.

js
const CACHE_NAME = 'game-assets-v1'

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) {
        return cached
      }
      
      return fetch(event.request).then((response) => {
        // ok가 아닌 응답이나 GET이 아닌 요청은 캐싱하지 않음
        if (!response.ok || event.request.method !== 'GET') {
          return response
        }
        
        // 캐싱을 위해 응답을 복제
        const clone = response.clone()
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, clone)
        })
        
        return response
      })
    })
  )
})

3) 핵심 에셋 미리 캐싱하기

install 이벤트는 미리 캐싱하기에 딱 좋습니다.

js
const CACHE_NAME = 'game-v1'

const PRECACHE = [
  '/',
  '/index.html',
  '/game.js',
  '/style.css',
  '/assets/sprites.png',
  '/assets/tileset.png',
  '/assets/player.png',
]

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(PRECACHE))
      .then(() => self.skipWaiting())
  )
})

4) 동적 콘텐츠는 네트워크 우선

자주 바뀌는 것(리더보드, 플레이어 데이터)에는 네트워크 우선을 쓰세요.

js
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)
  
  // API 호출: 네트워크 우선
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request))
    return
  }
  
  // 에셋: 캐시 우선
  event.respondWith(cacheFirst(event.request))
})

async function networkFirst(request) {
  try {
    const response = await fetch(request)
    const cache = await caches.open('api-cache')
    cache.put(request, response.clone())
    return response
  } catch {
    return caches.match(request)
  }
}

async function cacheFirst(request) {
  const cached = await caches.match(request)
  if (cached) return cached
  
  const response = await fetch(request)
  const cache = await caches.open(CACHE_NAME)
  cache.put(request, response.clone())
  return response
}

5) Stale-while-revalidate

캐시를 즉시 반환하고, 백그라운드에서 업데이트합니다.

js
async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_NAME)
  const cached = await cache.match(request)
  
  const fetchPromise = fetch(request).then((response) => {
    if (response.ok) {
      cache.put(request, response.clone())
    }
    return response
  })
  
  return cached || fetchPromise
}

6) 캐시 버전 관리와 정리

activate 시점에 오래된 캐시를 정리하세요.

js
const CACHE_VERSION = 'v2'
const CACHE_NAME = `game-${CACHE_VERSION}`

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys
          .filter(key => key.startsWith('game-') && key !== CACHE_NAME)
          .map(key => {
            console.log('Deleting old cache:', key)
            return caches.delete(key)
          })
      )
    }).then(() => self.clients.claim())
  )
})

7) 타입별로 캐시 분리하기

관리가 편하도록 캐시를 정리하세요.

js
const CACHES = {
  static: 'static-v1',    // HTML, JS, CSS
  images: 'images-v1',    // 스프라이트, 텍스처
  audio: 'audio-v1',      // 음악, 효과음
  api: 'api-v1',          // API 응답
}

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)
  
  if (url.pathname.match(/\.(png|jpg|webp)$/)) {
    event.respondWith(cacheFirst(event.request, CACHES.images))
  } else if (url.pathname.match(/\.(mp3|ogg|wav)$/)) {
    event.respondWith(cacheFirst(event.request, CACHES.audio))
  } else if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request, CACHES.api))
  } else {
    event.respondWith(cacheFirst(event.request, CACHES.static))
  }
})

8) 오디오/비디오용 Range 요청

먼저 알아둘 점이 하나 있습니다. Cache API는 부분 응답을 저장하지 않습니다. 206 Partial Content 응답이 cache.put()에 도달하면 TypeError: Failed to execute 'put' on 'Cache': Partial response (status code 206) is unsupported 오류를 던집니다. 그래서 캐싱된 미디어의 패턴은 항상 같습니다. 전체 파일을 일반 200 응답으로 미리 캐싱(또는 fetch)하고, Range 헤더가 붙은 요청이 들어올 때마다 캐싱된 바이트를 잘라서 206을 직접 만들어 주는 것입니다. 이걸 직접 손으로 짜기 싫다면, Workbox의 range-requests 플러그인이 CacheFirst 전략과 함께 쓰일 때 바로 그 자르기 작업을 대신 해줍니다.

range 요청을 제대로 처리하세요 (오디오 탐색에 필요).

js
self.addEventListener('fetch', (event) => {
  if (event.request.headers.has('range')) {
    event.respondWith(handleRangeRequest(event.request))
    return
  }
  // ... 일반 처리
})

async function handleRangeRequest(request) {
  const cache = await caches.open(CACHES.audio)
  // Range 헤더를 무시하고 전체 리소스를 매칭 (Range 헤더로는 캐싱된 전체 파일이 절대 매칭되지 않음)
  let cached = await cache.match(request.url)

  if (!cached) {
    // 전체 파일을 fetch (문자열 URL이면 Range 헤더가 빠짐)하고 200으로 캐싱
    cached = await fetch(request.url)
    if (cached.ok) cache.put(request.url, cached.clone()) // cache.put은 206을 거부하므로 전체 200만 저장
  }

  // 캐싱된 전체 본문에서 206 Partial Content 응답을 만듦
  const rangeHeader = request.headers.get('range') // 예: "bytes=200-1000"
  const buffer = await cached.arrayBuffer()
  const total = buffer.byteLength
  const [startStr, endStr] = rangeHeader.replace(/bytes=/, '').split('-')
  const start = Number(startStr)
  const end = endStr ? Number(endStr) : total - 1
  const slice = buffer.slice(start, end + 1)

  return new Response(slice, {
    status: 206,
    statusText: 'Partial Content',
    headers: {
      'Content-Type': cached.headers.get('Content-Type') || 'application/octet-stream',
      'Content-Range': `bytes ${start}-${end}/${total}`,
      'Content-Length': String(slice.byteLength),
      'Accept-Ranges': 'bytes',
    },
  })
}

9) 업데이트 알림

새 버전이 준비되면 플레이어에게 알려주세요.

js
// 메인 앱에서
let refreshing = false

navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (refreshing) return
  refreshing = true
  
  showUpdateBanner()
})

function showUpdateBanner() {
  const banner = document.createElement('div')
  banner.className = 'update-banner'
  banner.innerHTML = `
    <span>New version available!</span>
    <button onclick="location.reload()">Update</button>
  `
  document.body.appendChild(banner)
}

10) 완성된 게임 service worker

js
const VERSION = 'v1'
const STATIC_CACHE = `static-${VERSION}`
const ASSET_CACHE = `assets-${VERSION}`

const PRECACHE_URLS = [
  '/',
  '/index.html',
  '/game.js',
  '/style.css',
]

// install: 핵심 리소스를 미리 캐싱
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then(cache => cache.addAll(PRECACHE_URLS))
      .then(() => self.skipWaiting())
  )
})

// activate: 오래된 캐시 정리
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(keys => Promise.all(
      keys
        .filter(key => !key.endsWith(VERSION))
        .map(key => caches.delete(key))
    )).then(() => self.clients.claim())
  )
})

// fetch: 요청을 알맞은 전략으로 라우팅
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)
  
  // 동일 출처만
  if (url.origin !== location.origin) {
    return
  }
  
  // API: 네트워크 우선 + 캐시 폴백
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request, 'api-cache'))
    return
  }
  
  // 에셋: 캐시 우선
  if (url.pathname.startsWith('/assets/')) {
    event.respondWith(cacheFirst(event.request, ASSET_CACHE))
    return
  }
  
  // HTML/JS/CSS: stale while revalidate
  event.respondWith(staleWhileRevalidate(event.request, STATIC_CACHE))
})

async function cacheFirst(request, cacheName) {
  const cached = await caches.match(request)
  if (cached) return cached
  
  try {
    const response = await fetch(request)
    if (response.ok) {
      const cache = await caches.open(cacheName)
      cache.put(request, response.clone())
    }
    return response
  } catch {
    return new Response('Offline', { status: 503 })
  }
}

async function networkFirst(request, cacheName) {
  try {
    const response = await fetch(request)
    if (response.ok) {
      const cache = await caches.open(cacheName)
      cache.put(request, response.clone())
    }
    return response
  } catch {
    return caches.match(request) || new Response('Offline', { status: 503 })
  }
}

async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName)
  const cached = await cache.match(request)
  
  const fetchPromise = fetch(request).then(response => {
    if (response.ok) {
      cache.put(request, response.clone())
    }
    return response
  }).catch(() => cached)
  
  return cached || fetchPromise
}

관련 글

외부 자료