들어가며
이전 포스트에서 PWA를 활용해 네이티브 앱 못지않은 사용자 경험을 웹에서 구현할 수 있다는 이야기를 했었습니다. 특히 그 중심에 있는 기능 중 하나가 바로 웹 푸시 알림 인데요. 이번 포스트에서는 실제 프로젝트에서 직접 구현해본 웹 푸시 기능을 Next.js 14 App Router 환경 + FCM(Firebase Cloud Messaging)을 중심으로 풀어보려고 합니다.
저는 처음엔 단순히 공부 차원에서 시작했는데, 운 좋게도 이어진 프로젝트에서 웹 푸시 기능이 요구되면서 실무에 적용할 기회까지 생겼습니다. 이론만 알았다면 그냥 흘려보냈을 내용들을 실제 API 통신과 서비스워커 등록 등 다양한 과정 속에서 직접 다뤄보면서 제대로 체득할 수 있었고, 웹이 정말 많은 영역에서 네이티브 앱을 대체할 수 있겠다는 걸 실감하게 되었어요.
왜 웹 푸시인가?
모바일 앱에서는 푸시 알림이 사용자와의 접점을 유지하는 가장 강력한 수단 중 하나입니다. 그렇다면 웹 환경에서는 어떨까요? 사용자가 별도 앱을 설치하지 않아도, 웹만으로도 푸시 알림을 수신할 수 있다면 유지보수 비용은 줄고 리텐션은 높아집니다. 특히 PWA와 결합하면 앱과 거의 동일한 사용자 경험을 제공할 수 있죠.
이런 가능성을 실현하기 위해 이번 프로젝트에서 저는 웹 푸시 기능을 직접 도입해보았습니다.
구현 전략
- FCM을 통한 메시지 송신 및 수신
- Next.js 14 App Router 환경 적용
- Firebase 초기화 및 서비스 워커 구성
- 알림 권한 관리 및 토큰 저장 전략
순서대로 하나씩 살펴보겠습니다.
1. Firebase 프로젝트 설정 및 서비스 워커 구성
웹 푸시는 백그라운드에서 실행되는 Service Worker를 기반으로 작동합니다. 알림 수신 및 클릭 이벤트를 이 worker가 처리하게 됩니다.
Firebase 설정
Firebase 콘솔에서 새 프로젝트를 만들고 웹 앱을 등록합니다. 아래와 같은 설정값을 발급받고, `.env` 파일을 통해 보안 처리하는 것이 좋습니다.
// Firebase 설정값을 환경변수로 분리하여 보안 강화
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};
firebase.ts
에서 위 설정값으로 Firebase를 초기화하고 메시징 객체를 구성합니다.
Service Worker 구성
public/firebase-messaging-sw.js
파일을 생성하고 아래와 같이 작성합니다:
// 서비스워커가 설치되자마자 바로 활성화
self.addEventListener("install", () => {
self.skipWaiting();
});
// 백그라운드에서 푸시 메시지를 수신할 때 호출되는 이벤트
self.addEventListener("push", (event) => {
if (!event.data) return;
const data = event.data.json();
const notificationTitle = data.notification.title;
const notificationOptions = {
body: data.notification.body, // 알림 본문 텍스트
icon: data.notification.image, // 알림 아이콘 이미지
data: {
pushId: data.data.pushId, // 추적용 푸시 ID
linkUrl: data.data.linkUrl, // 알림 클릭 시 이동할 URL
},
};
// 알림을 표시
event.waitUntil(
self.registration.showNotification(notificationTitle, notificationOptions)
);
});
// 사용자가 알림을 클릭했을 때 실행되는 이벤트
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const { linkUrl, pushId } = event.notification.data;
// 이 단계에서 클릭 이벤트 api 작성
fetch("", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pushId }),
}).catch(console.error);
// 해당 링크로 이동
event.waitUntil(clients.openWindow(linkUrl));
});
참고로 Next.js에서 messaging-sw를 다르게 구성하는 방식을 추천하는 글들도 있지만, Firebase 설정을 노출하게 되므로 보안 측면에서 위와 같이 별도 파일로 구성하는 방식을 선택했습니다.
2. 알림 권한 및 로컬 스토리지 관리
사용자의 알림 권한 상태와 발급받은 토큰을 로컬 스토리지에 저장해두면 매번 요청을 반복하지 않아도 됩니다.
// 권한 상태 타입 정의
type NotificationPermissionType = "granted" | "denied" | "default";
// 로컬 스토리지 키 정의
const STORAGE_KEYS = {
FCM_TOKEN: "fcm_token",
NOTIFICATION_STATUS: "notification_status",
} as const;
export const notificationStorage = {
// 토큰 저장
saveToken: (token: string) => localStorage.setItem(STORAGE_KEYS.FCM_TOKEN, token),
// 권한 상태 저장
saveStatus: (status: NotificationPermissionType) =>
localStorage.setItem(STORAGE_KEYS.NOTIFICATION_STATUS, status),
// 토큰과 권한 상태 조회
getValues: () => {
if (typeof window === "undefined") return null;
return {
token: localStorage.getItem(STORAGE_KEYS.FCM_TOKEN),
status: localStorage.getItem(STORAGE_KEYS.NOTIFICATION_STATUS) as NotificationPermissionType,
};
},
// 토큰 제거
removeToken: () => localStorage.removeItem(STORAGE_KEYS.FCM_TOKEN),
};
3. 토큰 발급 및 서버 연동
사용자가 알림 권한을 허용하면 FCM 토큰을 발급받고, 이 토큰을 서버에 저장합니다. 해당 로직은 다음과 같습니다.
import { getMessaging, getToken } from "firebase/messaging";
export async function requestNotificationPermission() {
// 알림 권한 요청
const permission = await Notification.requestPermission();
// 권한이 허용된 경우에만 FCM 토큰 발급 진행
if (permission === "granted") {
const messaging = getMessaging();
// VAPID 키는 Firebase 콘솔 > Cloud Messaging 설정에서 확인 가능
const token = await getToken(messaging, {
vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY,
});
if (token) {
// 로컬에 토큰과 권한 상태 저장
notificationStorage.saveToken(token);
notificationStorage.saveStatus("granted");
// 서버에 토큰 전송
await fetch("/api/save-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
}
} else {
// 권한이 허용되지 않은 경우 상태만 저장
notificationStorage.saveStatus(permission);
}
}
4. 파일 구조 정리
파일 구조는 다음과 같이 구성하면 명확하게 책임이 나뉘고 유지보수하기 좋습니다:
├── lib/
│ └── firebase.ts // Firebase 초기화 및 메시징 객체
├── handlers/
│ └── notificationHandler.ts // 알림 권한 및 로컬 스토리지 관리
├── public/
│ └── firebase-messaging-sw.js // Service Worker
구현 시 주의할 점
Safari 브라우저에서는 사용자 액션(예: 버튼 클릭) 없이 알림 권한 요청이 불가능합니다. 따라서 알림 허용을 유도할 버튼 등의 UI 요소를 배치하는 것이 필요합니다.
마무리하며
단순한 스터디였던 PWA 주제가 자연스럽게 웹 푸시까지 확장되었고, 운 좋게 실제 프로젝트에서도 기능이 필요해졌던 덕분에 직접 구현해보며 경험을 쌓을 수 있었습니다.
이론만 봤을 때는 낯설고 복잡해 보였던 개념들이 실제로 코드를 구성하고, 서비스 워커를 등록하고, API를 붙여가며 테스트해보니 하나하나 납득되는 과정이 되었고요. 덕분에 웹에서도 네이티브 앱 못지않은 기능을 충분히 구현할 수 있다는 가능성을 체감하게 됐습니다.
웹이 점점 앱의 영역까지 확장되고 있습니다. 지금이야말로 웹에서도 알림을 활용해 사용자와의 접점을 넓혀야 할 시점 아닐까요?