[Frontend] Next.js PWA에서 FCM 웹 푸시 알림 구현하기

YuminPark·2026년 6월 1일

Frontend

목록 보기
17/17
post-thumbnail

지난 글에 Next.js PWA 세팅을 완료했는데, 이번엔 그 위에 FCM(Firebase Cloud Messaging)을 활용한 푸시 알림 기능을 구현한 과정을 정리해보려 합니다.
글릿은 가벼운 하루 기록을 통해 나만의 커리어 서사를 만들어주는 서비스입니다. 사용자가 원하는 요일과 시간에 맞춰 푸시 알림으로 기록을 상기시켜주는 기능이 필요했고, 이를 FCM 기반으로 구현했습니다.

FCM을 사용하려면 Firebase 콘솔에서 프로젝트 생성, 서비스 계정 키 발급, 백엔드에서 FCM API로 메시지를 발송하는 설정이 필요합니다. 이 부분은 백엔드팀에서 처리해주셨기 때문에 이 글에서는 다루지 않습니다. (백엔드 연동 방법이 궁금하신 분은 아래 참고 자료를 보시면 좋을 것 같습니다.)

전체 코드가 궁금하시다면 PR 링크를 참고해주세요!

참고 자료


전체 구현 흐름

1. Firebase 초기화
         ↓
2. 서비스 워커 등록 + 백그라운드 푸시 수신
         ↓
3. 홈 화면 첫 클릭 시 알림 권한 요청
         ↓
4. 권한 허용 → FCM 토큰 발급 → 서버에 디바이스 토큰 등록
         ↓
5. 알림 설정 기본값 저장
         ↓
6. 마이페이지에서 알림 설정 조회·수정

1. Firebase 초기화

FCM을 사용하려면 먼저 Firebase 앱을 초기화해야 합니다.
Firebase 콘솔에서 발급받은 설정값들을 .env.local.env.production에 추가하고 불러옵니다.

// src/lib/utils/fcm.ts
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,
  measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
};

export const app = initializeApp(firebaseConfig);

let _messaging: Messaging | null = null;

export const getMessagingInstance = (): Messaging | null => {
  if (typeof window === "undefined") return null;
  if (!_messaging) _messaging = getMessaging(app);
  return _messaging;
};

여기서 신경 쓴 부분이 두 가지 있습니다.

  • SSR 가드 : Next.js는 서버와 브라우저 양쪽에서 코드를 실행하는데, FCM의 getMessaging()은 브라우저 전용 API라 서버에서 호출하면 에러가 납니다. typeof window === "undefined" 체크로 서버에서는 실행되지 않도록 막았습니다.

  • 싱글톤 패턴 : _messaging 변수에 인스턴스를 캐싱해서 앱 전체에서 메시징 인스턴스가 딱 하나만 생성되도록 관리했습니다.

2. 서비스 워커

서비스 워커는 앱을 직접 사용하지 않는 상태(백그라운드)에서도 푸시를 수신하기 위해 필요합니다. 브라우저가 백그라운드에서 독립적으로 실행하는 스크립트라고 이해하면 됩니다.
Next.js에서는 public/ 폴더에 파일을 두면 /firebase-messaging-sw.js 경로로 접근할 수 있어서 여기에 위치시켰습니다.

self.addEventListener("install", () => self.skipWaiting());

self.addEventListener("push", (e) => {
  if (!e.data) return;
  const { title, body } = e.data.json().notification;
  const link = e.data.json().webpush?.fcm_options?.link || "/";

  e.waitUntil(
    self.registration.showNotification(title, {
      body,
      icon: "/icon-192x192.png",
      data: { link },
    }),
  );
});

self.addEventListener("notificationclick", (e) => {
  e.notification.close();
  e.waitUntil(clients.openWindow(e.notification.data?.link || "/"));
});
  • install에서 skipWaiting()을 호출해서 새 서비스 워커가 설치되는 즉시 활성화되도록 했습니다.
  • push 이벤트에서 서버가 보낸 payload를 파싱해 알림을 띄웁니다.
  • notificationclick에서 알림 클릭 시 payload에 담긴 링크로 이동시킵니다.

