--- id: web-pwa-service-worker title: PWA / Service Worker — Offline / Push / Install category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [web, pwa, service-worker, offline, vibe-coding] tech_stack: { language: "TS / Workbox", applicable_to: ["Frontend"] } applied_in: [] aliases: [PWA, Service Worker, Workbox, manifest, install prompt, push notification web] --- # PWA / Service Worker > **Web app 가 native 처럼**. Manifest + Service Worker = installable + offline + push. **Workbox** 가 표준 라이브러리. iOS 16.4+ web push 지원. ## 📖 핵심 개념 - Manifest: 앱 메타 (이름, icon, theme). - Service Worker: 네트워크 인터셉트, 캐싱, push. - Cache API: 자원 저장. - Background sync: 오프라인 → 온라인 시 sync. ## 💻 코드 패턴 ### Manifest ```json // public/manifest.webmanifest { "name": "Acme", "short_name": "Acme", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#3b82f6", "icons": [ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }, { "src": "/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ] } ``` ```html ``` ### Service Worker (단순 cache) ```ts // public/sw.js const CACHE = 'app-v1'; const ASSETS = ['/', '/app.css', '/app.js']; self.addEventListener('install', (e) => { e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS))); }); self.addEventListener('activate', (e) => { e.waitUntil( caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))) ) ); }); self.addEventListener('fetch', (e) => { if (e.request.method !== 'GET') return; e.respondWith( caches.match(e.request).then(r => r ?? fetch(e.request)) ); }); ``` ### Register ```ts if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').then(reg => { reg.addEventListener('updatefound', () => { const w = reg.installing; w?.addEventListener('statechange', () => { if (w.state === 'installed' && navigator.serviceWorker.controller) { showUpdatePrompt(); // 새 버전 있음 } }); }); }); } ``` ### Workbox (high-level) ```ts // workbox.config.ts (Vite plugin / Next config) import { generateSW } from 'workbox-build'; generateSW({ swDest: 'dist/sw.js', globDirectory: 'dist', globPatterns: ['**/*.{js,css,html,png,jpg,svg,woff2}'], runtimeCaching: [ { urlPattern: /^https:\/\/api\.example\.com/, handler: 'NetworkFirst', options: { cacheName: 'api', networkTimeoutSeconds: 5, expiration: { maxEntries: 50, maxAgeSeconds: 5 * 60 }, }, }, { urlPattern: /\.(?:png|jpg|webp)$/, handler: 'CacheFirst', options: { cacheName: 'images', expiration: { maxAgeSeconds: 30 * 24 * 3600 } }, }, ], }); ``` ### Vite-PWA plugin ```ts // vite.config.ts import { VitePWA } from 'vite-plugin-pwa'; plugins: [ VitePWA({ registerType: 'autoUpdate', workbox: { globPatterns: ['**/*.{js,css,html,png,svg}'] }, manifest: { /* 위 */ }, }), ]; ``` ### Install prompt ```ts let deferred: BeforeInstallPromptEvent | null = null; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e as BeforeInstallPromptEvent; showInstallButton(); }); installBtn.onclick = async () => { if (!deferred) return; await deferred.prompt(); const choice = await deferred.userChoice; console.log(choice.outcome); // 'accepted' / 'dismissed' deferred = null; }; ``` ### Push notification ```ts // 권한 const perm = await Notification.requestPermission(); if (perm !== 'granted') return; // Subscribe const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC_KEY, }); // 서버에 보내 — DB 에 저장 await fetch('/api/push/subscribe', { method: 'POST', body: JSON.stringify(sub) }); ``` ```ts // SW self.addEventListener('push', (event) => { const data = event.data?.json() ?? {}; event.waitUntil( self.registration.showNotification(data.title, { body: data.body, icon: '/icon-192.png', badge: '/badge.png', data: { url: data.url }, }) ); }); self.addEventListener('notificationclick', (event) => { event.notification.close(); event.waitUntil(self.clients.openWindow(event.notification.data.url)); }); ``` ### Server push (web-push) ```ts import webpush from 'web-push'; webpush.setVapidDetails('mailto:admin@acme.com', VAPID_PUBLIC, VAPID_PRIVATE); await webpush.sendNotification(subscription, JSON.stringify({ title: 'New message', body: 'You have a new message', url: '/inbox', })); ``` ### Background sync ```ts // SW self.addEventListener('sync', (event) => { if (event.tag === 'sync-orders') { event.waitUntil(syncOrders()); } }); // App const reg = await navigator.serviceWorker.ready; await reg.sync.register('sync-orders'); // 오프라인 → 다음 online 에 자동 ``` ### Update flow (smooth) ```ts // 사용자에 새 버전 알림 → "Refresh" 버튼 // 한번에 reload — 데이터 잃지 않게 자동 X ``` ```ts // Workbox skipWaiting import { Workbox } from 'workbox-window'; const wb = new Workbox('/sw.js'); wb.addEventListener('waiting', () => { if (confirm('New version available. Refresh?')) { wb.messageSkipWaiting(); window.location.reload(); } }); ``` ## 🤔 의사결정 기준 | 사용 | 추천 | |---|---| | Static + cache | CacheFirst | | API + freshness | NetworkFirst (timeout fallback) | | Image / font | CacheFirst with TTL | | Real-time | Network only | | Offline-first | Workbox + IndexedDB | | Push | VAPID + web-push | ## ❌ 안티패턴 - **모든 자원 cache + version 안 bump**: 새 버전 안 받음. - **API 까지 long cache**: stale 데이터. - **SW 무한 cache 자라남**: expiration / quota. - **Update 강제 (skipWaiting)**: 진행 중 작업 잃음. - **Push 권한 즉시 page load 시**: 사용자 거부 + 차단. - **VAPID key client 노출 (private)**: server only. - **iOS push 가정 — 안 install**: PWA 설치 후만 push 작동. ## 🤖 LLM 활용 힌트 - Workbox 가 SW 직접 보다 단순. - VitePWA / next-pwa plugin. - Push = VAPID + web-push + iOS 설치 필요. ## 🔗 관련 문서 - [[Web_HTTP_Cache_Headers]] - [[Web_Service_Worker_Patterns]] - [[iOS_Push_Notifications]]