PWA
Progressive Web App
앱처럼 동작하는 웹 앱. 오프라인 지원, 푸시 알림.
Progressive Web App
앱처럼 동작하는 웹 앱. 오프라인 지원, 푸시 알림.
PWA(Progressive Web App)는 웹 기술(HTML, CSS, JavaScript)로 구축되지만 네이티브 앱과 유사한 사용자 경험을 제공하는 웹 애플리케이션입니다. 브라우저에서 실행되지만 홈 화면에 설치할 수 있고, 오프라인에서도 동작하며, 푸시 알림을 보낼 수 있습니다. 앱 스토어 등록 없이 URL만으로 배포가 가능하여 업데이트가 즉시 반영됩니다.
PWA의 핵심 기술은 Service Worker와 Web App Manifest입니다. Service Worker는 브라우저와 네트워크 사이에서 동작하는 프록시 스크립트로, 네트워크 요청을 가로채 캐시된 응답을 반환하거나 백그라운드 동기화, 푸시 알림 처리를 담당합니다. Web App Manifest는 앱 이름, 아이콘, 테마 색상 등 앱의 메타데이터를 정의하는 JSON 파일입니다.
PWA는 점진적(Progressive)으로 향상됩니다. Service Worker를 지원하지 않는 브라우저에서도 일반 웹사이트처럼 동작하고, 지원하는 브라우저에서는 오프라인 기능과 설치 기능이 활성화됩니다. 이러한 특성 덕분에 하나의 코드베이스로 웹과 모바일을 모두 지원할 수 있습니다.
Workbox는 Google에서 제공하는 PWA 개발 라이브러리로, Service Worker 작성을 단순화합니다. Next.js, Nuxt, Vite 등 현대 프레임워크들은 PWA 플러그인을 통해 쉽게 PWA 기능을 추가할 수 있습니다. Lighthouse 도구를 사용하면 PWA 준수 여부를 검사하고 개선 사항을 확인할 수 있습니다.
// === manifest.json - Web App Manifest ===
{
"name": "My PWA App",
"short_name": "MyApp",
"description": "PWA 예제 애플리케이션",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#a855f7",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"screenshots": [
{
"src": "/screenshots/mobile.png",
"sizes": "1080x1920",
"type": "image/png",
"form_factor": "narrow"
}
]
}
// === index.html - Manifest 및 Service Worker 등록 ===
<!DOCTYPE html>
<html>
<head>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#a855f7">
<!-- iOS Safari 지원 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
</head>
<body>
<script>
// 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 (error) {
console.log('SW registration failed:', error);
}
});
}
</script>
</body>
</html>
// === sw.js - Service Worker (캐시 전략) ===
const CACHE_NAME = 'my-pwa-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/icons/icon-192.png'
];
// 설치 이벤트 - 정적 자원 캐싱
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('Caching static assets');
return cache.addAll(STATIC_ASSETS);
})
);
// 대기 중인 Service Worker 즉시 활성화
self.skipWaiting();
});
// 활성화 이벤트 - 오래된 캐시 정리
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
// 모든 클라이언트에 즉시 적용
self.clients.claim();
});
// Fetch 이벤트 - 캐시 우선 전략 (Cache First)
self.addEventListener('fetch', (event) => {
// API 요청은 네트워크 우선
if (event.request.url.includes('/api/')) {
event.respondWith(networkFirst(event.request));
return;
}
// 정적 자원은 캐시 우선
event.respondWith(cacheFirst(event.request));
});
// 캐시 우선 전략
async function cacheFirst(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
// 유효한 응답만 캐시
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// 오프라인 폴백 페이지
return caches.match('/offline.html');
}
}
// 네트워크 우선 전략
async function networkFirst(request) {
try {
const networkResponse = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
return networkResponse;
} catch (error) {
const cachedResponse = await caches.match(request);
return cachedResponse || new Response('Offline', { status: 503 });
}
}
// === 푸시 알림 처리 ===
self.addEventListener('push', (event) => {
const data = event.data?.json() || {
title: '알림',
body: '새로운 소식이 있습니다.',
icon: '/icons/icon-192.png'
};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
badge: '/icons/badge-72.png',
data: data.url
})
);
});
// 알림 클릭 처리
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data || '/')
);
});
// === 앱에서 푸시 알림 구독 ===
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
// 서버에 구독 정보 전송
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' }
});
}
"네이티브 앱과 웹 둘 다 만들어야 하는데 예산이 부족해요. 어떻게 하면 좋을까요?"
"PWA를 고려해보세요. 하나의 웹 코드베이스로 모바일 앱처럼 동작하는 앱을 만들 수 있어요. 홈 화면 설치, 오프라인 지원, 푸시 알림까지 가능하고, 앱 스토어 등록도 필요 없어서 업데이트가 즉시 반영됩니다. 다만 일부 네이티브 기능(NFC, 블루투스 등)은 제한적이에요."
"PWA가 오프라인에서 동작한다고 하는데, 어떻게 가능한 거예요?"
"Service Worker가 네트워크 요청을 가로채서 캐시된 응답을 반환해요. 처음 방문할 때 HTML, CSS, JS, 이미지 등을 캐싱해두고, 오프라인일 때는 캐시에서 응답합니다. 캐시 전략에 따라 Cache First, Network First 등을 선택할 수 있어요."
"PWA의 설치 조건은 무엇인가요?"
"HTTPS로 서비스되어야 하고, 유효한 manifest.json 파일이 필요합니다. manifest에는 name, icons, start_url, display 속성이 필수예요. 그리고 fetch 이벤트를 처리하는 Service Worker가 등록되어 있어야 합니다. 이 조건을 충족하면 브라우저가 설치 프롬프트를 표시해요."
HTTPS 필수: PWA는 localhost를 제외하고 반드시 HTTPS로 서비스되어야 합니다. Service Worker는 보안상 이유로 안전한 컨텍스트에서만 동작합니다. 개발 시에는 localhost에서 테스트하고, 배포 시 SSL 인증서를 적용하세요.
캐시 무효화: Service Worker의 캐시는 사용자가 직접 삭제하기 전까지 유지됩니다. 새 버전 배포 시 캐시 이름을 변경(버저닝)하고, 오래된 캐시를 정리하는 로직을 구현해야 합니다. 그렇지 않으면 사용자가 구 버전을 계속 보게 됩니다.
iOS Safari 제한: iOS Safari는 PWA 기능을 제한적으로 지원합니다. 푸시 알림은 iOS 16.4부터 지원되며, 백그라운드 동기화는 여전히 미지원입니다. 홈 화면에 추가해도 주소창이 보이지 않을 뿐 일부 기능이 제한됩니다.