최근에 React PWA 프로젝트에 FCM(Firebase Cloud Messaging)을 이용한 푸시 알림 기능을 구현했는데, 생각보다 고려할 점들이 많았어서 경험을 공유해보려고 한다. 이 글의 목표는 FCM알림에 대해서 대략적으로 이해하고, 세팅할수 있으며 각 오류에 대응할 수 있는 기본적인 정보를 얻는 것!
구현 목표
웹 브라우저에서 푸시 알림 받기
포그라운드(탭 열린 상태)와 백그라운드(탭 닫힌 상태) 모두 지원
사용자가 알림 ON/OFF를 쉽게 설정할 수 있는 토글 기능
PWA 환경에서도 푸시 알림 받을 수 있게 하기
전체 흐름 이해하기

FCM 알림의 기본 흐름은 다음과 같다
1. 사용자 알림 권한 요청
- 브라우저에서 Notification.requestPermission() 실행.
- 사용자가 허용해야 이후 단계 진행 가능
Notification은 브라우저가 제공하는 웹 표준 API인데, 브라우저에 요청하면 권한 팝업을 브라우저가 띄웁니다. (왼쪽 상단에 알림을 허용하시겠습니까? 라고 뜨는 팝업이랑 비슷합니다!)
2. 서비스워커 등록
- firebase-messaging-sw.js 같은 파일을 루트에 두고 navigator.serviceWorker.register()로 등록.
- 이 워커가 실제 푸시 알림을 수신하고 표시하는 역할.
페이지가 닫혀있어도 푸시알림을 보낼수있게 하는 역할을 해줍니다.
3. FCM 토큰 발급
- 클라이언트에서 Firebase SDK (getToken) 호출 시 VAPID 키를 넣어 호출.
- 브라우저/기기 식별용 FCM Registration Token이 생성됨.
4. 토큰 서버에 저장
- 이 토큰을 백엔드에 저장해야 함.
- DB에 사용자 ID와 매핑해서 관리하는 게 보통.
5. 서버에서 알림 발송
- 서버에서 Firebase Admin SDK 또는 HTTP v1 API로 send 요청.
- 대상 토큰(또는 토큰 배열)에 메시지를 전송.
7. 브라우저에서 알림 수신 및 표시
- 브라우저의 서비스워커가 메시지를 받음.
- self.addEventListener("push", ...) 내부에서 self.registration.showNotification(...) 실행해서 알림 띄움.
FCM구현을 위한 Firebase 설정
1. 프로젝트를 생성한다

2. 웹 푸시 인증서(VAPID 키) 확인
프론트엔드에서 FCM 토큰을 생성할 때 사용하는 키이다. 브라우저가 이 웹사이트가 진짜 알림을 보낼 권한이 있나?확인할 때 사용하고, FCM 서버에게 우리 앱이 맞다고 증명하는 신분증 역할을 한다. 보통 아래와 같은 방식으로 사용한다고 한다.
import { getToken } from 'firebase/messaging';
import { messaging } from './firebase-config';
// 여기서 VAPID 키를 사용!
const token = await getToken(messaging, {
vapidKey: "BL8x9x9x9x..." // 🔥 Firebase Console에서 생성한 그 키!
});
그래서 이 키를 어떻게 만드는고 하니

해당 이미지에서 클라우드 메시징 탭으로 이동하고

이렇게 만들면 공개키가 생긴다. 공개키는 추후에 사용할 것이다.
3. 서비스 계정 키 생성 및 다운
Spring 서버가 FCM 백엔드와 통신 및 메세지를 보낼수 있으려면 인증정보를 제공해야한다. 이때 서비스 계정키가 필요하다.

