[Next.js] PWA 기반 웹 서비스에서 푸시 알림 구현하기
PWA 기반 웹 서비스를 만들면서 푸시 알림도 함께 구현하게 되었습니다. 별다른 앱 개발이 필요 없이 프론트엔드 코드만으로 구현이 가능했고, 푸시 알림을 위해 구현했던 코드와 흐름을 정리해보았습니다.
Next.js 공식문서에도 PWA 세팅 과정부터 푸시 알림 구현까지 자세히 설명이 되어있습니다. 직접 구현해보며 원리와 흐름을 따라가보았습니다.
https://nextjs.org/docs/app/guides/progressive-web-apps
사용 환경
- Next.js (App Router)
- Neon DB, drizzle-orm (구독 정보 저장을 위한 백엔드)
사용자의 구독 정보 저장, 발송을 위해 Next.js의 API Route 기능도 중점적으로 사용하였습니다.
개념과 흐름
푸시 알림 구현을 위해선 4가지의 주인공이 필요했습니다.
- 사용자 브라우저(PWA)
알림을 허용하고, Service Worker를 등록합니다. 또한 해당 기기로 푸시를 받겠다는 구독(subscription) 객체 정보를 만들어 백엔드 서버에 저장해 둡니다.
푸시를 '받는' 쪽이 아닌, '구독을 등록하고 그 정보를 서버에 넘기는' 역할까지 담당합니다. - Service Worker
브라우저에 등록된 백그라운드 스크립트로 푸시 서버가 전달한 메세지는push이벤트로 이 서비스 워커에 전달되며, payload를 파싱해 시스템 알림을 표시합니다.
사용자가 알림을 클릭하면 이벤트에서 지정된 URL로 이동 해주는 기능도 서비스 워커가 담당합니다. - 백엔드 서버
클라이언트가 보낸 구독 정보를 DB에 저장, 갱신합니다. 푸시를 보낼 시점에 저장된 구독 목록을 조회합니다. 추가적으로 구현한 코드에선Web Push프로토콜을 통해 각 엔드포인트에 대해 푸시 서버로 발송 요청을 보내는 역할까지 담당합니다. - 푸시 서버 (Push Service)
각 브라우저/OS가 제공하는 인프라입니다. (Chrome → FCM, Firefox → Mozilla Push Service 등)
실제로 특정 기기까지 메세지를 전달하고 기기의 Service Worker에 push 이벤트를 발생시키는 것은 푸시 서버가 수행합니다.
전체 흐름을 세 단계로 정리했습니다.
- 구독 등록
- 사용자가 알림을 허용
- 브라우저가 해당 기기로 보낼때 쓸 주소를 푸시 서버에서 받고 구독 객체 생성
- 구독 객체를 DB에 저장
- 푸시 발송
- 알림을 보낼 시점에 DB에서 구독 목록을 조회
- 해당 정보를 토대로 푸시 서버에 요청, 실제 기기까지의 전달은 푸시 서버에서 수행
- 알림 표시
- 푸시 서버가 기기(브라우저)로 전달
- 등록된 Service Worker가 push 이벤트 수신
- payload 데이터 파싱 후 시스템 알림 표시
Service Worker?
Service Worker는 웹 페이지와 분리된, 브라우저가 백그라운드에서 실행하는 스크립트입니다. 일반적인 웹은 탭을 닫으면 스크립트도 함께 사라지지만 Service Worker는 한번 등록되면 해당 출처에 대해 필요할 때만 꺼내 실행이 가능합니다.
- 출처(origin) 단위로 등록됩니다. 예를 들어
https://my-app.com에서 등록한 서비스 워커는 이 도메인에서만 유효합니다. - 네트워크 요청 가로채기, 캐시 활용, 오프라인 동작, 푸시 알림 수신 같은 일을 할 수 있습니다. 푸시 알림 동작을 기능을 위해서 필수적으로 사용해야 합니다.
덧붙이자면 처음에는 next-pwa라는 라이브러리를 통해서 Service Worker 등록과정을 쉽게 구현하고자 했습니다. 하지만 Next.js 16 버전부터 turbopack이 기본 번들러로 설정됨에 따라 빌드 과정에서 호환성 문제로 충돌하는 상황이 발생했습니다.
라이브러리 자체도 마지막 업데이트가 3년 전이라 유지보수가 잘 안 되는 상황 같았습니다. 결론적으로 turbopack의 빠른 빌드 속도와 최신 Next.js 기능 최적화를 놓치고 싶지 않았기에 Service Worker를 직접 구현했습니다.
VAPID 키 발급
먼저 푸시 알림 기능을 구현하기 위해 사전에 VAPID 키 쌍이 필요합니다.
1. VAPID 키 생성
npx web-push generate-vapid-keys해당 명령을 실행시 VAPID 공개키, 비공개키 한쌍이 터미널에 출력됩니다. 이걸 복사해 .env 파일에서 환경변수로 관리해줍니다.
// .env.local
NEXT_PUBLIC_VAPID_PUBLIC_KEY='BBv2NgMH,,,'
VAPID_PRIVATE_KEY='npYfmS1,,,'
2. web-push 라이브러리 설치
npm install web-pushweb-push는 Node.js용 라이브러리로, Web Push 프로토콜을 대신 처리해 줍니다.
푸시 서버(FCM 등)에 보낼 때 필요한 VAPID 서명, payload 암호화, HTTP 요청을 직접 구현하지 않고 라이브러리로 처리합니다.
코드 구현
이제부턴 실제 구현한 코드를 기반으로 서비스 워커 등록부터 실제 푸시 알림 전송까지 전 과정을 흐름 순서대로 정리해보았습니다.
1. 구독 등록 (앱 접속시 한번)
1-1. public/sw.js — Service Worker 설치/활성화
// public/sw.js
self.addEventListener("install", (event) => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});- install
- 서비스 워커 스크립트가 설치될 때 한 번 실행됩니다.
skipWaiting()으로 대기 중인 새 서비스 워커를 즉시 활성화 합니다.
- activate
- SW가 활성화될 때 실행됩니다.
clients.claim()으로 현재 열린 페이지를 이 서비스 워커가 제어하게 됩니다.
1-2. components/service-worker-register.tsx - 앱 진입점
브라우저가 마운트 될때 한번 실행됩니다.
useNotification이라는 커스텀 훅을 두었고 두가지의 함수를 사용하고 있습니다.
subscribeToPush: VAPID 키를 바탕으로 푸쉬 서버에서 구독 객체 생성 및 리턴requestPermission: 유저에게 알림 권한 요청 후 boolean 값 반환
을 담당합니다.
// components/service-worker-register.tsx
"use client";
import { useEffect } from "react";
import { useNotification } from "@/hooks/useNotification";
export default function ServiceWorkerRegister() {
const { subscribeToPush, requestPermission } = useNotification();
useEffect(() => {
const initNotifications = async () => {
if (typeof window === "undefined" || !("Notification" in window)) {
return;
}
if (Notification.permission === "default") {
const granted = await requestPermission();
if (!granted) {
console.log("알림 권한이 거부되었습니다.");
return;
}
}
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js")
.then(async (registration) => {
registration.update();
const autoSubscribe = async () => {
if (Notification.permission === "granted") {
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (!vapidPublicKey) {
console.warn("VAPID 키가 설정되지 않았습니다.");
return;
}
// Web Push 서버가 전송한 구독 정보 객체
const subscription = await subscribeToPush(vapidPublicKey);
if (subscription) {
try {
// 등록한 API Route에 구독 정보 전달
const response = await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription),
});
if (response.ok) {
console.log("백그라운드 알림 자동 구독 완료");
}
} catch (error) {
console.error("구독 정보 저장 중 오류:", error);
}
}
}
};
navigator.serviceWorker.ready.then(() => {
autoSubscribe();
});
})
.catch((error) => {
console.error("Service Worker 등록 실패:", error);
});
}
};
initNotifications();
}, [subscribeToPush, requestPermission]);
return null;
}- register("/sw.js")
- 브라우저에 서비스 워커 스크립트를 등록합니다.
- 반환된 registration은
registration.update()를 호출해 최신 서비스워커 스크립트를 체크하는 데 사용합니다. - 추후 구독(pushManager.subscribe)은 커스텀 훅의
subscribeToPush안에서navigator.serviceWorker.ready로 활성화된 서비스 워커의 registration을 다시 얻은 뒤, 그registration.pushManager.subscribe(...)로 진행됩니다.
// app/layout.tsx
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<body>
{/* 생략... */}
<ServiceWorkerRegister />
</body>
</html>
);
}해당 컴포넌트를 루트 레이아웃에 넣어 사용자가 앱에 들어올 때(페이지 로드 시) 권한 요청 → 서비스워커 등록 → 푸시 구독 → 서버 저장이 자동으로 한 번 실행되도록 했습니다.
1-3. hooks/useNotification.tsx — 권한 + 구독 로직
이제 실제로 유저의 알림 권한을 허용하고 푸시 서버에 요청한 구독 정보를 받아 리턴해주는 커스텀 훅 코드를 살펴보겠습니다.
여기서 중요한 흐름은 두 가지가 있습니다.
subscribeToPush(vapidPublicKey)로 구독 정보를 생성한 뒤 반환해 줍니다.- VAPID 공개키를
urlBase64ToUint8Array유틸로 Uint8Array로 변환해 push manager의subscribe()에 넘겨줍니다.
// hooks/useNotification.tsx
"use client";
import { useState } from "react";
export function useNotification() {
// 알림 권한 상태 (default | granted | denied)
const [permission, setPermission] = useState<NotificationPermission>(() => {
if (typeof window !== "undefined" && "Notification" in window) {
return Notification.permission;
}
return "default";
});
const [isSupported] = useState(
() => typeof window !== "undefined" && "Notification" in window
);
// 알림 허용 요청 -> 구독 전에 반드시 granted 필요
const requestPermission = async () => {
if (!isSupported) {
console.warn("이 브라우저는 알림을 지원하지 않습니다.");
return false;
}
if (Notification.permission === "granted") {
setPermission("granted");
return true;
}
if (Notification.permission === "denied") {
console.warn("알림 권한이 거부되었습니다.");
setPermission("denied");
return false;
}
const result = await Notification.requestPermission();
setPermission(result);
return result === "granted";
};
const subscribeToPush = async (vapidPublicKey: string) => {
if (!isSupported || !("serviceWorker" in navigator)) {
console.warn("Push API를 지원하지 않습니다.");
return null;
}
if (Notification.permission !== "granted") {
const granted = await requestPermission();
if (!granted) return null;
}
try {
const registration = await navigator.serviceWorker.ready; // 활성 서비스 워커 대기
let subscription = await registration.pushManager.getSubscription(); // 기존 구독 있으면 재사용
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
vapidPublicKey
) as BufferSource,
});
}
return subscription; // endpoint, keys.p256dh, keys.auth 담긴 객체
} catch (error) {
console.error("Push 구독 실패:", error);
return null;
}
};
const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
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 as Uint8Array;
};
return {
permission,
isSupported,
requestPermission,
subscribeToPush,
};
}pushManager.subscribe({ applicationServerKey })- VAPID 공개키를 넘겨 브라우저가 푸시 서버와 통신합니다.
- 반환값은
endpoint,keys.p256dh,keys.auth등을 가진 구독 객체입니다.
1-4. app/api/push/subscribe/route.ts — 구독 저장
다음으론 클라이언트가 보낸 subscription(endpoint, keys)을 검증한 뒤 전달준 데이터를 그대로 DB에 저장하는 API입니다.
데이터베이스에 넣어두고, 푸시 발송할 때 이 데이터를 읽어서 일괄 발송하는 식으로 사용할 것입니다.
// app/api/push/subscribe/route.ts
import { db } from "@/db";
import { pushSubscriptionTable } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function POST(request: Request) {
try {
const subscription = await request.json();
if (!subscription.endpoint || !subscription.keys) {
return Response.json(
{ error: "구독 정보가 올바르지 않습니다." },
{ status: 400 }
);
}
const existing = await db
.select()
.from(pushSubscriptionTable)
.where(eq(pushSubscriptionTable.endpoint, subscription.endpoint))
.limit(1);
if (existing.length > 0) {
await db
.update(pushSubscriptionTable)
.set({
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
})
.where(eq(pushSubscriptionTable.endpoint, subscription.endpoint));
return Response.json({
success: true,
message: "구독 정보가 업데이트되었습니다.",
});
}
const subscriptionId = crypto.randomUUID();
await db.insert(pushSubscriptionTable).values({
id: subscriptionId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
});
return Response.json({
success: true,
message: "구독 정보가 저장되었습니다.",
});
} catch (error) {
return Response.json(
{ error: "구독 정보 저장에 실패했습니다." },
{ status: 500 }
);
}
}
실제로 저장된 테이블을 보면 구독(기기)마다 endpoint URL이 하나씩 들어 있고, 이 URL은 브라우저가 쓰는 푸시 서비스(Google FCM, Apple APNs 등)가 구독마다 발급해 준 고유 주소임을 알 수 있습니다.
2. 푸시 발송 (이벤트 발생 시)
이제부턴 실제로 특정 이벤트 발생시 기기에 푸시 알림이 발송되는 흐름을 살펴보겠습니다.
먼저 사용자가 폼 양식 작성 후 제출하면 실행되는 함수입니다. Next.js의 서버 액션 기능을 활용했고, formData를 전달 해 서버 함수를 호출합니다.
const onSubmit = async (data: z.infer<typeof formSchema>) => {
// 생략...
// server function
const res = await createDiary(formData);
};2-1. app/actions/createDiary.ts — 실제 푸시 알림 보내기
// app/actions/createDiary.ts
"use server";
import { db } from "@/db";
import { diaryTable, pushSubscriptionTable } from "@/db/schema";
import { updateTag } from "next/cache";
import webpush from "web-push";
import { eq } from "drizzle-orm";
export async function createDiary(formData: FormData) {
const title = formData.get("title")?.toString() || "";
const content = formData.get("content")?.toString() || "";
const author = Number(formData.get("author")?.toString()) || 1;
const image = formData.get("image")?.toString() || null;
const [inserted] = await db
.insert(diaryTable)
.values({ title, content, author, image })
.returning({ id: diaryTable.id });
updateTag("diary");
const diaryId = inserted?.id;
const authorName = author === 1 ? "이브 엄마" : "이브 아빠";
try {
// VAPID 키 검증
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
if (!vapidPublicKey || !vapidPrivateKey) {
console.error("VAPID 키가 설정되지 않았습니다.");
return { code: 200, success: true };
}
// 푸시 서버 요청 시 서버 소유 증명용
webpush.setVapidDetails(
"mailto:example@example.com",
vapidPublicKey,
vapidPrivateKey
);
const subscriptions = await db.select().from(pushSubscriptionTable);
if (subscriptions.length === 0) {
console.log("구독자가 없습니다.");
return { code: 200, success: true };
}
const promises = subscriptions.map(async (sub) => {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
},
JSON.stringify({
title: "새 다이어리가 등록되었습니다.",
body: `${authorName}가 새 다이어리를 작성했습니다.`,
icon: "/icon-192x192.png",
badge: "/icon-192x192.png",
tag: "diary-created",
data: {
url: diaryId ? `/diary/${diaryId}` : "/diary", // 알림 클릭 시 이동할 URL
},
})
);
return { success: true, endpoint: sub.endpoint };
} catch (error: unknown) {
const webPushError = error as { statusCode?: number; message?: string };
// 410 or 404 → 구독 만료/삭제됨 → DB에서 제거
if (
webPushError.statusCode === 410 ||
webPushError.statusCode === 404
) {
await db
.delete(pushSubscriptionTable)
.where(eq(pushSubscriptionTable.endpoint, sub.endpoint));
}
return {
success: false,
endpoint: sub.endpoint,
error: webPushError.message || "알 수 없는 오류",
};
}
});
const results = await Promise.allSettled(promises);
const successCount = results.filter(
(r) => r.status === "fulfilled" && r.value.success
).length;
console.log(
`푸시 메시지 발송 완료: ${successCount}/${subscriptions.length}`
);
} catch (error) {
console.error("푸시 알림 발송 실패:", error);
}
return { code: 200, success: true };
}try 블록 위쪽은 DB에 단순히 데이터를 넣는 부분이고, 실제 푸시 알림 발송 로직은 그 아래부터 이어집니다.
먼저, 앞에서 저장한 구독정보를 조회한 뒤, webpush.sendNotification에 두가지를 넘겨주게 됩니다.
-
첫번째 인자: DB에 저장해 둔 구독 정보(
endpoint,keys.p256dh,keys.auth)로, 이 구독에 연결된 기기로 보내라는 주소와 암호화에 쓸 키 정보들 입니다. -
두번째 인자: JSON 문자열로 된 payload 데이터로, 푸시 서버가 그대로 해당 기기로 전달하게 되며 기기에서 동작 중인 Service Worker의
push이벤트에서event.data.json()으로 파싱해 쓰게 될 예정입니다.필드 용도 title 시스템 알림에 표시되는 제목 문자열 body 알림 본문 문자열 icon 알림에 함께 보여 줄 아이콘 이미지 URL입니다. PWA 아이콘 경로를 넣어둡니다. badge 알림 영역에서 앱을 구분할 때 쓰는 작은 뱃지 이미지 URL이라고 합니다. 아이콘 이미지 URL을 동일하게 넣어줍니다. tag 같은 종류 알림을 그룹 짓는 식별자입니다. 같은 tag면 기기가 알림을 묶어 주거나 새 알림으로 갱신할 수 있다고합니다.data 알림 클릭 시 Service Worker가 읽는 데이터가 됩니다. 여기서는 url값을 넣어두어 사용자가 알림을 클릭하면 해당 URL로 이동하도록 Service Worker에서 추후에 제어하게 됩니다.
이 과정을 거치면, 푸시 서버가 구독 목록의 각 기기로 메시지를 보내고, 해당 기기의 Service Worker에서 push 이벤트를 받게 됩니다.
3. 기기에서 알림까지
이제 마지막 과정입니다! Service Worker가 푸시 서버에서 전달 받은 푸시 알림을 push 이벤트 수신을 통해 실제 시스템 알림으로 표시해주게 되는 과정이 됩니다.
다시 Service Worker 스크립트로 돌아왔습니다.
3.1 public/sw.js
// public/sw.js (전체)
self.addEventListener("install", (event) => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("push", (event) => {
if (!event.data) return;
let data;
try {
// 푸시 서버를 통해 전달된 payload(JSON 문자열) 파싱
data = event.data.json();
} catch (e) {
return;
}
const title = data.title || "알림";
const body = data.body || "";
const icon = data.icon || "/icon-192x192.png";
const badge = data.badge || "/icon-192x192.png";
// 시스템 알림 띄우기
event.waitUntil(
self.registration.showNotification(title, {
body,
icon,
badge,
tag: data.tag || "",
data: data.data || {}, // 클릭 시 사용할 data 객체
requireInteraction: false,
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
// 서버가 넣어 준 data.url 로 이동 (없으면 루트로)
const urlToOpen = event.notification.data?.url || "/";
const fullUrl = new URL(urlToOpen, self.location.origin).href;
event.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clientList) => {
// 이미 열린 탭이 있으면 그쪽으로 navigate + focus
for (const client of clientList) {
if ("focus" in client && "navigate" in client) {
client.navigate(fullUrl);
return client.focus();
}
}
// 해당 출처 탭이 없으면 새 창 열기
if (self.clients.openWindow) {
return self.clients.openWindow(fullUrl);
}
})
);
});event.waitUntil(showNotification(...))- 이 시점에 실제 OS 알림을 띄웁니다.
title,body,icon,badge로 알림 UI를 구성합니다. requireInteraction: false라서, OS 정책에 따라 사용자가 직접 끄지 않아도 일정 시간 후 자동으로 사라질 수 있습니다.
- 이 시점에 실제 OS 알림을 띄웁니다.
- notificationclick
- 사용자가 알림을 클릭했을 때 실행합니다.
여기까지 과정을 따라가면, 브라우저에서 푸시 알림이 정상적으로 뜨는 것을 확인할 수 있었습니다.

(가족 전용 앱으로 재미삼아 만들었는데 아내가 좋아하는 것을 보니 괜히 뿌듯했습니다ㅎㅎ)
마치며
PWA에서 직접 푸시 알림 기능을 구현해 보며, 굳이 네이티브 앱을 만들지 않아도 앱과 비슷하게 동작하도록 만들 수 있다는 점이 인상적이었습니다.
홈 화면 설치 같은 번거로운 과정이나 모든 네이티브 앱 기능을 완전히 지원하지는 않지만, 간단한 사이드 프로젝트에 적용해 보기에는 충분히 매력적인 선택이라는 생각이 들었습니다.