PWA 설정
웹 앱 매니페스트와 서비스 워커를 설정하고 캐시·오프라인·설치 가능 여부를 운영 빌드에서 검증합니다.
현대 웹 애플리케이션은 정보를 보여 주는 데서 그치지 않고, 네이티브 앱에 가까운 경험을 주는 방향으로 발전하고 있습니다. 프로그레시브 웹 앱(Progressive Web App, PWA)은 이 목표를 이루기 위한 웹 기술의 모음으로, 앱 스토어에서 설치하지 않고도 모바일 앱처럼 동작하도록 웹의 접근성과 기능을 함께 끌어올립니다. Next.js는 PWA 구현에 필요한 기능을 지원하며, 설정만으로 비교적 쉽게 PWA로 바꿀 수 있습니다. 이 글에서는 App Router의 매니페스트 규칙과 직접 작성한 서비스 워커로 설치, 오프라인 지원, 업데이트를 구성하고, 푸시 알림까지 어디서부터가 별도의 구현인지 익힙니다.
PWA란 무엇이며 왜 중요한가요?
PWA는 HTML, CSS, JavaScript 같은 웹 기술로 만들지만, 접근성과 SEO 같은 웹의 장점과 오프라인 지원, 푸시 알림, 홈 화면 설치 같은 네이티브 앱의 장점을 함께 갖춘 애플리케이션입니다. PWA는 점진적 향상(progressive enhancement) 원칙을 따르므로, 최신 PWA 기능을 지원하는 브라우저에서는 앱 같은 경험을 주고 그렇지 않은 브라우저에서는 일반 웹사이트처럼 동작합니다.
서비스 워커와 캐시를 이용하면 네트워크가 실패했을 때 보여 줄 화면이나 기능을 정할 수 있지만, 구현한 캐시 범위를 벗어난 기능은 오프라인에서 동작하지 않을 수 있습니다. 자산과 응답의 성격에 맞는 캐싱 전략을 적용하면 반복 방문이나 불안정한 네트워크에서 로딩 시간을 줄이고 데이터 사용량도 줄일 수 있습니다. 다만 잘못된 캐시 정책은 오래된 데이터나 불필요한 저장 공간을 만들 수 있으므로 측정이 필요합니다.
사용자는 웹사이트를 스마트폰의 홈 화면에 추가해 앱처럼 실행할 수 있고, 푸시 알림으로 실시간 알림을 받아 다시 방문할 수 있습니다. 매니페스트와 브라우저 지원에 따라 별도의 앱 창으로 실행할 수도 있으며, 아래의 standalone은 운영 체제의 UI까지 없애는 fullscreen과는 다릅니다. PWA는 URL로 접근하고 검색 엔진에도 노출되며, 앱 스토어를 거치지 않고 바로 쓸 수 있습니다.
일반적인 웹 경험은 폭넓은 브라우저와 장치에서 제공할 수 있습니다. 하지만 설치, 푸시 알림, 백그라운드 동작, beforeinstallprompt 같은 PWA 기능의 지원 범위는 브라우저와 운영 체제마다 다르므로, 기능을 감지하는 코드와 대체 경로를 함께 준비해야 합니다.
App Router에서 PWA 구성하기
Next.js 16은 App Router의 매니페스트 파일 규칙을 기본으로 제공하며, 서비스 워커는 앱의 캐시 정책에 맞게 직접 작성할 수 있습니다. 이 기본 구성에는 별도의 PWA 패키지가 필요하지 않습니다.
웹 앱 매니페스트 작성
src/app/manifest.ts는 이름, 아이콘, 시작 URL, 표시 모드를 타입 안전하게 정의하는 특별한 파일입니다. 파일 이름이 정해진 규칙이라서, 이 함수가 돌려준 객체를 Next.js가 JSON으로 바꿔 매니페스트로 제공합니다. 반환 타입 MetadataRoute.Manifest를 붙여 두면 display에 허용되지 않는 값을 쓰는 것 같은 실수를 타입 검사가 잡아 줍니다. short_name, start_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">도 추가하므로, 링크 태그를 직접 만들 필요가 없습니다.
서비스 워커 작성
서비스 워커(service worker)는 브라우저가 페이지와 따로 실행하면서 그 페이지의 네트워크 요청을 가로챌 수 있는 스크립트이며, 설치(install), 대기(waiting), 활성화(activate)를 거쳐 페이지를 제어하기까지의 생명주기는 웹 워커와 서비스 워커에서 자세히 다룹니다. 이 글의 서비스 워커는 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가 새로 캐시하지 않음 | 조건별 반환 결과를 분리해 유지합니다. 오프라인 안내 한 파일의 경로이며 앱 전체 오프라인 실행이나 캐시 파일 존재를 보장하지 않습니다. |
위 그림은 예제 서비스 워커가 어떤 요청에, 어떤 조건에서 오프라인 안내를 돌려주는지 보여 줍니다. 위쪽 흐름에서 워커의 fetch 이벤트는 요청이 navigate일 때만 이 처리기가 관여하며, 그 밖의 요청에는 관여하지 않습니다. 페이지 이동 요청은 네트워크로 fetch를 실행해, 200이나 404, 500 같은 HTTP 응답을 받으면 그대로 돌려줍니다. Promise가 거부되는 네트워크 실패일 때만 캐시에서 offline.html을 찾고, 그 항목이 없으면 대체 응답을 보장하지 못합니다. 그림의 단계도 이동 요청의 네트워크 대기, Promise 결과별 처리, 각 경로의 반환 범위 순서입니다.
아래쪽 표는 요청과 결과별로 남는 한계를 정리합니다. 정상 응답은 그대로 돌려주지만 방문한 페이지를 캐시에 저장하지 않고, fetch는 HTTP 오류만으로는 거부되지 않습니다. 네트워크 실패에서도 항목이 없거나 지워졌으면 응답을 보장하지 못하며, 이미지나 API 같은 다른 요청에는 관여하지 않으므로 앱 전체의 오프라인 실행을 제공하지는 않습니다. 그림의 설명처럼 이 경로는 서비스 워커가 현재 페이지를 제어하고 오프라인 파일이 캐시에 있어야 동작합니다.
캐시 대상을 넓힐 때에도 인증 응답, 사용자별 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>를 돌려주는 루트 레이아웃이므로, 상위에 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>
);
}앞 글의 레이아웃과 달라진 곳은 metadata와 viewport export, 그리고 <body> 안의 PwaRegister입니다. PwaRegister는 null을 돌려주는 클라이언트 컴포넌트라서 화면에는 아무것도 그리지 않고, 프로덕션 빌드에서 페이지가 처음 마운트될 때 /sw.js를 등록하는 일만 합니다. viewport.themeColor는 매니페스트의 theme_color와 같은 값으로 맞춰 두었습니다.
더 깊이: 서비스 워커 파일 자체가 캐시되지 않게 하기
서비스 워커가 페이지의 요청을 캐시하는 것과 별개로, sw.js 파일 자체도 브라우저의 HTTP 캐시에 남을 수 있습니다. 그러면 새로 배포한 워커를 브라우저가 늦게 받아 예전 동작이 오래 남을 수 있으므로, Next.js의 PWA 안내는 next.config의 headers()로 /sw.js에 세 가지 헤더를 붙이는 예를 보여 줍니다. Content-Type: application/javascript; charset=utf-8은 파일이 JavaScript로 해석되게 하고, Cache-Control: no-cache, no-store, must-revalidate는 워커 파일이 캐시되지 않아 사용자가 언제나 최신 버전을 받게 하며, Content-Security-Policy: default-src 'self'; script-src 'self'는 워커가 같은 출처의 스크립트만 쓰게 제한합니다.
같은 안내의 등록 예제는 navigator.serviceWorker.register에 updateViaCache: 'none' 옵션도 넘깁니다. 이 옵션은 브라우저가 워커 스크립트의 갱신을 확인할 때 메인 스크립트와 그 스크립트가 가져오는 다른 스크립트 모두에 HTTP 캐시를 쓰지 않고 네트워크에서 받게 합니다. 이 글의 PwaRegister는 옵션 없이 /sw.js만 등록하므로, 배포 환경에서는 이 헤더와 옵션을 함께 검토합니다. 푸시 알림을 붙일 계획이라면, 홈 화면에 설치한 iOS 16.4 이상의 앱과 macOS 13 이상의 Safari 16에서 지원된다는 점과, 로컬에서 시험할 때는 next dev --experimental-https로 HTTPS를 켜야 한다는 점도 같은 안내에서 확인할 수 있습니다.
PWA 기능 구현 및 테스트
예제는 skipWaiting()이나 clients.claim()을 호출하지 않습니다. 따라서 새 워커는 기존 페이지가 닫힐 때까지 기다릴 수 있고, 워커가 활성화되었다는 사실만으로 현재 페이지를 제어한다고 볼 수는 없습니다.
오프라인 지원 (Caching Strategy)
서비스 워커는 작성한 fetch 처리 범위만 제어하므로, 캐시 대상과 전략은 요청의 성격에 따라 명시적으로 정해야 합니다. 파일 이름이 해시로 바뀌는 정적 자산은 캐시를 먼저 보는 cache-first 전략을 검토할 수 있습니다. 최신성과 오프라인 접근을 함께 원하는 공개 문서는 stale-while-revalidate를 검토합니다. 인증, 권한, 개인정보가 담긴 사용자별 응답은 공유 캐시에 넣지 않고, 네트워크가 실패했을 때의 UI를 따로 설계합니다.
오프라인 동작은 다음 순서로 시험합니다. 먼저 앱을 프로덕션으로 빌드하고 실행한 뒤(npm run build && npm run start) 브라우저에서 접속합니다. Chrome DevTools의 Application 탭을 열어 Service Workers에서 활성 상태와 현재 페이지를 제어하는지 확인하고, 필요하면 온라인 상태에서 다시 접속한 뒤 Cache Storage에 offline.html이 있는지 확인합니다. 그다음 Network 탭에서 네트워크 상태를 Offline으로 바꾸고, 페이지를 새로 고치거나 다른 페이지로 이동해 앱 화면 대신 오프라인 안내가 나오는지 확인합니다. 이는 앱 전체가 오프라인에서 실행되는지 검사하는 것이 아닙니다.
푸시 알림 (Push Notifications)
푸시 알림은 사용자에게 명시적인 권한을 받은 뒤, 서버가 브라우저의 구독 정보로 메시지를 보내는 기능입니다. 구현하려면 서비스 워커 파일(sw.js)에서 push 이벤트를 받아 알림을 보여 주는 로직을 직접 구현하고, 클라이언트에서 PushManager API로 사용자에게 알림 권한을 요청해 구독 정보를 백엔드에 보내 저장해야 합니다. 그리고 백엔드에서는 Node.js용 web-push 같은 웹 푸시 라이브러리로 저장한 구독 정보에 알림을 보내며, VAPID 키 설정이 필요합니다. 푸시 알림 구현은 백엔드 로직이 필요하므로 이 글의 범위를 넘지만, PWA 설정의 중요한 부분으로 알아 두는 것이 좋습니다.
홈 화면 설치 (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 탭에서 서비스 워커와 매니페스트를 확인하고, 네트워크를 오프라인으로 바꿔 캐싱 기능을 시험할 수 있습니다. 출시하기 전에는 캐시가 업데이트될 때와 네트워크가 실패할 때에도 사용자가 다음에 무엇을 해야 할지 이해할 수 있는지 확인합니다.
정리
- PWA는 매니페스트, 서비스 워커, HTTPS로 설치, 오프라인 대응, 푸시 알림 같은 앱 기능을 웹에 더하며, 지원 범위는 브라우저마다 다릅니다.
src/app/manifest.ts는/manifest.webmanifest로 제공되고, Next.js가<link rel="manifest">를 자동으로 추가합니다.- 예제 서비스 워커는 페이지 이동 요청이 네트워크에서 거부될 때만 캐시한
offline.html을 돌려주며, HTTP 오류 응답은 그대로 전달합니다. - 캐시 이름을 바꾸면
activate에서 같은 접두사의 옛 캐시를 지우고, 인증이나 사용자별 응답은 캐시에 넣지 않습니다. - 서비스 워커는 프로덕션 빌드에서만 등록하고, 새 워커가 기존 페이지를 바로 제어하지 않을 수 있다는 점을 확인합니다.
연습 문제
1. 오프라인 응답을 예측하기
현재 페이지를 예제 워커가 제어하고 /offline.html은 캐시에 있다고 가정합니다. 문서 이동에서 서버가 500 응답을 돌려준 경우, 네트워크 Promise가 거부된 경우, 이미지 요청이 실패한 경우의 결과를 각각 정해 보세요. 캐시를 지운 뒤 두 번째 상황이 생겨도 안내가 보장될까요?
해설 보기
500은 HTTP 응답이므로 fetch의 Promise는 그 응답으로 끝나고, 그 응답이 그대로 전달됩니다. 네트워크 실패로 거부되면 catch가 실행되어 캐시의 오프라인 파일을 찾습니다. 이미지 요청은 mode가 navigate가 아니므로 이 처리기가 응답을 대신 만들지 않습니다.
캐시에 파일이 없으면 caches.match가 쓸 응답을 찾지 못하므로 안내도 보장되지 않습니다. 이 검사는 먼저 워커가 페이지를 제어하는지와 캐시 항목이 있는지 확인한 뒤 해야 합니다. 안내 HTML이 보였다는 결과를 상품 조회, 로그인, 주문 저장까지 오프라인에서 가능하다는 뜻으로 해석하지 않습니다.
2. 캐시 이름을 바꿔 확인하기
새 워커가 쓰는 캐시 이름이 예제처럼 app-shell-v1이고, 저장소에 app-shell-v0, app-shell-v1, photos-v1이 있다면 activate 뒤에 어떤 항목이 남을까요?
해설 보기
접두사가 같으면서 지금 이름과 다른 app-shell-v0만 지웁니다. 지금의 app-shell-v1과, 다른 용도의 photos-v1은 남습니다. 파일 내용을 바꿀 때는 새 캐시 이름, 워커의 대기 상태, 실제로 페이지를 제어하는지를 함께 확인합니다.
3. 고친 오프라인 화면이 보이지 않을 때
offline.html의 문구를 고치고 다시 배포했는데, 오프라인으로 바꿔 보면 여전히 예전 문구가 보입니다. sw.js는 바꾸지 않았습니다. 왜 그럴까요?
해설 보기
offline.html은 install 이벤트에서 한 번 캐시에 저장되는데, sw.js가 바뀌지 않았으므로 브라우저는 새 워커를 설치하지 않고 기존 캐시의 예전 파일을 계속 씁니다. 새 문구를 반영하려면 CACHE_NAME을 app-shell-v2처럼 바꿔 sw.js를 고쳐야 새 워커가 설치되며 새 파일을 캐시합니다. 이 예제는 skipWaiting()을 쓰지 않으므로, 기존 탭을 모두 닫거나 DevTools에서 대기 중인 워커를 활성화해야 새 워커가 페이지를 제어합니다.
App Router에서 PWA를 구성하는 공식 절차는 Next.js 공식 문서의 Progressive Web Apps 안내에서 확인할 수 있습니다.
다음 글 서버리스 함수 활용에서는 Route Handler를 서버리스 함수로 배포할 때의 실행 방식과 주의할 점을 알아봅니다.