PWA 설정
웹 앱 매니페스트와 서비스 워커를 설정하고 캐시·오프라인·설치 가능 여부를 운영 빌드에서 검증합니다.
현대 웹 애플리케이션은 단순 정보 제공을 넘어 네이티브 앱에 가까운 경험을 제공하는 방향으로 진화하고 있습니다.
프로그레시브 웹 앱(Progressive Web App, PWA)은 이 목표를 달성하기 위한 웹 기술 집합입니다.
앱 스토어 설치 없이도 모바일 앱처럼 동작하도록 웹의 접근성과 기능을 함께 끌어올립니다.
Next.js는 PWA 구현에 필요한 기능을 지원하고, 라이브러리와 설정을 통해 비교적 쉽게 PWA로 전환할 수 있습니다.
이 절에서는 App Router의 매니페스트 규칙과 직접 작성한 서비스 워커를 바탕으로 설치, 오프라인 지원, 업데이트, 푸시 알림의 경계를 익힙니다.
PWA란 무엇이며 왜 중요한가요?
PWA는 웹 기술(HTML, CSS, JavaScript)로 구축되지만, 웹의 장점(접근성, SEO)과 네이티브 앱의 장점(오프라인 지원, 푸시 알림, 홈 화면 설치)을 결합한 애플리케이션입니다.
PWA는 점진적 향상(Progressive Enhancement) 원칙을 따르므로, 최신 PWA 기능을 지원하는 브라우저에서는 앱 같은 경험을 제공하고 그렇지 않은 브라우저에서는 일반 웹사이트처럼 작동합니다.
PWA의 핵심 기능 및 이점- 오프라인 대응: 서비스 워커와 캐시를 이용해 네트워크 실패 시 제공할 화면이나 기능을 정할 수 있습니다. 구현한 캐시 범위를 벗어난 기능은 오프라인에서 동작하지 않을 수 있습니다.
- 빠른 로딩 (Fast): 자산과 응답 특성에 맞는 캐싱 전략을 적용하면 반복 방문과 불안정한 네트워크에서 로딩 시간을 줄일 수 있습니다. 잘못된 캐시 정책은 오래된 데이터나 불필요한 저장 공간을 만들 수 있으므로 측정이 필요합니다.
-
높은 참여도 (Engaging)
- 홈 화면 설치(Add to Home Screen): 사용자가 웹사이트를 스마트폰의 홈 화면에 추가하여 앱처럼 실행할 수 있습니다.
- 푸시 알림(Push Notifications): 사용자에게 실시간 알림을 보내 재방문을 유도하고 참여도를 높입니다.
- 앱 표시 모드: 매니페스트와 브라우저 지원에 따라 별도 앱 창으로 실행할 수 있습니다. 아래의
standalone은 운영 체제 UI까지 없애는fullscreen과 다릅니다.
- 접근성 및 발견 용이성: URL을 통해 접근 가능하고 검색 엔진에 노출됩니다. 앱 스토어 설치 과정 없이 바로 사용할 수 있습니다.
- 점진적 호환성: 일반 웹 경험은 폭넓은 브라우저와 장치에서 제공할 수 있습니다. 다만 설치, 푸시 알림, 백그라운드 동작,
beforeinstallprompt같은 PWA 기능의 지원 범위는 브라우저와 운영 체제마다 다르므로 기능 감지와 대체 경로를 함께 준비해야 합니다. - 적은 데이터 사용량: 효율적인 캐싱으로 데이터 사용량을 줄입니다.
App Router에서 PWA 구성하기
Next.js 16은 App Router의 매니페스트 파일 규칙을 기본 제공하며, 서비스 워커는 앱의 캐시 정책에 맞게 직접 작성할 수 있습니다.
이 기본 구성에는 별도 PWA 패키지가 필요하지 않습니다.
웹 앱 매니페스트 작성
src/app/manifest.ts는 이름, 아이콘, 시작 URL, 표시 모드를 타입 안전하게 정의하는 특별 파일입니다.
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'My Next.js PWA App',
short_name: 'Next PWA',
description: 'A Next.js PWA example.',
start_url: '/ko',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#0f172a',
icons: [
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
},
],
};
}아이콘 파일은 public/icons에 두고 실제 픽셀 크기와 sizes 값을 일치시킵니다.
Next.js가 이 파일을 /manifest.webmanifest로 제공하고 필요한 <link rel="manifest">를 추가하므로 직접 링크 태그를 만들 필요가 없습니다.
서비스 워커 작성
서비스 워커는 public/sw.js에 두고 캐시할 범위를 최소한으로 시작합니다.
다음 예제는 오프라인 안내 화면만 미리 저장하고, 문서 이동이 네트워크에서 실패했을 때 그 화면으로 대체합니다.
const CACHE_NAME = 'app-shell-v1';
const OFFLINE_URL = '/offline.html';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.add(OFFLINE_URL)),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter(
(key) => key.startsWith('app-shell-') && key !== CACHE_NAME,
)
.map((key) => caches.delete(key)),
),
),
);
});
self.addEventListener('fetch', (event) => {
if (event.request.mode !== 'navigate') return;
event.respondWith(
fetch(event.request).catch(() => caches.match(OFFLINE_URL)),
);
});public/offline.html에는 특정 locale 라우트나 서버 렌더링에 의존하지 않는 간단한 안내 화면을 만듭니다.
정적 파일이므로 서비스 워커 설치 시 어느 언어 경로에서도 같은 URL을 캐시할 수 있습니다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>오프라인</title>
</head>
<body>
<main>
<h1>네트워크 연결을 확인해 주세요.</h1>
<p>연결이 복구되면 페이지를 다시 불러오세요.</p>
<button type="button" onclick="location.reload()">다시 시도</button>
</main>
</body>
</html>예제 서비스 워커가 응답하는 범위
worker가 제어하는 navigate 요청의 공통 fetch 판정에서 HTTP 응답과 Promise 거부를 비교합니다. HTTP 200·404·500은 그대로 끝나고 거부만 캐시를 조회하며 cache miss와 다른 요청 범위를 보존합니다.
Next · Navigation fallback scope
오프라인 안내를 반환하는 조건
현재 페이지를 제어하는 서비스 워커가 navigation 요청만 처리합니다. offline.html 하나를 미리 캐시하고 네트워크 실패 때 조회하며, HTTP 오류나 다른 요청은 별도로 구분합니다.
| 요청·결과 | 예제 처리 | 남는 경계 |
|---|---|---|
| 페이지 이동 · 정상 응답 | 네트워크 응답 반환 | 방문 페이지를 캐시에 저장하지 않음 |
| 페이지 이동 · HTTP 오류 | 404·500 등의 응답 그대로 반환 | fetch는 HTTP 오류만으로 reject하지 않음 |
| 페이지 이동 · 네트워크 실패 | 캐시의 offline.html 조회 | 항목이 없거나 제거됐으면 응답을 보장하지 못함 |
| 이미지·API 등 다른 요청 | 이 fetch 처리에서 관여하지 않음 | 앱 전체의 오프라인 실행을 제공하지 않음 |
- 페이지 이동 · 정상 응답
- 예제 처리
- 네트워크 응답 반환
- 남는 경계
- 방문 페이지를 캐시에 저장하지 않음
- 페이지 이동 · HTTP 오류
- 예제 처리
- 404·500 등의 응답 그대로 반환
- 남는 경계
- fetch는 HTTP 오류만으로 reject하지 않음
- 페이지 이동 · 네트워크 실패
- 예제 처리
- 캐시의 offline.html 조회
- 남는 경계
- 항목이 없거나 제거됐으면 응답을 보장하지 못함
- 이미지·API 등 다른 요청
- 예제 처리
- 이 fetch 처리에서 관여하지 않음
- 남는 경계
- 앱 전체의 오프라인 실행을 제공하지 않음
서비스 워커가 현재 페이지를 제어하고 오프라인 파일이 캐시에 있어야 이 경로가 동작합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 요청이 navigate이면 네트워크 fetch를 실행한다. 정상과 HTTP 404·500 응답은 그대로 반환한다. 네트워크 실패로 Promise가 거부될 때만 캐시 offline.html을 조회한다. navigate가 아닌 요청에는 이 처리기가 관여하지 않는다. 워커가 페이지를 제어한다는 전제이며 캐시 항목이 없으면 대체 응답을 보장하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 6 | 요청이 navigate이면 네트워크 fetch를 실행한다. 정상과 HTTP 404·500 응답은 그대로 반환한다. 네트워크 실패로 Promise가 거부될 때만 캐시 offline.html을 조회한다. navigate가 아닌 요청에는 이 처리기가 관여하지 않는다. 워커가 페이지를 제어한다는 전제이며 캐시 항목이 없으면 대체 응답을 보장하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| navigation 네트워크 대기 현재 페이지를 제어하는 worker가 navigate 요청에 한해 fetch를 실행합니다. | 공통 조건: worker의 페이지 제어·navigate · 다른 요청: 이 handler 관여 없음 · 처리: 네트워크 Promise 대기 | navigation 요청만 응답 대기로 들어가며 이미지·API의 실패를 이 fallback으로 처리하지 않습니다. |
| Promise 결과별 처리 HTTP 응답을 받으면 그대로 반환하고 Promise가 거부될 때만 offline.html 캐시를 조회합니다. | HTTP 응답: 200·404·500 모두 그대로 반환 · Promise 거부: caches.match(/offline.html) · HTTP 응답의 cache 조회: 없음 | 같은 Promise 판정에서 HTTP 경로는 종료하고 거부 경로만 캐시로 갑니다. HTTP 오류 반환 뒤 fallback을 이어 실행하지 않습니다. |
| 각 경로의 반환 범위 HTTP 경로는 받은 응답으로 끝나고 거부 경로는 찾은 캐시 응답 또는 cache miss로 끝납니다. | HTTP 경로: 네트워크 응답 그대로 · 거부·cache hit: 캐시 offline.html 응답 · 거부·cache miss: 사용할 응답 없음·안내 보장 없음 · 방문 페이지: 이 handler가 새로 캐시하지 않음 | 조건별 반환 결과를 분리해 유지합니다. 오프라인 안내 한 파일의 경로이며 앱 전체 오프라인 실행이나 캐시 파일 존재를 보장하지 않습니다. |
예제 서비스 워커가 응답하는 범위
worker가 제어하는 navigate 요청의 공통 fetch 판정에서 HTTP 응답과 Promise 거부를 비교합니다. HTTP 200·404·500은 그대로 끝나고 거부만 캐시를 조회하며 cache miss와 다른 요청 범위를 보존합니다.
Next · Navigation fallback scope
오프라인 안내를 반환하는 조건
현재 페이지를 제어하는 서비스 워커가 navigation 요청만 처리합니다. offline.html 하나를 미리 캐시하고 네트워크 실패 때 조회하며, HTTP 오류나 다른 요청은 별도로 구분합니다.
| 요청·결과 | 예제 처리 | 남는 경계 |
|---|---|---|
| 페이지 이동 · 정상 응답 | 네트워크 응답 반환 | 방문 페이지를 캐시에 저장하지 않음 |
| 페이지 이동 · HTTP 오류 | 404·500 등의 응답 그대로 반환 | fetch는 HTTP 오류만으로 reject하지 않음 |
| 페이지 이동 · 네트워크 실패 | 캐시의 offline.html 조회 | 항목이 없거나 제거됐으면 응답을 보장하지 못함 |
| 이미지·API 등 다른 요청 | 이 fetch 처리에서 관여하지 않음 | 앱 전체의 오프라인 실행을 제공하지 않음 |
- 페이지 이동 · 정상 응답
- 예제 처리
- 네트워크 응답 반환
- 남는 경계
- 방문 페이지를 캐시에 저장하지 않음
- 페이지 이동 · HTTP 오류
- 예제 처리
- 404·500 등의 응답 그대로 반환
- 남는 경계
- fetch는 HTTP 오류만으로 reject하지 않음
- 페이지 이동 · 네트워크 실패
- 예제 처리
- 캐시의 offline.html 조회
- 남는 경계
- 항목이 없거나 제거됐으면 응답을 보장하지 못함
- 이미지·API 등 다른 요청
- 예제 처리
- 이 fetch 처리에서 관여하지 않음
- 남는 경계
- 앱 전체의 오프라인 실행을 제공하지 않음
서비스 워커가 현재 페이지를 제어하고 오프라인 파일이 캐시에 있어야 이 경로가 동작합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 요청이 navigate이면 네트워크 fetch를 실행한다. 정상과 HTTP 404·500 응답은 그대로 반환한다. 네트워크 실패로 Promise가 거부될 때만 캐시 offline.html을 조회한다. navigate가 아닌 요청에는 이 처리기가 관여하지 않는다. 워커가 페이지를 제어한다는 전제이며 캐시 항목이 없으면 대체 응답을 보장하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 6 | 요청이 navigate이면 네트워크 fetch를 실행한다. 정상과 HTTP 404·500 응답은 그대로 반환한다. 네트워크 실패로 Promise가 거부될 때만 캐시 offline.html을 조회한다. navigate가 아닌 요청에는 이 처리기가 관여하지 않는다. 워커가 페이지를 제어한다는 전제이며 캐시 항목이 없으면 대체 응답을 보장하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| navigation 네트워크 대기 현재 페이지를 제어하는 worker가 navigate 요청에 한해 fetch를 실행합니다. | 공통 조건: worker의 페이지 제어·navigate · 다른 요청: 이 handler 관여 없음 · 처리: 네트워크 Promise 대기 | navigation 요청만 응답 대기로 들어가며 이미지·API의 실패를 이 fallback으로 처리하지 않습니다. |
| Promise 결과별 처리 HTTP 응답을 받으면 그대로 반환하고 Promise가 거부될 때만 offline.html 캐시를 조회합니다. | HTTP 응답: 200·404·500 모두 그대로 반환 · Promise 거부: caches.match(/offline.html) · HTTP 응답의 cache 조회: 없음 | 같은 Promise 판정에서 HTTP 경로는 종료하고 거부 경로만 캐시로 갑니다. HTTP 오류 반환 뒤 fallback을 이어 실행하지 않습니다. |
| 각 경로의 반환 범위 HTTP 경로는 받은 응답으로 끝나고 거부 경로는 찾은 캐시 응답 또는 cache miss로 끝납니다. | HTTP 경로: 네트워크 응답 그대로 · 거부·cache hit: 캐시 offline.html 응답 · 거부·cache miss: 사용할 응답 없음·안내 보장 없음 · 방문 페이지: 이 handler가 새로 캐시하지 않음 | 조건별 반환 결과를 분리해 유지합니다. 오프라인 안내 한 파일의 경로이며 앱 전체 오프라인 실행이나 캐시 파일 존재를 보장하지 않습니다. |
오프라인 응답을 예측하기
현재 페이지를 예제 워커가 제어하고 /offline.html은 캐시에 있다고 가정합니다. 문서 이동에서 서버가 500 응답을 반환한 경우, 네트워크 Promise가 거부된 경우, 이미지 요청이 실패한 경우의 결과를 각각 정하세요. 캐시를 지운 뒤 두 번째 상황이 생겨도 안내가 보장될까요?
catch가 실행되는 조건
500은 HTTP 응답이므로 원 fetch의 Promise는 그 응답으로 완료되고 그대로 반환됩니다. 네트워크 실패로 거부되면 catch가 실행되어 캐시의 오프라인 파일을 조회합니다. 이미지 요청은 mode가 navigate가 아니므로 이 처리기가 응답을 대신 만들지 않습니다.
캐시에 파일이 없으면 caches.match는 사용할 응답을 찾지 못하므로 안내도 보장되지 않습니다. 이 검사는 먼저 워커의 제어 여부와 캐시 항목을 확인한 뒤 수행해야 합니다. 안내 HTML이 보인 결과를 상품 조회·로그인·주문 저장까지 오프라인에서 가능하다는 뜻으로 해석하지 않습니다.
캐시 이름을 바꿔 확인하기
새 워커가 사용하는 캐시 이름이 원 코드처럼 app-shell-v1이고 저장소에 app-shell-v0, app-shell-v1, photos-v1이 있다면 activate 뒤 어떤 항목이 남을까요?
이 앱의 옛 캐시만 정리
접두사가 같으면서 현재 이름과 다른 app-shell-v0만 삭제합니다. 현재 app-shell-v1과 다른 용도의 photos-v1은 남깁니다. 파일 내용 변경 때는 새 캐시 이름·워커 대기 상태·실제 페이지 제어를 함께 확인합니다.
캐시 대상을 확대할 때에도 인증 응답, 사용자별 API와 결제 결과를 무조건 저장하지 않습니다.
브라우저에 등록
등록 로직은 작은 클라이언트 컴포넌트로 분리하고 운영 환경에서만 실행합니다. 서비스 워커에는 HTTPS 같은 보안 컨텍스트가 필요하며 로컬 개발용 localhost는 예외로 허용됩니다.
'use client';
import { useEffect } from 'react';
export function PwaRegister() {
useEffect(() => {
if (
process.env.NODE_ENV === 'production' &&
'serviceWorker' in navigator
) {
navigator.serviceWorker.register('/sw.js');
}
}, []);
return null;
}앞 절에서 만든 src/app/[locale]/layout.tsx에 등록 컴포넌트와 메타데이터 export를 병합합니다.
이미 이 파일이 <html>과 <body>를 반환하는 Root Layout이므로 상위 src/app/layout.tsx를 새로 만들지 않습니다.
import type { Metadata, Viewport } from 'next';
import { notFound } from 'next/navigation';
import { isLocale, locales } from '@/i18n/config';
import { PwaRegister } from './pwa-register';
export const metadata: Metadata = {
title: 'My Next.js PWA App',
description: 'A Next.js PWA example.',
};
export const viewport: Viewport = {
themeColor: '#0f172a',
};
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!isLocale(locale)) {
notFound();
}
return (
<html lang={locale}>
<body>
<PwaRegister />
{children}
</body>
</html>
);
}PWA 기능 구현 및 테스트
예제는 skipWaiting()이나 clients.claim()을 호출하지 않습니다. 새 워커는 기존 페이지가 닫힐 때까지 대기할 수 있고, 활성화됐다는 사실만으로 현재 페이지를 제어한다고 볼 수는 없습니다.
오프라인 지원 (Caching Strategy)
서비스 워커는 작성한 fetch 처리 범위만 제어합니다.
캐시 대상과 전략은 요청의 성격에 따라 명시적으로 정해야 합니다.
- 정적 자산: 파일명이 해시로 바뀌는 자산은
cache-first전략을 검토할 수 있습니다. - 공개 문서: 최신성과 오프라인 접근을 함께 원하면
stale-while-revalidate를 검토합니다. - 사용자별 데이터: 인증·권한·개인정보 응답은 공유 캐시에 넣지 않고 네트워크 실패 UI를 따로 설계합니다.
Next.js 앱을 프로덕션 빌드하고 실행합니다 (npm run build && npm run start).
브라우저에서 앱에 접속합니다.
개발자 도구(Chrome DevTools)를 열고 Application 탭으로 이동합니다.
Service Workers에서 활성 상태와 현재 페이지의 제어 여부를 확인합니다. 필요하면 온라인 상태에서 다시 접속하고, Cache Storage에 offline.html이 있는지 확인합니다.
Network 탭으로 이동하여 네트워크 상태를 Offline으로 변경합니다.
페이지를 새로고침하거나 다른 페이지로 이동하여 앱 화면 대신 오프라인 안내가 나오는지 확인합니다. 이는 전체 앱의 오프라인 실행 검사가 아닙니다.
푸시 알림 (Push Notifications)
푸시 알림은 사용자의 명시적 권한을 받은 뒤 서버가 브라우저 구독 정보로 메시지를 보내는 기능입니다.
구현하려면 다음 단계가 필요합니다.
서비스 워커에서 푸시 이벤트 수신: 서비스 워커 파일(sw.js)에서 push 이벤트를 수신하고 알림을 표시하는 로직을 직접 구현합니다.
푸시 API 사용: 클라이언트 측에서 PushManager API를 사용하여 사용자에게 알림 권한을 요청하고, 구독 정보를 백엔드로 전송하여 저장합니다.
백엔드에서 푸시 메시지 전송: 웹 푸시 라이브러리(예: Node.js용 web-push)를 사용하여 저장된 구독 정보로 사용자에게 푸시 알림을 보냅니다.
VAPID 키 설정이 필요합니다.
홈 화면 설치 (Add to Home Screen)
사용자가 PWA를 홈 화면에 설치할 수 있도록 유도하는 기능입니다.
-
설치 조건 충족
- 웹 앱 매니페스트 파일이 유효해야 합니다.
- HTTPS로 서비스되어야 합니다.
- 브라우저별 설치 가능성 기준과 사용자 설정을 충족해야 합니다.
- 서비스 워커의 역할: 최신 브라우저에서 설치 자체의 공통 필수 조건은 아니지만, 오프라인 화면·푸시 알림·백그라운드 동작에는 필요합니다.
- 설치 프롬프트: 지원하는 브라우저는 설치 가능 조건을 충족하면 앱 설치 또는 홈 화면에 추가 UI를 제공합니다. Chromium 계열에서는
beforeinstallprompt로 사용자 제스처에 맞춘 버튼을 만들 수 있지만, 모든 브라우저가 이 이벤트를 제공하는 것은 아닙니다.
개발 환경에서 PWA 테스트
예제 등록 코드는 개발 모드에서 서비스 워커를 등록하지 않습니다.
캐시가 개발 결과를 가리지 않도록 PWA 동작은 프로덕션 빌드(npm run build) 후 Next.js 서버를 시작(npm run start)해 확인합니다.
npm run build
npm run start그 후 localhost:3000 (또는 설정된 포트)으로 접속하여 개발자 도구의 Application 탭에서 서비스 워커와 매니페스트를 확인하고, 네트워크를 오프라인으로 전환하여 캐싱 기능을 테스트할 수 있습니다.
출시 전에는 캐시 업데이트와 네트워크 실패 상황에서도 사용자가 다음 행동을 이해할 수 있는지 확인합니다.