도입
2025년, PWA(Progressive Web App)는 더 이상 실험적 기술이 아닙니다. Twitter(X), Starbucks, Pinterest, Spotify 등 수많은 대형 서비스가 PWA를 도입하여 네이티브 앱에 버금가는 사용자 경험을 제공하고 있습니다. Google Play Store와 Microsoft Store에서는 PWA를 직접 등록할 수 있으며, iOS도 점진적으로 PWA 지원을 확대하고 있습니다.
이 가이드에서는 Service Worker의 생명주기부터 5가지 캐싱 전략, Workbox 활용, 푸시 알림, 오프라인 우선 아키텍처, Next.js PWA 통합, 그리고 앱스토어 배포까지 체계적으로 다룹니다.
> **핵심 표현 정리**
>
> | 표현 | 의미 |
> |------|------|
> | **Service Worker** | 브라우저 백그라운드에서 실행되는 JavaScript 프록시. 네트워크 요청 가로채기, 캐싱, 푸시 알림 처리 |
> | **Cache-First Strategy** | 캐시에서 먼저 응답하고, 없으면 네트워크에서 가져오는 전략. 오프라인 지원의 핵심 |
> | **Web App Manifest** | PWA의 메타데이터를 정의하는 JSON 파일. 앱 이름, 아이콘, 시작 URL, 디스플레이 모드 등 |
> | **Workbox** | Google이 만든 Service Worker 라이브러리. 캐싱 전략, 프리캐싱, 라우팅을 쉽게 구현 |
1. PWA란 무엇인가?
1.1 PWA의 핵심 특성
PWA는 웹 기술로 구축되면서도 네이티브 앱과 같은 경험을 제공하는 애플리케이션입니다.
PWA의 3가지 핵심 요소:
┌─────────────────────────────────────────┐
│ Progressive Web App │
├─────────────┬─────────────┬─────────────┤
│ Reliable │ Fast │ Engaging │
│ (안정적) │ (빠른) │ (몰입감) │
│ │ │ │
│ - 오프라인 │ - 즉시 로드 │ - 홈화면 │
│ - 안정적 │ - 부드러운 │ - 전체화면 │
│ 네트워크 │ 스크롤 │ - 푸시 알림 │
│ │ - 60fps │ - 설치 가능 │
└─────────────┴─────────────┴─────────────┘
1.2 PWA vs 네이티브 앱 vs 일반 웹
| 기능 | 일반 웹 | PWA | 네이티브 앱 |
|------|---------|-----|-------------|
| 설치 필요 | 없음 | 선택적 | 필수 |
| 오프라인 지원 | 없음 | 있음 | 있음 |
| 푸시 알림 | 없음 | 있음 | 있음 |
| 앱스토어 배포 | 불가 | 가능 (TWA/PWABuilder) | 기본 |
| 업데이트 | 즉시 | 즉시/백그라운드 | 스토어 심사 필요 |
| 파일 크기 | 0 (URL 접근) | 수 KB ~ 수 MB | 수십~수백 MB |
| 하드웨어 접근 | 제한적 | 확대 중 | 완전 |
| 개발 비용 | 낮음 | 낮음 | 높음 (플랫폼별) |
| SEO | 우수 | 우수 | 불가 |
| 크로스 플랫폼 | 브라우저 | 브라우저 | 플랫폼별 개발 |
1.3 PWA 도입 성과 사례
주요 기업 PWA 성과:
Twitter Lite:
- 페이지당 데이터 소비 70% 감소
- 트윗 전송 75% 증가
- 이탈률 20% 감소
Pinterest:
- 광고 수익 44% 증가
- 핵심 참여 지표 60% 증가
- 로그인 사용자 40% 증가
Starbucks:
- 네이티브 앱 대비 99.84% 작은 크기
- 일일 활성 사용자 2배 증가
Uber:
- 3G에서 3초 이내 로드
- 핵심 앱 크기 50KB
2. Service Worker 생명주기
2.1 Service Worker란?
Service Worker는 브라우저와 네트워크 사이에서 동작하는 프록시 스크립트입니다. 메인 스레드와 별도의 스레드에서 실행되며, DOM에 직접 접근할 수 없습니다.
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Web App │────▶│Service Worker│────▶│ Network │
│ (Main │ │ (Proxy) │ │ (Server) │
│ Thread) │◀────│ │◀────│ │
└─────────────┘ │ ┌──────┐ │ └─────────────┘
│ │Cache │ │
│ │ API │ │
│ └──────┘ │
└──────────────┘
2.2 생명주기 단계
Service Worker 생명주기:
1. Registration (등록)
└─▶ navigator.serviceWorker.register('/sw.js')
2. Installation (설치)
└─▶ 'install' 이벤트 발생
└─▶ 정적 자원 프리캐싱
3. Waiting (대기)
└─▶ 이전 SW가 활성 상태면 대기
4. Activation (활성화)
└─▶ 'activate' 이벤트 발생
└─▶ 오래된 캐시 정리
5. Fetch (요청 가로채기)
└─▶ 'fetch' 이벤트로 네트워크 요청 제어
6. Idle / Terminated (유휴/종료)
└─▶ 이벤트 없으면 브라우저가 종료 가능
2.3 기본 Service Worker 구현
// sw.js - Service Worker 파일
const CACHE_NAME = 'my-pwa-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png',
'/offline.html',
];
// Install 이벤트: 정적 자원 프리캐싱
self.addEventListener('install', (event) => {
console.log('[SW] Installing...');
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('[SW] Pre-caching static assets');
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting()) // 대기 건너뛰기
);
});
// Activate 이벤트: 오래된 캐시 정리
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
}).then(() => self.clients.claim()) // 즉시 제어 획득
);
});
// Fetch 이벤트: 요청 가로채기
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request);
})
.catch(() => {
// 네트워크 실패 시 오프라인 페이지
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
})
);
});
2.4 등록 코드
// main.js - 앱 진입점
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
console.log('SW registered:', registration.scope);
// 업데이트 확인
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// 새 버전 사용 가능
showUpdateNotification();
}
}
});
});
} catch (error) {
console.error('SW registration failed:', error);
}
});
}
function showUpdateNotification() {
const banner = document.createElement('div');
banner.innerHTML = '새 버전이 있습니다. <button id="reload">업데이트</button>';
banner.className = 'update-banner';
document.body.appendChild(banner);
document.getElementById('reload').addEventListener('click', () => {
window.location.reload();
});
}
3. 캐싱 전략 5가지
3.1 Cache First (캐시 우선)
캐시에 있으면 캐시에서, 없으면 네트워크에서 가져옵니다. 정적 자원에 최적입니다.
// Cache First 전략
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
});
})
);
});
요청 흐름:
Client ──▶ Cache ──(hit)──▶ 응답 반환
│
(miss)
│
▼
Network ──▶ 응답 반환 + 캐시 저장
3.2 Network First (네트워크 우선)
네트워크를 먼저 시도하고, 실패하면 캐시에서 가져옵니다. API 데이터에 적합합니다.
// Network First 전략
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => caches.match(event.request))
);
});
3.3 Stale While Revalidate (오래된 것 사용 후 갱신)
캐시에서 즉시 응답하면서, 백그라운드에서 네트워크로 캐시를 갱신합니다.
// Stale While Revalidate 전략
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
return cached || fetchPromise;
});
})
);
});
3.4 Cache Only / Network Only
// Cache Only - 프리캐싱된 자원에만 사용
self.addEventListener('fetch', (event) => {
event.respondWith(caches.match(event.request));
});
// Network Only - 캐싱이 불필요한 요청 (예: 분석 데이터)
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
3.5 전략 선택 가이드
| 전략 | 사용 사례 | 장점 | 단점 |
|------|-----------|------|------|
| Cache First | 정적 자원 (CSS, JS, 이미지) | 빠른 응답, 오프라인 | 업데이트 지연 |
| Network First | API 데이터, 뉴스 피드 | 항상 최신 | 느린 네트워크에서 지연 |
| Stale While Revalidate | 자주 변하는 정적 자원 | 빠르면서 최신 | 첫 요청은 오래된 데이터 |
| Cache Only | 버전화된 자원 | 완전한 오프라인 | 업데이트 불가 |
| Network Only | 결제, 인증 | 항상 최신 | 오프라인 불가 |
4. Web App Manifest
4.1 Manifest 작성
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "An awesome PWA that works offline",
"start_url": "/?source=pwa",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2196F3",
"orientation": "portrait-primary",
"scope": "/",
"lang": "ko",
"dir": "ltr",
"categories": ["productivity", "utilities"],
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Desktop view"
},
{
"src": "/screenshots/mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow",
"label": "Mobile view"
}
],
"shortcuts": [
{
"name": "New Task",
"short_name": "Task",
"url": "/tasks/new",
"icons": [{ "src": "/icons/task.png", "sizes": "96x96" }]
}
]
}
4.2 HTML에 Manifest 연결
<!DOCTYPE html>
<!-- Manifest 연결 -->
<!-- iOS 지원 -->
<!-- 테마 색상 -->
<!-- 앱 콘텐츠 -->
4.3 Display 모드 비교
| 모드 | 설명 | 브라우저 UI |
|------|------|-------------|
| `fullscreen` | 전체 화면, 상태바 숨김 | 없음 |
| `standalone` | 독립 앱처럼 표시 | 상태바만 |
| `minimal-ui` | 최소 브라우저 UI | 뒤로가기/새로고침 |
| `browser` | 일반 브라우저 탭 | 전체 브라우저 UI |
5. Workbox를 활용한 Service Worker
5.1 Workbox 소개
Workbox는 Google이 만든 Service Worker 라이브러리로, 캐싱 전략을 선언적으로 구현할 수 있습니다.
Workbox CLI 설치
npm install workbox-cli --save-dev
또는 Webpack 플러그인
npm install workbox-webpack-plugin --save-dev
5.2 Workbox로 캐싱 전략 구현
// sw.js with Workbox
CacheFirst,
NetworkFirst,
StaleWhileRevalidate,
} from 'workbox-strategies';
// 프리캐싱 (빌드 시 주입)
precacheAndRoute(self.__WB_MANIFEST);
// 이미지: Cache First + 만료 설정
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images-cache',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30일
}),
],
})
);
// CSS/JS: Stale While Revalidate
registerRoute(
({ request }) =>
request.destination === 'style' ||
request.destination === 'script',
new StaleWhileRevalidate({
cacheName: 'static-resources',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
],
})
);
// API 호출: Network First
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 5,
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5분
}),
],
})
);
// Google Fonts: Cache First
registerRoute(
({ url }) => url.origin === 'https://fonts.googleapis.com' ||
url.origin === 'https://fonts.gstatic.com',
new CacheFirst({
cacheName: 'google-fonts',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 30,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1년
}),
],
})
);
5.3 Workbox 빌드 설정
// workbox-config.js
module.exports = {
globDirectory: 'dist/',
globPatterns: [
'**/*.{html,js,css,png,jpg,svg,woff2}'
],
globIgnores: [
'admin/**/*',
'node_modules/**/*'
],
swDest: 'dist/sw.js',
swSrc: 'src/sw.js',
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
};
5.4 백그라운드 동기화
// Background Sync로 오프라인 폼 제출
const bgSyncPlugin = new BackgroundSyncPlugin('formQueue', {
maxRetentionTime: 24 * 60, // 24시간 (분 단위)
onSync: async ({ queue }) => {
let entry;
while ((entry = await queue.shiftRequest())) {
try {
await fetch(entry.request);
console.log('[BG Sync] Replayed:', entry.request.url);
} catch (error) {
console.error('[BG Sync] Failed:', error);
await queue.unshiftRequest(entry);
throw error;
}
}
},
});
// POST 요청에 Background Sync 적용
registerRoute(
({ url, request }) =>
url.pathname === '/api/submit' && request.method === 'POST',
new NetworkOnly({
plugins: [bgSyncPlugin],
}),
'POST'
);
6. 푸시 알림 (Push Notification)
6.1 푸시 알림 아키텍처
푸시 알림 흐름:
┌──────────┐ 구독 ┌──────────────┐
│ Client │─────────▶│ Push Server │
│ (Browser)│ │ (FCM/APNS) │
└────┬─────┘ └──────┬───────┘
│ 구독 정보 │
│ 전달 │
▼ │
┌──────────┐ 푸시 전송 ◀───┘
│ App │
│ Server │
└──────────┘
6.2 구독 구현
// 클라이언트: 푸시 알림 구독
async function subscribePush() {
// 알림 권한 요청
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('Notification permission denied');
return;
}
const registration = await navigator.serviceWorker.ready;
// VAPID 공개키 변환
const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
const convertedKey = urlBase64ToUint8Array(vapidPublicKey);
// 구독 생성
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedKey,
});
// 서버에 구독 정보 전송
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
console.log('Push subscription successful');
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
6.3 서버에서 푸시 전송
// Node.js 서버: web-push 라이브러리
const webpush = require('web-push');
// VAPID 키 설정
webpush.setVapidDetails(
'mailto:admin@example.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
// 푸시 메시지 전송
async function sendPushNotification(subscription, payload) {
try {
await webpush.sendNotification(
subscription,
JSON.stringify({
title: '새 메시지',
body: '김철수님이 메시지를 보냈습니다.',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
data: {
url: '/messages/123',
timestamp: Date.now(),
},
actions: [
{ action: 'reply', title: '답장', icon: '/icons/reply.png' },
{ action: 'dismiss', title: '닫기', icon: '/icons/close.png' },
],
})
);
console.log('Push sent successfully');
} catch (error) {
if (error.statusCode === 410) {
// 구독 만료 - DB에서 제거
await removeSubscription(subscription.endpoint);
}
console.error('Push failed:', error);
}
}
6.4 Service Worker에서 푸시 수신
// sw.js: 푸시 이벤트 처리
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : {};
const options = {
body: data.body || 'New notification',
icon: data.icon || '/icons/icon-192x192.png',
badge: data.badge || '/icons/badge-72x72.png',
vibrate: [200, 100, 200],
data: data.data || {},
actions: data.actions || [],
tag: data.tag || 'default',
renotify: true,
};
event.waitUntil(
self.registration.showNotification(data.title || 'Notification', options)
);
});
// 알림 클릭 처리
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const urlToOpen = event.notification.data.url || '/';
if (event.action === 'reply') {
// 답장 액션 처리
event.waitUntil(clients.openWindow(urlToOpen + '?action=reply'));
} else {
event.waitUntil(
clients.matchAll({ type: 'window' }).then((clientList) => {
// 이미 열린 창이 있으면 포커스
for (const client of clientList) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// 없으면 새 창 열기
return clients.openWindow(urlToOpen);
})
);
}
});
7. 오프라인 우선 (Offline-First) 아키텍처
7.1 IndexedDB를 활용한 로컬 데이터 저장
// IndexedDB 래퍼 (idb 라이브러리 사용)
class LocalStore {
constructor(dbName, storeName) {
this.dbName = dbName;
this.storeName = storeName;
}
async getDB() {
return openDB(this.dbName, 1, {
upgrade(db) {
if (!db.objectStoreNames.contains('tasks')) {
const store = db.createObjectStore('tasks', {
keyPath: 'id',
autoIncrement: true,
});
store.createIndex('status', 'status');
store.createIndex('syncStatus', 'syncStatus');
store.createIndex('updatedAt', 'updatedAt');
}
},
});
}
async getAll() {
const db = await this.getDB();
return db.getAll(this.storeName);
}
async get(id) {
const db = await this.getDB();
return db.get(this.storeName, id);
}
async add(item) {
const db = await this.getDB();
item.syncStatus = 'pending';
item.updatedAt = Date.now();
return db.add(this.storeName, item);
}
async update(item) {
const db = await this.getDB();
item.syncStatus = 'pending';
item.updatedAt = Date.now();
return db.put(this.storeName, item);
}
async delete(id) {
const db = await this.getDB();
return db.delete(this.storeName, id);
}
async getPendingSync() {
const db = await this.getDB();
const tx = db.transaction(this.storeName, 'readonly');
const index = tx.store.index('syncStatus');
return index.getAll('pending');
}
}
// 사용 예시
const taskStore = new LocalStore('myApp', 'tasks');
// 오프라인에서도 작업 추가 가능
await taskStore.add({
title: '새 할일',
status: 'active',
createdAt: Date.now(),
});
7.2 동기화 전략
// 온라인 복구 시 동기화
class SyncManager {
constructor(localStore, apiUrl) {
this.localStore = localStore;
this.apiUrl = apiUrl;
this.setupListeners();
}
setupListeners() {
window.addEventListener('online', () => this.sync());
}
async sync() {
const pendingItems = await this.localStore.getPendingSync();
for (const item of pendingItems) {
try {
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
if (response.ok) {
item.syncStatus = 'synced';
await this.localStore.update(item);
}
} catch (error) {
console.error('Sync failed for item:', item.id, error);
}
}
}
}
8. Next.js PWA 통합
8.1 next-pwa 설정
npm install @ducanh2912/next-pwa
// next.config.js
const withPWA = require('@ducanh2912/next-pwa').default({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
register: true,
skipWaiting: true,
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
reloadOnOnline: true,
workboxOptions: {
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1년
},
},
},
{
urlPattern: /\.(?:eot|otf|ttc|ttf|woff|woff2|font\.css)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-font-assets',
expiration: {
maxEntries: 4,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7일
},
},
},
{
urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-image-assets',
expiration: {
maxEntries: 64,
maxAgeSeconds: 24 * 60 * 60, // 24시간
},
},
},
{
urlPattern: /\/api\/.*$/i,
handler: 'NetworkFirst',
options: {
cacheName: 'apis',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 16,
maxAgeSeconds: 24 * 60 * 60,
},
},
},
],
},
});
module.exports = withPWA({
// Next.js 설정
reactStrictMode: true,
});
8.2 App Router에서 Manifest
// app/manifest.ts (Next.js App Router)
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'My Next.js PWA',
short_name: 'NextPWA',
description: 'A Progressive Web App built with Next.js',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [
{
src: '/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
};
}
8.3 오프라인 페이지 처리
// app/offline/page.tsx
export default function OfflinePage() {
return (
인터넷 연결을 확인해 주세요.
onClick={() => window.location.reload()}
className="px-6 py-3 bg-blue-500 text-white rounded-lg"
>
다시 시도
);
}
9. 앱스토어 배포
9.1 Google Play Store (TWA)
Trusted Web Activity(TWA)를 사용하여 PWA를 Google Play Store에 배포할 수 있습니다.
Bubblewrap으로 TWA 생성
npx @nicolo-ribaudo/bubblewrap init --manifest https://myapp.com/manifest.json
npx @nicolo-ribaudo/bubblewrap build
Google Play 배포 단계:
1. Digital Asset Links 설정
- /.well-known/assetlinks.json 파일 생성
- 앱 서명 인증서의 SHA-256 지문 포함
2. TWA 프로젝트 생성
- Bubblewrap 또는 PWABuilder 사용
- Android 프로젝트 자동 생성
3. 서명 및 빌드
- Android keystore 생성
- APK 또는 AAB 빌드
4. Play Console에 업로드
- 내부/비공개/프로덕션 트랙 선택
- 스토어 목록 작성
9.2 Microsoft Store (PWABuilder)
Microsoft Store 배포 단계:
1. PWABuilder (https://www.pwabuilder.com/) 접속
2. PWA URL 입력
3. 점수 확인 및 패키지 다운로드
4. MSIX 패키지 생성
5. Partner Center에서 앱 등록
6. MSIX 패키지 업로드
9.3 iOS (Safari 지원)
iOS PWA 제한사항 (2025년 기준):
지원:
- 홈 화면 추가
- 오프라인 캐싱
- Web App Manifest (일부)
- 기본 푸시 알림 (iOS 16.4+)
제한:
- 백그라운드 동기화 미지원
- 50MB 스토리지 제한
- 서드파티 브라우저에서 PWA 설치 불가
- 일부 Web API 미지원
10. PWA 성능 최적화
10.1 Lighthouse PWA 점검 항목
Lighthouse PWA 체크리스트:
[필수]
- HTTPS 사용
- Service Worker 등록
- 오프라인에서 200 응답
- Web App Manifest 존재
- start_url 설정
- 아이콘 (192px, 512px)
- viewport 메타 태그 설정
- theme-color 메타 태그
[권장]
- HTTP를 HTTPS로 리다이렉트
- 3G에서 빠른 로드 (10초 이내)
- 커스텀 스플래시 스크린
- 주소표시줄 테마 색상
- 뷰포트에 맞는 콘텐츠
- apple-touch-icon 설정
10.2 캐시 크기 관리
// 캐시 크기 모니터링
async function getCacheSize() {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
const usedMB = (estimate.usage / (1024 * 1024)).toFixed(2);
const quotaMB = (estimate.quota / (1024 * 1024)).toFixed(2);
console.log('Storage used:', usedMB, 'MB of', quotaMB, 'MB');
return estimate;
}
}
// 영구 스토리지 요청
async function requestPersistentStorage() {
if (navigator.storage && navigator.storage.persist) {
const granted = await navigator.storage.persist();
console.log('Persistent storage:', granted ? 'granted' : 'denied');
}
}
10.3 설치 프롬프트 커스터마이징
// 설치 프롬프트 제어
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
showInstallButton();
});
function showInstallButton() {
const installBtn = document.getElementById('install-btn');
installBtn.style.display = 'block';
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const result = await deferredPrompt.userChoice;
console.log('Install prompt result:', result.outcome);
deferredPrompt = null;
installBtn.style.display = 'none';
});
}
// 설치 완료 감지
window.addEventListener('appinstalled', () => {
console.log('PWA was installed');
deferredPrompt = null;
// 분석 이벤트 전송
analytics.track('pwa_installed');
});
11. 실전 퀴즈
각 문제를 풀고 답을 확인해 보세요.
**Registration - Installation - Waiting - Activation - Fetch (Idle/Terminated)**
등록(Registration) 후 설치(Installation)에서 정적 자원을 프리캐싱하고, 이전 SW가 있으면 대기(Waiting) 상태에 들어갑니다. 활성화(Activation)에서 오래된 캐시를 정리하고, 이후 Fetch 이벤트로 네트워크 요청을 제어합니다.
**정적 자원(CSS, JS 번들, 이미지, 폰트)**
변경 빈도가 낮고 버전 관리가 되는 자원에 적합합니다. 캐시에서 즉시 응답하므로 빠르고 오프라인에서도 동작합니다. API 데이터처럼 자주 변하는 데이터에는 Network First나 Stale While Revalidate가 적합합니다.
**독립 앱처럼 표시되며 브라우저 주소창이 없는 모드**
standalone 모드에서는 상태바만 표시되고, 일반 앱처럼 보입니다. fullscreen은 상태바도 없고, minimal-ui는 최소한의 브라우저 UI(뒤로가기 등)가 표시되며, browser는 일반 브라우저 탭에서 열립니다.
**오프라인 상태에서 실패한 네트워크 요청을 큐에 저장하고, 온라인 복구 시 자동 재시도하는 것**
예를 들어 오프라인 상태에서 폼 제출을 하면, 요청을 IndexedDB에 저장해두었다가 네트워크가 복구되면 자동으로 서버에 전송합니다. 이를 통해 오프라인에서도 데이터 손실 없이 작업할 수 있습니다.
**TWA (Trusted Web Activity)**
TWA는 Chrome Custom Tabs를 기반으로 PWA를 네이티브 Android 앱으로 래핑합니다. Digital Asset Links 파일(assetlinks.json)을 통해 앱과 웹사이트 간의 소유권을 증명합니다. Bubblewrap이나 PWABuilder 도구로 TWA 프로젝트를 쉽게 생성할 수 있습니다.
12. 참고 자료
1. [web.dev PWA 가이드](https://web.dev/progressive-web-apps/) - Google의 공식 PWA 가이드
2. [MDN Service Worker API](https://developer.mozilla.org/ko/docs/Web/API/Service_Worker_API) - Service Worker API 문서
3. [Workbox 공식 문서](https://developer.chrome.com/docs/workbox/) - Workbox 라이브러리 문서
4. [PWABuilder](https://www.pwabuilder.com/) - PWA 빌드 및 배포 도구
5. [Web Push Protocol](https://web.dev/push-notifications-overview/) - 웹 푸시 알림 개요
6. [Lighthouse](https://developer.chrome.com/docs/lighthouse/) - PWA 감사 도구
7. [next-pwa](https://github.com/nicolo-ribaudo/next-pwa) - Next.js PWA 플러그인
8. [idb](https://github.com/nicolo-ribaudo/idb) - IndexedDB Promise 래퍼
9. [Project Fugu](https://fugu-tracker.web.app/) - 웹 플랫폼 API 트래커
10. [Bubblewrap](https://github.com/nicolo-ribaudo/nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo) - TWA 빌드 도구
11. [What PWA Can Do Today](https://whatpwacando.today/) - PWA 기능 데모
12. [Microsoft PWA 문서](https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps-chromium/) - Microsoft Edge PWA 가이드
13. [Apple Developer - Web Apps](https://developer.apple.com/documentation/webkit/web-apps) - iOS PWA 문서
현재 단락 (1/852)
2025년, PWA(Progressive Web App)는 더 이상 실험적 기술이 아닙니다. Twitter(X), Starbucks, Pinterest, Spotify 등 수많은 ...