importScripts로 Firebase SDK를 불러오는 방식도 있지만, 저는 push 이벤트를 직접 처리하는 방식으로 구현했습니다. Firebase SDK 의존성 없이도 동작하고, 알림 동작을 직접 제어할 수 있어서 더 적합하다고 판단했습니다.

3. 알림 권한 요청

권한 요청 로직은 컴포넌트함수로 역할을 나눠서 구현했습니다.

컴포넌트는 마운트 시 서비스 워커를 등록하는 역할만 하고 화면에는 아무것도 렌더링하지 않습니다(return null). 홈 화면에 올려두면 페이지가 로드될 때 서비스 워커가 자동으로 등록됩니다.

requestNotificationPermission 함수가 실제 권한 요청과 토큰 발급을 담당합니다. 함수를 컴포넌트 밖으로 분리한 이유는, 홈에서 특정 시점(첫 터치)에만 호출해야 했기 때문입니다. 컴포넌트 안에 넣으면 마운트 즉시 실행되어버립니다.

// src/components/common/NotificationPermission.tsx
export const requestNotificationPermission = async () => {
  const permission = await Notification.requestPermission();

  if (permission !== "granted") {
    // 거부 시 서버에도 비활성화 상태로 저장
    await patchAlarmSettings({ isActive: false }).catch(console.error);
    return;
  }

  const messaging = getMessagingInstance();
  const registration = await navigator.serviceWorker.ready; // SW가 완전히 준비될 때까지 대기
  const token = await getToken(messaging, {
    vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY,
    serviceWorkerRegistration: registration,
  });

  await postDeviceToken(token);

  // 최초 허용 시 기본값: 평일 22:00
  await patchAlarmSettings({
    isActive: true,
    daysOfWeek: ["MON", "TUE", "WED", "THU", "FRI"],
    notifyTime: "22:00",
  });
};

토큰 발급 시 navigator.serviceWorker.ready를 기다리는 이유는, 서비스 워커가 완전히 준비되기 전에 토큰을 요청하면 실패할 수 있기 때문입니다.

4. 홈 화면 — 첫 터치 시 권한 요청 트리거

const handleFirstClick = () => {
  if (Notification.permission === "denied") return;        // 이미 거부한 경우 스킵
  if (localStorage.getItem("notification_asked")) return; // 이미 물어본 경우 스킵
  localStorage.setItem("notification_asked", "true");
  requestNotificationPermission();
};

return (
  <div onClick={handleFirstClick}>
    <NotificationPermission />
    ...
  </div>
);

홈 전체에 onClick을 달아서 첫 터치 시 딱 한 번만 권한 요청이 실행되도록 했습니다. localStorage로 이미 요청했는지 기억하고, 이미 거부(denied) 상태면 더 이상 묻지 않습니다.

이렇게 구현한 데는 iOS 제약 때문이기도 합니다.
Safari에서는 Notification.requestPermission()반드시 사용자 터치나 클릭 같은 제스처 컨텍스트 안에서 호출되어야 합니다.
useEffect에서 바로 호출하면 iOS에서는 팝업 자체가 뜨지 않기 때문에, 홈에 들어온 뒤 화면을 한 번 터치하는 시점에 실행되도록 했습니다.

5. API 연동

디바이스 토큰 등록

FCM이 발급한 토큰을 서버에 저장해야, 서버가 FCM을 통해 해당 기기로 푸시를 발송할 수 있습니다.

// src/lib/apis/auth/deviceToken.ts
export const postDeviceToken = (pushToken: string) =>
  api.post("/api/users/me/device-tokens", { platform: "WEB", pushToken });

플랫폼을 "WEB"으로 명시해서 서버가 iOS, Android와 구분할 수 있도록 했습니다.

알림 설정 조회, 저장

// src/lib/apis/user/notification.ts
export const getAlarmSettings = () =>
  api.get<AlarmData>("/api/users/me/notification-settings");

export const patchAlarmSettings = (body: Partial<AlarmData>) =>
  api.patch("/api/users/me/notification-settings", body);

