오프라인 웹 게임을 위한 PWA
Progressive Web App을 쓰면 플레이어가 게임을 설치해서 오프라인에서도 즐길 수 있습니다. 이 튜토리얼에서는 웹 게임에 PWA 지원을 추가하는 방법을 보여줍니다.
1) Web App Manifest
manifest.json을 만드세요:
{
"name": "My Awesome Game",
"short_name": "AwesomeGame",
"description": "An awesome game you can play offline",
"start_url": "/",
"display": "fullscreen",
"orientation": "landscape",
"background_color": "#1a1a2e",
"theme_color": "#4ade80",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}HTML에서 연결하세요:
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#4ade80">
<link rel="apple-touch-icon" href="/icons/icon-192.png">2) 기본 service worker
sw.js를 만드세요:
const CACHE_NAME = 'game-v1'
const ASSETS = [
'/',
'/index.html',
'/game.js',
'/style.css',
'/assets/sprites.png',
'/assets/sounds/jump.mp3',
'/assets/sounds/music.mp3',
]
// Install: 에셋 캐싱
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS)
})
)
})
// Activate: 오래된 캐시 정리
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
)
})
)
})
// Fetch: 캐시에서 제공하고, 없으면 네트워크로 폴백
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request)
})
)
})3) service worker 등록
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js')
console.log('SW registered:', registration.scope)
} catch (err) {
console.error('SW registration failed:', err)
}
})
}4) 캐시 우선 + 백그라운드 업데이트
게임에 더 잘 맞습니다. 빠르게 로딩하면서 백그라운드에서 업데이트하죠:
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then(async (cache) => {
const cached = await cache.match(event.request)
// 백그라운드에서 네트워크 요청 시작
const fetchPromise = fetch(event.request).then((response) => {
if (response.ok) {
cache.put(event.request, response.clone())
}
return response
}).catch(() => null)
// 캐시를 바로 반환하거나, 네트워크를 기다림
return cached || fetchPromise
})
)
})5) 업데이트를 위한 버전별 캐시
const CACHE_VERSION = 'v2'
const STATIC_CACHE = `static-${CACHE_VERSION}`
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`
const STATIC_ASSETS = [
'/',
'/index.html',
'/game.js',
// ... 거의 바뀌지 않는 핵심 에셋
]
self.addEventListener('install', (event) => {
self.skipWaiting() // 즉시 활성화
event.waitUntil(
caches.open(STATIC_CACHE).then(cache => cache.addAll(STATIC_ASSETS))
)
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => {
return Promise.all(
keys.filter(key => !key.includes(CACHE_VERSION))
.map(key => caches.delete(key))
)
})
)
clients.claim() // 즉시 제어권 인수
})6) 게임 업데이트 처리
새 버전이 나오면 플레이어에게 알리세요:
// 메인 앱
let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!refreshing) {
refreshing = true
showUpdateNotification()
}
})
function showUpdateNotification() {
const banner = document.createElement('div')
banner.innerHTML = `
<p>Game updated! Refresh to get the latest version.</p>
<button onclick="location.reload()">Refresh</button>
`
banner.className = 'update-banner'
document.body.appendChild(banner)
}7) 오프라인 감지
function updateOnlineStatus() {
if (navigator.onLine) {
hideOfflineBanner()
syncGameData()
} else {
showOfflineBanner()
}
}
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
function showOfflineBanner() {
document.getElementById('offline-banner').style.display = 'block'
}
function hideOfflineBanner() {
document.getElementById('offline-banner').style.display = 'none'
}8) 설치 프롬프트
let deferredPrompt = null
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredPrompt = e
showInstallButton()
})
function showInstallButton() {
const btn = document.getElementById('install-btn')
btn.style.display = 'block'
btn.addEventListener('click', installApp)
}
async function installApp() {
if (!deferredPrompt) return
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
console.log('Install prompt outcome:', outcome)
deferredPrompt = null
document.getElementById('install-btn').style.display = 'none'
}
window.addEventListener('appinstalled', () => {
console.log('App installed!')
deferredPrompt = null
})iOS와 iPadOS에서는 beforeinstallprompt 이벤트가 절대 발생하지 않습니다. 그래서 위의 설치 버튼은 Chromium 기반 브라우저에서만 나타납니다. iPhone과 iPad 사용자는 공유 버튼을 누른 다음 "홈 화면에 추가"를 탭해서 설치합니다. 2026년에 알아둘 만한 변화가 하나 있습니다. Safari 26(iOS 26 / iPadOS 26)부터는 홈 화면에 추가한 모든 사이트가 기본적으로 웹 앱으로 열리며, 기본으로 켜져 있는 "웹 앱으로 열기" 토글이 생겼습니다. 이제 iOS에서 설치 가능 요건은 사라졌지만, 그래도 manifest와 service worker를 제공하면 오프라인 경험이 훨씬 좋아집니다.
9) 리더보드를 위한 백그라운드 동기화
지원에 대한 주의사항입니다. Background Sync API는 Chromium 기반 브라우저(Chrome, Edge, Opera, Samsung Internet)에서만 구현되어 있습니다. Firefox와 Safari(iOS 포함)는 지원하지 않으니, 항상 기능 감지를 하고 다음 정상 앱 로드 때 점수를 제출하는 폴백을 함께 두세요.
// service worker 안에서
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-scores') {
event.waitUntil(syncScores())
}
})
async function syncScores() {
const db = await openDB('game', 1)
const pendingScores = await db.getAll('pending-scores')
for (const score of pendingScores) {
try {
await fetch('/api/scores', {
method: 'POST',
body: JSON.stringify(score),
headers: { 'Content-Type': 'application/json' }
})
await db.delete('pending-scores', score.id)
} catch (err) {
// 다음 동기화 때 재시도
break
}
}
}
// 메인 앱에서
async function submitScore(score) {
try {
await fetch('/api/scores', { method: 'POST', body: JSON.stringify(score) })
} catch {
// 나중에 동기화하려고 저장
const db = await openDB('game', 1)
await db.add('pending-scores', { ...score, id: Date.now() })
if ('serviceWorker' in navigator && 'sync' in window.registration) {
await navigator.serviceWorker.ready
await registration.sync.register('sync-scores')
}
}
}10) PWA 테스트
Chrome DevTools:
- Application > Service Workers
- Application > Manifest
- Application > Cache Storage
- Network > Offline 체크박스
Lighthouse 감사:
- PWA 감사 실행
- 설치 가능성 확인
- 오프라인 동작 확인
실기기 테스트:
- 휴대폰 홈 화면에 설치
- 비행기 모드 켜기
- 모든 기능을 오프라인에서 테스트
관련 글
- 게임 캐싱을 위한 Service Worker
- IndexedDB 게임 저장
- 빠르게 로딩되는 웹 게임 출시하기
- 모바일 친화적인 웹 게임 — PWA 게임을 위한 터치 컨트롤과 뷰포트 처리
- 스트리밍 에셋 로딩 — service worker 캐싱과 잘 맞는 로딩 전략
외부 자료
- MDN: Progressive Web Apps — 전체 PWA 문서
- web.dev: Learn PWA — Google의 PWA 학습 경로
- MDN: Web App Manifest — manifest 파일 레퍼런스