main-logo

PWA 꽃 웹 푸시 기능 구현하기

웹으로도 푸시 알림을 받을 수 있다! Next.js와 FCM으로 웹 푸시 구현하기

profile
zurang23
2025년 03월 30일 · 0 분 소요

들어가며

이전 포스트에서 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를 붙여가며 테스트해보니 하나하나 납득되는 과정이 되었고요. 덕분에 웹에서도 네이티브 앱 못지않은 기능을 충분히 구현할 수 있다는 가능성을 체감하게 됐습니다.

웹이 점점 앱의 영역까지 확장되고 있습니다. 지금이야말로 웹에서도 알림을 활용해 사용자와의 접점을 넓혀야 할 시점 아닐까요?