Partial<AlarmData>로 받아서 isActive만 바꾸거나, 요일,시간 전체를 한 번에 넘기는 등 유연하게 활용할 수 있도록 했습니다.

6. 데이터 변환 유틸

UI에서 다루는 형식과 API 요청 형식이 달라서 변환 유틸이 필요했습니다.

UIAPI
요일"월", "화", "수""MON", "TUE", "WED"
시간오전/오후 12시간제 TimeValue 객체"22:00" 같은 24시간제 문자열
// UI → API 변환
export const toAlarmData = (settings): AlarmData => ({
  isActive: settings.isActive,
  daysOfWeek: settings.selectedDays.map(d => DAY_TO_DAY_OF_WEEK[d]),
  notifyTime: toNotifyTime(settings.time), // TimeValue → "HH:MM"
});

// API → UI 변환
export const fromAlarmData = (data: AlarmData) => ({
  isActive: data.isActive,
  selectedDays: data.daysOfWeek.map(d => DAY_OF_WEEK_TO_DAY[d]),
  time: data.notifyTime ? fromNotifyTime(data.notifyTime) : { hour: 7, minute: 0, meridiem: "Am" },
});

toAlarmData(저장)와 fromAlarmData(조회)를 대칭으로 구성해서, AlarmForm에서는 변환 로직을 신경 쓰지 않고 UI 상태만 다룰 수 있도록 관심사를 분리했습니다.

7. 마이페이지 알림 설정

알림을 허용하면 기본값(평일 22:00)으로 설정했지만, 사용자마다 원하는 요일과 시간이 다를 수 있습니다. 마이페이지에서 알림 수신 여부를 직접 제어하고, 원하는 요일과 시간으로 변경할 수 있도록 서버와 연동했습니다.
상태는 saved(서버에 저장된 값)와 draft(편집 중인 임시 값) 두 가지로 분리했습니다.

const [saved, setSaved] = useState<AlarmSettings | null>(null);
const [draft, setDraft] = useState<AlarmSettings | null>(null);

const current = isEditing ? draft : saved; // 편집 중엔 draft, 아니면 saved를 보여줌

// 마운트 시 서버에서 초기값 불러오기
useEffect(() => {
  getAlarmSettings().then(res => {
    if (!res) return;
    const settings = fromAlarmData(res);
    setSaved(settings);
    setDraft(settings);
  });
}, []);

// 완료 버튼 클릭 시 서버에 저장
const handleSave = async () => {
  if (!draft) return;
  await patchAlarmSettings(toAlarmData(draft));
  setSaved(draft);
  setIsEditing(false);
};

saved / draft를 분리한 덕분에 편집 중 취소를 누르면 서버 저장값(saved)으로 자연스럽게 롤백됩니다.
current = isEditing ? draft : saved 한 줄로 렌더링할 값을 결정하는 것도 깔끔하게 처리됩니다.

테스트 결과

마이페이지에서 매일 오후 17:00로 설정해둔 결과, 아래 사진과 같이 알림이 정상적으로 수신되는 것을 확인했습니다.


마무리

구현하면서 신경 쓴 부분을 간단히 정리하면 이렇습니다.

  • iOS 대응 : Safari는 사용자 제스처 없이 알림 권한을 요청할 수 없어서, 홈 첫 터치 시점에 실행되도록 설계했습니다.
  • SSR 안전성 : FCM은 브라우저 전용 API이므로 서버 환경에서 실행되지 않도록 가드를 추가했습니다.
  • 권한 거부 처리 : 거부 시에도 서버에 isActive: false로 명시적으로 동기화합니다.
  • 상태 분리 : saved / draft 패턴으로 편집 중 UI와 서버 동기화 상태를 깔끔하게 분리했습니다.

웹에서도 네이티브 앱 수준의 푸시 알림을 구현할 수 있다는 점이 PWA의 가장 큰 매력인 것 같습니다. 서비스 워커와 FCM을 조합하면 포그라운드/백그라운드 모두 안정적으로 동작하는 푸시 알림을 만들 수 있으니, 관심 있으신 분들께 도움이 되었으면 좋겠습니다. 🚀

0개의 댓글