여기에서 새 비공개 키 생성 버튼이 있는데 이걸 클릭한뒤 키생성을 클릭한다. 이때 키가 포함된 JSON이 다운되게 될텐데, 서버에서 FCM을 사용할수있는 인증정보다 담겨져있는것이다.
또 서비스 계정키를 생성해야하는데, 밑에있는 새 비공개 키 생성버튼을 누르고 키생성을 클릭합니다. 그러면 서비스 계정 키가 포함된 JSON파일이 다운되는데 여기서 서버에서 FCM을 사용할수있는 인증정보가 담겨있다.
우리팀은 java를 쓰니까 java로 설정해서 비공개 키를 생성했다!

4. 개발환경 세팅
(Firebase 세팅)src/firebase/config.ts
const firebaseConfig = {
apiKey: '...',
projectId: '...',
messagingSenderId: '...',
appId: '...',
};
export const VAPID_KEY = '...';
(Service Worker 통합)public/service-worker.js
// 기존 기능 유지
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
// + Firebase FCM 추가
try {
importScripts('https://www.gstatic.com/firebasejs/9.23.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/9.23.0/firebase-messaging-compat.js');
firebase.initializeApp({ /* Firebase 설정 */ });
// FCM 로직...
} catch (error) {
console.warn('Firebase 초기화 실패:', error);
}
// + 개발환경에서 MSW도 지원
if (self.location.hostname === 'localhost') {
importScripts('/mockServiceWorker.js');
}
토글 알림설정 구현 설계하기
먼저 어떻게 구현했는지 코드로 설명하기에 앞서서, 어떻게 설계했는지를 큰 틀로 설명해보고자한다. 다이어그램을 사용해서 설명한뒤, 해당플로우를 위해 어떻게 구현했는지 코드로 설명하겠다
처음에 웹/앱에 접속하면?
- 가장 먼저 사용자가 이전에 알림을 켰는지의 여부를 받아오고, 알림을 켰다는 말은 토큰을 이전에 발급받았다는 말이니 토큰을 로컬스토리지에 저장
- 이후 서비스워커가 있는지 확인하고, 있으면 그대로, 없으면 서비스워커를 등록
- 알림설정에 대해서 로컬상태와 서버 상태가 다르면 안되므로 백엔드 API를 활용해서 실제 사용자의 알림 설정값을 확인
- 그리고 조회된 알림의 값에 따라서 UI 스위치를 초기화
- 탭이 열려있는 상태에서도 알림을 받을수있도록 onMessage를 구독!
이렇게 하면 초기화가 완료된다. 토글을 누를때마다 알림이 알맞게 동작할것이다.



사용자가 알림 토글을 ON한다면?
- 오른쪽과 같은 페이지에서 토글을 누르면 먼저 알림 권한을 요청
- 권한이 허용되어있으면 (사용자가 한번이라도 왼쪽 상단에 뜨는 허용버튼을 눌렀다면) FCM토큰을 확인하지만 아니라면 불가 페이지가 뜸
- FCM토큰을 확인하는데 기존토큰이 로컬스토리지에 있다면 (사용자의 로그인이 유지되어있었다면) 해당 토큰을 사용하고 아니면 토큰을 새로 발급
- 가진 토큰을 서버에 전송해서 현재의 알림설정을 백엔드 API로 반영
- 로컬스토리지에 현재 상태를 반영하고
- UI를 변경한다


사용자가 알림 토글을 OFF한다면?
- 토글을 OFF하면
- 로컬상태를 변경하고
- API를 요청하여 서버 알림 설정을 반영합니다. (그럼 알림이 오지 않아요)


실제로 알림이 올때는?
- 우리 백엔드는 방에 피드백이 올때마다 알림 메세지를 전송한다!
- 만약 브라우저 상태가 포그라운드라면 onMessage , 백그라운드라면 onBackgroundMessage를 실행
- 그리고 각 상태에 따라서 실행위치가 메인스레드, 서비스워커로 달라지게 된다.


