title: Next.js로 프로그레시브 웹 앱(PWA) 구축하는 방법
description: Next.js를 사용하여 프로그레시브 웹 애플리케이션(PWA)을 구축하는 방법을 배워보세요.
url: "https://nextjs.org/docs/app/guides/progressive-web-apps"
version: 16.1.6
lastUpdated: 2026-02-27
prerequisites:
안녕하세요! 오늘은 프론트엔드 개발자라면 꼭 한 번쯤 다뤄봐야 할 주제인 프로그레시브 웹 애플리케이션(PWA, Progressive Web Applications)을 Next.js 환경에서 어떻게 구축하는지 공식 문서를 통해 함께 알아볼게요.
PWA는 웹 애플리케이션의 뛰어난 접근성과 도달력을 네이티브 모바일 앱의 기능 및 사용자 경험(UX)과 결합한 형태를 말해요. Next.js를 사용하면 여러 개의 코드베이스를 관리하거나 번거로운 앱 스토어 승인 절차를 거칠 필요 없이, 모든 플랫폼에서 매끄러운 앱과 같은 경험을 제공하는 PWA를 만들 수 있답니다.
PWA를 사용하면 다음과 같은 일들이 가능해져요:
👨🏫 강사의 팁 & 보충 설명:
PWA는 웹 기술만으로 모바일 앱과 같은 경험을 줄 수 있다는 점에서 프론트엔드 개발자에게 아주 강력한 무기예요. 특히 React Native 같은 모바일 전용 프레임워크를 새로 공부하기 전에, 이미 익숙한 React와 Next.js 지식만으로 모바일 사용자를 타겟팅할 수 있다는 건 엄청난 장점이죠. 나중에 React Native를 학습하실 때도 "웹에서는 서비스 워커로 이렇게 처리했는데 모바일 네이티브 환경에서는 어떻게 다를까?" 비교하며 공부하기 아주 좋답니다! 면접에서도 "웹 기술의 한계를 어떻게 극복할 것인가?"라는 질문에 PWA 도입 경험을 이야기하면 아주 훌륭한 답변이 될 수 있어요.
Next.js는 App Router를 사용할 때 웹 앱 매니페스트(web app manifest) 생성을 내장 기능으로 지원해요. 정적(static) 매니페스트 파일이나 동적(dynamic) 매니페스트 파일을 모두 만들 수 있답니다.
예를 들어, app/manifest.ts 또는 app/manifest.json 파일을 생성해 보세요:
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: '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: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
}
}
export default function manifest() {
return {
name: '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: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
}
}
이 파일에는 앱의 이름, 아이콘, 그리고 사용자의 기기에서 아이콘으로 표시될 때 어떻게 보여야 하는지에 대한 정보가 포함되어야 해요. 이렇게 하면 사용자가 여러분의 PWA를 자신의 기기 홈 화면에 설치할 수 있어서 진짜 네이티브 앱을 쓰는 것 같은 느낌을 줄 수 있어요.
다양한 크기의 아이콘 세트를 만들려면 favicon generators와 같은 도구를 활용할 수 있고, 그렇게 생성된 파일들을 프로젝트의 public/ 폴더에 넣으시면 됩니다.
👨🏫 강사의 팁:
포트폴리오로 개인 웹 프로필 사이트나 블로그 플랫폼(예: Velog 뷰어)을 만들 때, 이 매니페스트 설정을 꼭 추가해 보세요! 채용 담당자가 모바일로 여러분의 포트폴리오 주소에 들어갔을 때 '홈 화면에 추가' 팝업이 뜨고, 예쁜 아이콘으로 스마트폰 바탕화면에 설치된다면 첫인상부터 확실한 차별점을 줄 수 있어요.
웹 푸시 알림은 다음과 같은 최신 브라우저들에서 모두 지원돼요:
덕분에 PWA가 네이티브 앱을 대체할 수 있는 아주 현실적인 대안이 되었죠. 특히 주목할 만한 점은, 오프라인 지원 기능이 없어도 설치 프롬프트를 띄울 수 있다는 거예요.
웹 푸시 알림을 활용하면 사용자가 앱을 적극적으로 사용하고 있지 않을 때에도 다시 앱으로 돌아오도록(re-engage) 유도할 수 있어요. Next.js 애플리케이션에서 이를 구현하는 방법은 다음과 같습니다:
먼저 app/page.tsx 파일에 메인 페이지 컴포넌트를 만들어 볼게요. 이해하기 쉽도록 여러 부분으로 나누어서 설명할게요. 우선 필요한 import 문과 유틸리티 함수를 추가해 봅시다. 아직 우리가 참조하는 Server Actions 파일이 존재하지 않더라도 괜찮아요:
'use client'
import { useState, useEffect } from 'react'
import { subscribeUser, unsubscribeUser, sendNotification } from './actions'
function urlBase64ToUint8Array(base64String: string) {
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
}
'use client'
import { useState, useEffect } from 'react'
import { subscribeUser, unsubscribeUser, sendNotification } from './actions'
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
}
👨🏫 보충 설명:
위 코드에 있는urlBase64ToUint8Array함수가 조금 낯설게 느껴지실 수 있어요. 푸시 알림을 구독할 때 보안을 위해 VAPID 키라는 걸 사용하는데, 서버에서 제공하는 이 키(Base64 문자열 형식)를 브라우저의 푸시 매니저가 이해할 수 있는 형태인Uint8Array배열로 변환해 주는 필수 유틸리티 함수랍니다. 복잡해 보이지만 통상적으로 복사해서 붙여넣고 사용하는 보일러플레이트 코드라고 생각하시면 편해요.
이제 푸시 알림의 구독, 구독 취소, 그리고 푸시 알림 전송을 관리하는 컴포넌트를 추가해 볼게요.
function PushNotificationManager() {
const [isSupported, setIsSupported] = useState(false)
const [subscription, setSubscription] = useState<PushSubscription | null>(
null
)
const [message, setMessage] = useState('')
useEffect(() => {
if ('serviceWorker' in navigator && 'PushManager' in window) {
setIsSupported(true)
registerServiceWorker()
}
}, [])
async function registerServiceWorker() {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none',
})
const sub = await registration.pushManager.getSubscription()
setSubscription(sub)
}
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
),
})
setSubscription(sub)
const serializedSub = JSON.parse(JSON.stringify(sub))
await subscribeUser(serializedSub)
}
async function unsubscribeFromPush() {
await subscription?.unsubscribe()
setSubscription(null)
await unsubscribeUser()
}
async function sendTestNotification() {
if (subscription) {
await sendNotification(message)
setMessage('')
}
}
if (!isSupported) {
return <p>Push notifications are not supported in this browser.</p>
}
return (
<div>
<h3>Push Notifications</h3>
{subscription ? (
<>
<p>You are subscribed to push notifications.</p>
<button onClick={unsubscribeFromPush}>Unsubscribe</button>
<input
type="text"
placeholder="Enter notification message"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button onClick={sendTestNotification}>Send Test</button>
</>
) : (
<>
<p>You are not subscribed to push notifications.</p>
<button onClick={subscribeToPush}>Subscribe</button>
</>
)}
</div>
)
}
function PushNotificationManager() {
const [isSupported, setIsSupported] = useState(false);
const [subscription, setSubscription] = useState(null);
const [message, setMessage] = useState('');
useEffect(() => {
if ('serviceWorker' in navigator && 'PushManager' in window) {
setIsSupported(true);
registerServiceWorker();
}
}, []);
async function registerServiceWorker() {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none',
});
const sub = await registration.pushManager.getSubscription();
setSubscription(sub);
}
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
),
});
setSubscription(sub);
await subscribeUser(sub);
}
async function unsubscribeFromPush() {
await subscription?.unsubscribe();
setSubscription(null);
await unsubscribeUser();
}
async function sendTestNotification() {
if (subscription) {
await sendNotification(message);
setMessage('');
}
}
if (!isSupported) {
return <p>Push notifications are not supported in this browser.</p>;
}
return (
<div>
<h3>Push Notifications</h3>
{subscription ? (
<>
<p>You are subscribed to push notifications.</p>
<button onClick={unsubscribeFromPush}>Unsubscribe</button>
<input
type="text"
placeholder="Enter notification message"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button onClick={sendTestNotification}>Send Test</button>
</>
) : (
<>
<p>You are not subscribed to push notifications.</p>
<button onClick={subscribeToPush}>Subscribe</button>
</>
)}
</div>
);
}
마지막으로, 앱이 아직 설치되지 않은 경우에 한해 iOS 기기 사용자들에게 홈 화면 설치 방법을 안내하는 메시지를 보여주는 컴포넌트를 만들어 볼게요.
function InstallPrompt() {
const [isIOS, setIsIOS] = useState(false)
const [isStandalone, setIsStandalone] = useState(false)
useEffect(() => {
setIsIOS(
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
)
setIsStandalone(window.matchMedia('(display-mode: standalone)').matches)
}, [])
if (isStandalone) {
return null // Don't show install button if already installed
}
return (
<div>
<h3>Install App</h3>
<button>Add to Home Screen</button>
{isIOS && (
<p>
To install this app on your iOS device, tap the share button
<span role="img" aria-label="share icon">
{' '}
⎋{' '}
</span>
and then "Add to Home Screen"
<span role="img" aria-label="plus icon">
{' '}
➕{' '}
</span>
.
</p>
)}
</div>
)
}
export default function Page() {
return (
<div>
<PushNotificationManager />
<InstallPrompt />
</div>
)
}
function InstallPrompt() {
const [isIOS, setIsIOS] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
useEffect(() => {
setIsIOS(
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
);
setIsStandalone(window.matchMedia('(display-mode: standalone)').matches);
}, []);
if (isStandalone) {
return null; // Don't show install button if already installed
}
return (
<div>
<h3>Install App</h3>
<button>Add to Home Screen</button>
{isIOS && (
<p>
To install this app on your iOS device, tap the share button
<span role="img" aria-label="share icon">
{' '}
⎋{' '}
</span>
and then "Add to Home Screen"
<span role="img" aria-label="plus icon">
{' '}
➕{' '}
</span>
.
</p>
)}
</div>
);
}
export default function Page() {
return (
<div>
<PushNotificationManager />
<InstallPrompt />
</div>
);
}
자, 이제 이 파일에서 호출하고 있는 Server Actions를 만들어 볼 차례예요.
여러분의 액션들을 담아둘 새 파일을 app/actions.ts 경로에 만들어주세요. 이 파일은 구독 생성, 구독 삭제, 그리고 알림 전송과 관련된 로직을 처리할 거예요.
'use server'
import webpush from 'web-push'
webpush.setVapidDetails(
'<mailto:your-email@example.com>',
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
)
let subscription: PushSubscription | null = null
export async function subscribeUser(sub: PushSubscription) {
subscription = sub
// In a production environment, you would want to store the subscription in a database
// For example: await db.subscriptions.create({ data: sub })
return { success: true }
}
export async function unsubscribeUser() {
subscription = null
// In a production environment, you would want to remove the subscription from the database
// For example: await db.subscriptions.delete({ where: { ... } })
return { success: true }
}
export async function sendNotification(message: string) {
if (!subscription) {
throw new Error('No subscription available')
}
try {
await webpush.sendNotification(
subscription,
JSON.stringify({
title: 'Test Notification',
body: message,
icon: '/icon.png',
})
)
return { success: true }
} catch (error) {
console.error('Error sending push notification:', error)
return { success: false, error: 'Failed to send notification' }
}
}
'use server';
import webpush from 'web-push';
webpush.setVapidDetails(
'<mailto:your-email@example.com>',
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
let subscription= null;
export async function subscribeUser(sub) {
subscription = sub;
// In a production environment, you would want to store the subscription in a database
// For example: await db.subscriptions.create({ data: sub })
return { success: true };
}
export async function unsubscribeUser() {
subscription = null;
// In a production environment, you would want to remove the subscription from the database
// For example: await db.subscriptions.delete({ where: { ... } })
return { success: true };
}
export async function sendNotification(message) {
if (!subscription) {
throw new Error('No subscription available');
}
try {
await webpush.sendNotification(
subscription,
JSON.stringify({
title: 'Test Notification',
body: message,
icon: '/icon.png',
})
);
return { success: true };
} catch (error) {
console.error('Error sending push notification:', error);
return { success: false, error: 'Failed to send notification' };
}
}
실제 알림을 전송하는 작업은 5단계에서 만들 서비스 워커(Service Worker)가 처리하게 될 거예요.
👨🏫 보충 설명:
여기 코드의 주석에도 적혀있지만, 이 예제는 단순히 메모리(let subscription)에 구독 정보를 저장하고 있어요. 실무 환경(Production)에서는 서버가 재시작되어도 데이터가 유지되게 하고 여러 사용자의 구독 정보를 관리하기 위해 반드시 이 구독 정보를 데이터베이스(DB)에 저장해야 한답니다!
웹 푸시 API(Web Push API)를 사용하려면 VAPID 키를 생성해야 해요. 가장 쉬운 방법은 web-push CLI를 직접 사용하는 거예요:
우선 전역으로 web-push를 설치해 줍니다:
pnpm add -g web-push
npm install -g web-push
yarn global add web-push
bun add -g web-push
다음 명령어를 실행해서 VAPID 키를 생성하세요:
web-push generate-vapid-keys
출력된 결과물을 복사해서 프로젝트의 .env 파일에 붙여넣어 주세요:
NEXT_PUBLIC_VAPID_PUBLIC_KEY=여기에_여러분의_public_key를_넣으세요
VAPID_PRIVATE_KEY=여기에_여러분의_private_key를_넣으세요
서비스 워커를 위한 파일을 public/sw.js 경로에 만들어주세요:
self.addEventListener('push', function (event) {
if (event.data) {
const data = event.data.json()
const options = {
body: data.body,
icon: data.icon || '/icon.png',
badge: '/badge.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: '2',
},
}
event.waitUntil(self.registration.showNotification(data.title, options))
}
})
self.addEventListener('notificationclick', function (event) {
console.log('Notification click received.')
event.notification.close()
event.waitUntil(clients.openWindow('[https://your-website.com](https://your-website.com)'))
})
이 서비스 워커는 커스텀 이미지와 알림을 지원해요. 들어오는 푸시 이벤트와 알림 클릭 이벤트를 처리하는 역할을 합니다.
icon과 badge 속성을 사용해서 알림에 들어갈 커스텀 아이콘을 설정할 수 있어요.vibrate 패턴을 조정해서 기기가 지원하는 경우 사용자 지정 진동 알림을 만들 수 있어요.data 속성을 이용하면 알림 객체에 추가적인 데이터를 붙여둘 수 있답니다.다양한 기기와 브라우저에서 기대한 대로 잘 동작하는지 서비스 워커를 꼼꼼하게 테스트하는 것을 잊지 마세요. 또한, notificationclick 이벤트 리스너 안에 있는 'https://your-website.com' 링크를 여러분의 애플리케이션에 맞는 적절한 URL로 꼭 변경해 주셔야 해요.
👨🏫 강사의 팁:
프론트엔드 개발을 하다 보면 브라우저의 캐싱이나 백그라운드 작업 때문에 서비스 워커 디버깅이 까다로울 때가 많아요. 크롬 개발자 도구(F12)를 열고Application탭 ->Service Workers메뉴에 가시면 현재 등록된 서비스 워커의 상태를 확인하고, 수동으로 멈추거나 푸시 이벤트를 가짜로 발생시켜서 테스트해 볼 수도 있답니다!
앞서 2단계에서 정의했던 InstallPrompt 컴포넌트는 iOS 기기 사용자들이 앱을 홈 화면에 설치할 수 있도록 안내하는 메시지를 보여주는 역할을 합니다.
모바일 홈 화면에 앱을 성공적으로 설치할 수 있게 하려면 다음 조건들을 반드시 갖추어야 해요:
이 조건들이 충족되면, 최신 브라우저들은 사용자에게 자동으로 설치 프롬프트를 띄워줍니다. beforeinstallprompt 이벤트를 활용해서 여러분만의 커스텀 설치 버튼을 제공할 수도 있지만, 이 방식은 브라우저 및 플랫폼 간의 호환성이 완벽하지 않아서(예: Safari iOS에서는 작동하지 않음) Next.js 공식 문서에서는 권장하지 않고 있어요.
로컬 환경에서 알림이 제대로 보이는지 확인하려면 다음 사항들을 체크해 주세요:
next dev --experimental-https 명령어를 사용할 수 있어요.보안은 어떤 웹 애플리케이션에서든 매우 중요한 요소이며, PWA에서는 더욱 그렇습니다. Next.js는 next.config.js 파일을 통해 보안 헤더를 손쉽게 설정할 수 있게 해줘요. 예를 들면 다음과 같습니다:
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
],
},
{
source: '/sw.js',
headers: [
{
key: 'Content-Type',
value: 'application/javascript; charset=utf-8',
},
{
key: 'Cache-Control',
value: 'no-cache, no-store, must-revalidate',
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self'",
},
],
},
]
},
}
각 옵션들이 어떤 역할을 하는지 살펴볼게요:
X-Content-Type-Options: nosniff: 브라우저가 MIME 타입을 임의로 추측(sniffing)하는 것을 막아주어, 악의적인 파일 업로드 공격의 위험을 줄여줍니다.X-Frame-Options: DENY: 여러분의 사이트가 다른 사이트의 iframe 안에 임베드되는 것을 막아서 클릭재킹(clickjacking) 공격으로부터 보호해 줘요.Referrer-Policy: strict-origin-when-cross-origin: 요청 시에 리퍼러(referrer) 정보가 얼마나 포함될지를 제어해서 보안과 기능성의 균형을 맞춰줍니다.Content-Type: application/javascript; charset=utf-8: 브라우저가 서비스 워커 파일을 자바스크립트로 정확히 해석할 수 있도록 보장합니다.Cache-Control: no-cache, no-store, must-revalidate: 서비스 워커가 캐싱되는 것을 막아서, 사용자들이 항상 가장 최신 버전의 서비스 워커를 내려받도록 보장해 줍니다.Content-Security-Policy: default-src 'self'; script-src 'self': 서비스 워커에 엄격한 콘텐츠 보안 정책(CSP)을 적용해서, 동일한 출처(origin)의 스크립트만 허용하게 만들어요.Next.js에서 콘텐츠 보안 정책(Content Security Policies)을 정의하는 방법에 대해 더 깊이 알고 싶으시다면 링크를 참고해 보세요.
👨🏫 강사의 팁:
이 보안 헤더 부분은 기술 면접을 준비하실 때 아주 훌륭한 단골 소재입니다. 단순히 "기능을 만들 수 있습니다"를 넘어 "이러한 이유로nosniff나DENY같은 보안 헤더를 설정하여 애플리케이션의 안전성을 높였습니다"라고 어필하면 면접관들에게 깊은 인상을 남길 수 있어요. 포트폴리오 프로젝트에도 꼭 이 설정을 적용해 보세요!
manifest.json 파일에 대한 API 레퍼런스 문서입니다.전체 문서의 시맨틱 개요를 보고 싶으시다면, https://nextjs.org/docs/sitemap.md 를 참고해 주세요.
사용 가능한 전체 문서의 색인을 보시려면, https://nextjs.org/docs/llms.txt 를 참고해 주세요.