PWA(Progressive Web App)는 웹 앱과 네이티브 앱의 장점을 모두 갖춘 웹 앱이다.
일반 웹 앱은 브라우저 탭 안에서만 동작하고 오프라인이 되면 흰 화면이 뜬다.
네이티브 앱은 기능이 풍부하지만 iOS/Android 각각 따로 만들어야 하고 앱스토어 심사도 거쳐야 한다.
PWA는 그 중간이다. 웹 기술(HTML/CSS/JS)로 만들지만 홈 화면에 설치할 수 있고 오프라인에서도 동작하며, 푸시 알림도 받을 수 있다.
| 구분 | 웹 앱 | PWA | 네이티브 앱 |
|---|---|---|---|
| 설치 | 불필요 | 홈 화면 추가 가능 | 앱스토어 필요 |
| 오프라인 | 불가 | 가능 (캐싱) | 가능 |
| 푸시 알림 | 불가 | 가능 | 가능 |
| 개발 비용 | 낮음 | 낮음 | 높음 (플랫폼별) |
| 앱스토어 심사 | 없음 | 없음 | 필요 |
| SEO | 가능 | 가능 | 불가 |
트위터가 PWA를 도입한 대표적인 사례이다.
인터넷 속도나 기기 성능이 낮은 모바일 사용자를 위한 경량화 버전으로 웹사이트 접속 대기시간을 30% 이상 줄이고 로딩 속도를 75% 이상 향상시켰다고 한다.
PWA라고 불리려면 아래 조건을 갖춰야 한다.
manifest.jsonService Worker + Cache APIBackground SyncPush APIService Worker는 PWA의 가장 큰 핵심기능 중 하나이다.
Service Worker를 이해하려면 일반 JavaScript와의 차이부터 알안ㅂ자
일반 JavaScript는 메인 스레드에서 실행된다.
HTML 페이지에 여러 JS 파일이 로딩되더라도 모두 하나의 스레드에서 동작하며 DOM에 자유롭게 접근할 수 있다.
하지만 Service Worker는 다르다.
JS 파일이긴 하지만 메인 스레드와 완전히 분리된 백그라운드 스레드에서 실행된다.
그래서 두 가지 중요한 특징이 생겨난다.
Service Worker는 단독으로 동작하지 않고 이벤트에 반응하는 방식으로 동작한다.
주요 이벤트는 다음과 같다.
Fetch 이벤트
브라우저가 HTML, CSS, JS, 이미지 등 리소스를 로딩할 때마다 발생한다. Service Worker를 네트워크 프록시처럼 활용할 수 있다. 모든 요청을 가로채서 캐시에서 반환하거나 네트워크로 전달하거나 선택할 수 있다.
주의할 점이 하나 있다. Ajax 요청, 즉 XMLHttpRequest는 fetch 이벤트를 발생시키지 않는다. fetch() API로 보낸 요청만 해당된다.
그래서 axios는 내부적으로 XMLHttpRequest를 사용하기 때문에 Service Worker의 fetch 이벤트에 잡히지 않습니다.
다만 axios도 최신 버전에서는 브라우저 환경에 따라 fetch를 사용하도록 설정할 수 있고 아예 fetch 기반의 ky나 wretch 같은 라이브러리로 교체하는 방법도 있다.
PWA에서 오프라인 지원이나 캐싱을 목적으로 쓰려는 거라면 axios 대신 네이티브 fetch를 쓰는 게 자연스럽다.
Push Notification 이벤트
푸시 알림은
서버 → 브라우저 벤더의 Push 서버(Chrome은 Google) → 클라이언트 순으로 전달된다. Service Worker가 이 push 이벤트를 수신한다.
왜 일반 페이지 JS가 아니라 Service Worker에서 처리해야 하냐면 페이지가 닫혀있어도 Service Worker는 백그라운드에서 실행되기 때문이다.
사용자가 앱을 열지 않아도 알림을 받을 수 있다.
Notification Interaction 이벤트
푸시 알림을 받은 후 사용자가 알림을 클릭했을 때의 동작을 처리한다.
예를 들어 특정 URL로 이동시키거나 캐시에서 데이터를 불러오는 동작을 여기서 구현한다.
Background Sync 이벤트
인터넷 연결이 불안정한 환경에서 특정 작업을 수행하고 싶을 때 사용한다.
지금은 안 되더라도 인터넷이 재연결되면 실행되도록 이벤트를 발행할 수 있다.
브라우저가 닫혀있는 상황에서도 가능하다.
다만 Safari 지원이 아직 불안정하므로 실무에서는 주의가 필요하다!
register() → install → (waiting) → activate → idle → terminated(일시적)
install 단계
Service Worker가 처음 설치될 때 딱 한 번 실행된다.
이 시점에 오프라인에서 필요한 정적 자산들을 미리 캐싱해두는 작업을 주로 한다.
waiting 단계
기존에 열려있는 탭이나 윈도우가 있으면 새 Service Worker는 install은 되지만 activate되지 않고 대기 상태로 머문다.
그 이유는 페이지가 기존 Service Worker와 통신 중인데 새 버전으로 갑자기 교체되면 잘못된 데이터를 가져올 수 있기 때문이다.
activate 단계
이전에 실행 중인 Service Worker가 없다면 install 직후 바로 activate된다.
이 단계에서 오래된 캐시를 정리하는 작업을 한다.
idle → terminated
Service Worker가 activate되면 백그라운드에서 idle 상태가 된다.
이벤트가 없으면 아무것도 하지 않다가 일정 시간이 지나면 terminated된다.
삭제나 등록 해제가 되는 게 아니라 Sleeping 상태가 되는 것이다.
이벤트를 수신하면 다시 깨어난다.
Service Worker의 fetch 이벤트에서 네트워크 요청을 어떻게 처리할지 전략을 정해야 한다.
상황에 따라 다른 전략을 선택하는 게 핵심이다.
캐시를 먼저 확인하고 있으면 바로 반환한다.
캐시에 없을 때만 네트워크로 요청한다.
// 캐시 있으면 즉시 반환 없으면 네트워크
caches.match(request).then((cached) => cached || fetch(request))
사용 예 — 폰트, 아이콘, 로고처럼 거의 변경되지 않는 정적 자산.
네트워크를 먼저 시도하고, 실패하면 캐시로 폴백한다.
fetch(request)
.then((res) => {
// 성공하면 캐시에 저장하고 반환
const clone = res.clone()
caches.open('v1').then((cache) => cache.put(request, clone))
return res
})
.catch(() => caches.match(request)) // 실패하면 캐시 반환
사용 예 — API 응답, 자주 바뀌는 HTML.
캐시를 즉시 반환하면서, 동시에 네트워크 요청을 보내 캐시를 업데이트한다.
caches.match(request).then((cached) => {
const networkFetch = fetch(request).then((res) => {
caches.open('v1').then((cache) => cache.put(request, res.clone()))
return res
})
return cached || networkFetch // 캐시 있으면 즉시 반환, 없으면 네트워크 기다림
})
사용 예 — 뉴스 피드, 프로필 이미지처럼 조금 오래돼도 괜찮은 데이터.
캐시에서만 응답하고 네트워크 요청을 보내지 않는다.
사용 예 — install 시 미리 캐시해둔 App Shell(HTML 껍데기, 기본 CSS 등). 완전히 오프라인에서도 껍데기는 보여줘야 할 때 사용한다.
캐시 없이 항상 네트워크로만 요청한다.
언제 쓰나 — 결제, 인증처럼 반드시 서버와 통신해야 하는 요청. 캐시된 낡은 데이터를 쓰면 안 되는 경우에 사용한다.
next-pwa 같은 라이브러리 없이 순수 Next.js로 직접 구현해보자
라이브러리가 편하긴 하지만 내부를 모르면 문제가 생겼을 때 대응이 어렵다.
apps/web/
├── public/
│ ├── ServiceWorker.js # Service Worker 직접 작성
│ ├── manifest.json # 앱 메타정보
│ └── icons/
│ ├── icon-192x192.png
│ └── icon-512x512.png
└── app/
├── layout.tsx # 메타 태그 + SW 등록
├── offline/
│ └── page.tsx # 오프라인 폴백 페이지
└── components/
└── ServiceWorkerRegister.tsx
{
"name": "Jiyu App",
"short_name": "App",
"description": "Jiyu PWA application",
"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"
}
]
}
display: "standalone"이 핵심이다. 이 값을 설정해야 브라우저 주소창 없이 앱처럼 뜬다.
아이콘은 192x192와 512x512 두 가지가 필수이다.
// public/ServiceWorker.js
const CACHE_NAME = 'my-app-v1'
const PRECACHE_URLS = [
'/',
'/offline',
]
// install — 정적 자산 pre-caching
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(PRECACHE_URLS)
})
)
self.skipWaiting()
})
// activate — 이전 캐시 정리
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
)
self.clients.claim()
})
// fetch — 네트워크 요청 가로채기
self.addEventListener('fetch', (event) => {
// POST 요청이나 크롬 익스텐션 요청은 무시
if (
event.request.method !== 'GET' ||
event.request.url.startsWith('chrome-extension')
) return
event.respondWith(
caches.match(event.request).then((cached) => {
return (
cached ||
fetch(event.request).catch(() => {
// 네트워크도 실패하면 오프라인 페이지 반환
if (event.request.destination === 'document') {
return caches.match('/offline')
}
})
)
})
)
})
skipWaiting()을 호출하면 waiting 단계 없이 새 SW가 바로 activate된다. clients.claim()은 현재 열려있는 페이지를 즉시 제어하게 해준다.
Next.js App Router에서 navigator는 클라이언트에서만 접근 가능하다.
'use client'로 분리된 컴포넌트에서 useEffect를 통해 등록해야 한다.
// app/components/ServiceWorkerRegister.tsx
'use client'
import { useEffect } from 'react'
export default function ServiceWorkerRegister() {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/ServiceWorker.js')
.then((reg) => {
console.log('SW registered:', reg.scope)
// 새 버전 감지
reg.addEventListener('updatefound', () => {
const newSW = reg.installing
newSW?.addEventListener('statechange', () => {
if (newSW.state === 'installed' && navigator.serviceWorker.controller) {
// 새 버전 배포 시 사용자에게 알림
if (confirm('새 버전이 있어요. 업데이트할까요?')) {
window.location.reload()
}
}
})
})
})
.catch((err) => console.error('SW registration failed:', err))
}
}, [])
return null
}
// app/layout.tsx
import type { Metadata } from 'next'
import ServiceWorkerRegister from './components/ServiceWorkerRegister'
export const metadata: Metadata = {
manifest: '/manifest.json',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<head>
<meta name="theme-color" content="#000000" />
{/* IOS Safari는 manifest를 완전히 지원하지 않아 별도 메타 태그 필요 */}
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
</head>
<body>
<ServiceWorkerRegister />
{children}
</body>
</html>
)
}
IOS Safari는 manifest.json을 완전히 지원하지 않는다.
apple-mobile-web-app-capable 같은 메타 태그를 별도로 달아줘야 IOS에서 홈 화면 추가 시 제대로 동작한다.
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div>
<h1>오프라인 상태예요</h1>
<p>인터넷 연결을 확인하고 다시 시도해주세요.</p>
</div>
)
}
ServiceWorker.js 파일 자체를 브라우저가 캐싱해버리면 새 버전을 배포해도 Service Worker가 갱신되지 않는 문제가 생긴다.
반드시 no-cache 헤더를 설정해야 한다.
// next.config.ts
const nextConfig = {
async headers() {
return [
{
source: '/ServiceWorker.js',
headers: [
{
key: 'Content-Type',
value: 'application/javascript; charset=utf-8',
},
{
key: 'Cache-Control',
value: 'no-cache, no-store, must-revalidate',
},
],
},
]
},
}
export default nextConfig
next-pwa 라이브러리를 사용하면
npm install @ducanh2912/next-pwa
라이브러리 설치 후
// next.config.js
const withPWA = require('@ducanh2912/next-pwa').default({
dest: 'public',
});
module.exports = withPWA({ /* 기존 설정 */ });
withPWA로 해당 라이브러리를 감싼 후 public/manifest.json 파일만 생성하면 세팅이 끝난다!
오늘은 PWA에 대해서 알아보았다.