실제코드 일부
// public/service-worker.js
// 기존 PWA 기능 유지
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
// FCM 기능 추가
try {
importScripts('https://www.gstatic.com/firebasejs/9.23.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/9.23.0/firebase-messaging-compat.js');
firebase.initializeApp({
// Firebase 설정
});
const messaging = firebase.messaging();
// 백그라운드 메시지 처리
messaging.onBackgroundMessage((payload) => {
const title = payload.notification?.title || '새 알림';
const options = {
body: payload.notification?.body || '',
icon: '/logo192.png',
requireInteraction: true,
};
self.registration.showNotification(title, options);
});
} catch (error) {
console.warn('Firebase 초기화 실패:', error);
}
// 개발환경에서 MSW 지원
if (self.location.hostname === 'localhost') {
importScripts('/mockServiceWorker.js');
}
핵심 구현: NotificationService
알림 기능의 핵심은 NotificationService 클래스. 여기서 토큰 생성부터 권한 관리까지 모든 것을 처리한다.
// services/notificationService.ts
export class NotificationService {
static async enable(): Promise<NotificationServiceResult> {
try {
// 1. 브라우저 지원 여부 확인
const supportCheck = isNotificationSupported();
if (!supportCheck) {
throw new Error('이 브라우저는 푸시 알림을 지원하지 않습니다.');
}
// 2. Firebase 설정 확인
if (!messaging || !VAPID_KEY) {
throw new Error('Firebase 설정이 올바르지 않습니다.');
}
// 3. 알림 권한 요청
const permission = await this.requestPermission();
if (permission !== 'granted') {
throw new Error('알림 권한이 거부되었습니다.');
}
// 4. 서비스 워커 등록 확인
const registration = await this.ensureServiceWorkerRegistration();
// 5. 기존 토큰 확인 (중복 생성 방지)
const existingToken = getStoredFCMToken();
let token = existingToken;
if (!existingToken) {
// 6. FCM 토큰 생성
token = await getToken(messaging, {
vapidKey: VAPID_KEY,
serviceWorkerRegistration: registration,
});
if (!token) {
throw new Error('FCM 토큰 생성에 실패했습니다.');
}
// 7. 백엔드에 토큰 전송
await postFCMToken(token);
setStoredFCMToken(token);
}
// 8. 로컬 상태 저장
setStoredNotificationState(true);
return {
success: true,
message: '알림이 성공적으로 활성화되었습니다.',
data: { token },
};
} catch (error) {
setStoredNotificationState(false);
throw new Error(createNotificationErrorMessage(error));
}
}
static disable(): NotificationServiceResult {
setStoredNotificationState(false);
return {
success: true,
message: '알림이 비활성화되었습니다.',
};
}
// 권한 요청 로직
private static async requestPermission(): Promise<NotificationPermission> {
if (!('Notification' in window)) {
throw new Error('이 브라우저는 알림을 지원하지 않습니다.');
}
const currentPermission = Notification.permission;
if (currentPermission === 'granted') {
return currentPermission;
}
return await Notification.requestPermission();
}
// 서비스 워커 등록 확인
private static async ensureServiceWorkerRegistration(): Promise<ServiceWorkerRegistration> {
if (!('serviceWorker' in navigator)) {
throw new Error('이 브라우저는 Service Worker를 지원하지 않습니다.');
}
const existingRegistration = await navigator.serviceWorker.getRegistration();
if (existingRegistration) {
return existingRegistration;
}
const registration = await navigator.serviceWorker.register('/service-worker.js');
await navigator.serviceWorker.ready;
return registration;
}
}
React 훅으로 상태 관리
FCM 관련 상태들을 관리하기 위해 커스텀 훅을 만들었다
// hooks/useFCMManager.ts
export const useFCMManager = () => {
const [permission, setPermission] = useState<NotificationPermission>('default');
const [isEnabled, setIsEnabled] = useState(() => getStoredNotificationState());
// 포그라운드 메시지 처리
useEffect(() => {
if (!messaging) return;
const unsubscribe = onMessage(messaging, (payload: MessagePayload) => {
if (Notification.permission === 'granted') {
new Notification(payload.notification?.title || '새 알림', {
body: payload.notification?.body || '새로운 메시지가 있습니다.',
icon: '/logo192.png',
data: payload.data,
});
}
});
return () => unsubscribe();
}, []);
const updateState = (enabled: boolean) => {
setIsEnabled(enabled);
setStoredNotificationState(enabled);
};
return {
isEnabled,
permission,
updateState,
};
};
사용자 토글 구현
사용자가 쉽게 알림을 켜고 끌 수 있도록 토글 기능을 구현했다.
// hooks/useNotificationSettingsPage.ts
export const useNotificationSettingsPage = () => {
const { isEnabled: localEnabled, updateState } = useFCMManager();
// 서버 설정과 로컬 설정 동기화
const { data: serverSettings } = useQuery({
queryKey: QUERY_KEYS.notificationSettings(),
queryFn: getNotificationSettings,
});
const updateMutation = useNotificationSettingMutation({
localEnabled,
updateState,
});
const updateNotificationSetting = (enabled: boolean) => {
if (enabled === isToggleEnabled || updateMutation.isPending) {
return;
}
// 250ms 딜레이로 UI 반응성 개선
setTimeout(() => {
updateMutation.mutate({ enabled });
}, 250);
};
return {
isToggleEnabled: serverSettings?.data?.alertsOn ?? localEnabled,
isLoading: updateMutation.isPending,
updateNotificationSetting,
};
};
트러블 슈팅
1. 토큰 중복 생성 문제
처음엔 사용자가 토글을 여러 번 누르면 FCM 토큰이 계속 생성되는 문제가 있었습니다!
해결법: 로컬 스토리지에 토큰을 저장하고, 기존 토큰이 있으면 재사용하도록 수정했습니다.
// 기존 토큰 확인 후 재사용
const existingToken = getStoredFCMToken();
let token = existingToken;
if (!existingToken) {
token = await getToken(messaging, { /* ... */ });
setStoredFCMToken(token);
}
2. 권한 상태 동기화 문제
브라우저에서 권한을 수동으로 변경하면 앱 내 상태와 동기화되지 않는 문제가 있었습니다.
해결법: 주기적으로 권한 상태를 체크하는 로직을 추가했습니다.
useEffect(() => {
const interval = setInterval(() => {
const updatedPermission = Notification.permission;
if (updatedPermission !== permission) {
setPermission(updatedPermission);
}
}, 1000);
return () => clearInterval(interval);
}, [permission]);
3. IOS정책으로 인한 PWA 알림 안옴문제
이는 사용자가 들어가자마자 알림 권한 모달창이 뜨게 해선 안되고, 특정 상호작용후에 뜨게 해야 한다는 정책이었습니다. 그래서 로그인 버튼을 눌러야 권한 요청을 할수있게 하여 수정했습니다.
마무리
위에 작성한것 말고도 많은 문제들이 생겼고, 해결했었는데요. 하나 후회되는 점은 문제를 바로 해결하지 말고 기록해두었으면 좋았겠다는 점입니다. 뿐만아니라 좀더 괜찮은 설계를 해볼수있지 않았을까 하는 생각도 듭니다. 물론 앞으로도 고도화하면서 설계 내용을 조금씩 수정할것같습니다!
참고 자료
'프론트엔드' 카테고리의 다른 글
| Sentry로 에러 모니터링 시스템 구축하기 in 피드줍줍 (0) | 2025.09.21 |
|---|---|
| AWS CodePipeline으로 자동 배포 시스템 구축하기 (0) | 2025.09.21 |
| 피드줍줍 성능 최적화: Lighthouse에 반영되지 않은 개선들 (0) | 2025.09.21 |
| GIF로 vscode 단축키 5분만에 습득하기 (mac) (0) | 2025.02.23 |
| Todo Tree로 깔끔하게 Todo, 요구사항 관리하기 (4) | 2024.11.05 |