---
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
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#3b82f6">
<link rel="apple-touch-icon" href="/icon-192.png">
```

### 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]]
