6.6 KiB
6.6 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| web-pwa-service-worker | PWA / Service Worker — Offline / Push / Install | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
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
// 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" }
]
}
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#3b82f6">
<link rel="apple-touch-icon" href="/icon-192.png">
Service Worker (단순 cache)
// 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
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)
// 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
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: { globPatterns: ['**/*.{js,css,html,png,svg}'] },
manifest: { /* 위 */ },
}),
];
Install prompt
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
// 권한
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) });
// 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)
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
// 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)
// 사용자에 새 버전 알림 → "Refresh" 버튼
// 한번에 reload — 데이터 잃지 않게 자동 X
// 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 설치 필요.