
해당 포스트는 Push Notification의 구현기를 담고 있어요.

서비스 접속 시 푸시 이벤트 및 알림 권한 흐름
- 최초 접속 시 서비스 워커 설치
- FCM 관련 이벤트 등록
- 유저에게 알림 권한 요청
크게 (1) FCM 서비스 워커 설치 관련 로직과 (2) 알림 권한 관련 로직으로 나눌 수 있습니다. 서비스 워커의 설치 유무에 따라 동작이 달라져야 하고, 알림 권한 유무에 따라 동작이 달라져야 합니다. 그러므로 총 4가지의 경우의 수가 나오는데요, 요것을 처리를 해주어야 합니다.
흐름을 따라가보며 설명해보겠습니다.
initFirebaseApp()
export const initFirebaseApp = async () => { const firebaseApps = getApps(); /** 최초 Firebase App instance 생성 \*/ if (firebaseApps.length === 0) { initializeApp(firebaseConfig); } /** 서비스 워커 초기화 \*/ await initFirebaseWorker(); /** 알림 권한 설정 \*/ await verifyNotificationPermission(); };
최초로 initFirebaseApp()를 호출합니다. 이 녀석은 보다시피 3가지의 로직을 호출하고 있습니다.
- Firebase App Instance 생성
- Firebase 관련 Service Worker 초기화
- 알림 권한 설정
사실 initializeApp()을 여러 번 호출해도 같은 인스턴스를 반환합니다. 그렇지만 불필요한 호출은 막아주는 것이 좋겠지요. 따라서 한 번만 호출할 수 있게 처리해줍니다.
그 다음 살펴볼 것은 서비스 워커 초기화를 기다린 후에 알림 권한 설정을 진행한다는 점인데요, 서비스 워커와 알림 권한 요청은 병렬로 처리해도 별 문제가 없을 것 같은 별개의 로직 같지 않나요?
하지만 기다린 다음에 알림 권한 요청을 하는 이유는 FCM의 getToken()의 내부적인 구조적인 이유 때문에 그렇습니다. 더 앞서서, 사실 Firebase Service Worker를 따로 init해주는 이유도 이와 관련이 있습니다. 아래에서 더 자세히 다뤄볼게요.
initFirebaseWorker()
export const initFirebaseWorker = async () => { const firebaseWorker = await getFirebaseWorker(); const hasNoFirebaseWorker = firebaseWorker === undefined; if (hasNoFirebaseWorker) { const newFirebaseWorker = await createNewFirewbaseWorker(); return listenFCMEvent(newFirebaseWorker); } return listenFCMEvent(firebaseWorker); };
접속을 했을 때 이전에 접속을 했던 사람이라면 이미 서비스 워커가 설치되어 있겠습니다. 마찬가지로 최초로 접속한 사람이라면 서비스 워커가 설치가 되어 있지 않겠죠? 이 경우를 위해 서비스 워커를 생성합니다. 이후 FCM와 관련된 이벤트를 구독합니다.
여담으로, 위의 코드는 아래와 같이 줄여 쓸 수 있습니다.
return listenFCMEvent((await getFirebaseWorker()) || (await createNewFirewbaseWorker()));
하지만 분명 아래의 코드보다 위의 코드가 분명 더 한 눈에 들어오기 때문에, 우리 모두 코드가 길어져도 명시적인 코드를 생각하며 코드를 짭시다(?)
createNewFirewbaseWorker()
const createNewFirewbaseWorker = async (): Promise<ServiceWorkerRegistration\> => { const newFirebaseSW = await navigator.serviceWorker.register('firebase-messaging-sw.js', { scope: FCM_SCOPE, }); return new Promise((resolve) => { const handleStateChange = async () => { const isActivated = newFirebaseSW.active?.state === 'activated'; if (isActivated) { resolve(newFirebaseSW); } }; newFirebaseSW.installing?.addEventListener('statechange', handleStateChange.bind(this)); }); };
여기가 바로 코드의 핵심인데요, 사실 이 코드는 필요없을 지도 몰랐을 코드입니다. 이게 무슨 말이냐면, 사실 FCM에서 제공하는 getToken()은 호출 시에 내부적으로 서비스 워커를 설치합니다.
FCM 내부 코드
async function getToken$1(e, t) { if (!navigator) throw C.create('only-available-in-window'); if ( ('default' === Notification.permission && (await Notification.requestPermission()), 'granted' !== Notification.permission) ) throw C.create('permission-blocked'); return ( await (async function updateVapidKey(e, t) { t ? (e.vapidKey = t) : e.vapidKey || (e.vapidKey = I); })(e, null == t ? void 0 : t.vapidKey), await (async function updateSwReg(e, t) { if ((t || e.swRegistration || (await registerDefaultSw(e)), t || !e.swRegistration)) { if (!(t instanceof ServiceWorkerRegistration)) throw C.create('invalid-sw-registration'); e.swRegistration = t; } })(e, null == t ? void 0 : t.serviceWorkerRegistration), getTokenInternal(e) ); } async function registerDefaultSw(e) { try { (e.swRegistration = await navigator.serviceWorker.register('/firebase-messaging-sw.js', { scope: '/firebase-cloud-messaging-push-scope', })), e.swRegistration.update().catch(() => {}); } catch (e) { throw C.create('failed-service-worker-registration', { browserErrorMessage: null == e ? void 0 : e.message, }); } }
자세하게 볼 건 updateSwReg() 부분입니다. 만약 등록된 서비스 워커가 없다면 registerDefaultSw()를 호출하여 기본 서비스 워커를 등록하게 됩니다. 다만 포인트는 register() 호출 이후에 서비스 워커는 Update 사이클을 돌게 됩니다. Install -> Wait -> Activate 상태를 거치며 서비스 워커를 등록합니다. 즉, register()를 호출한다고 해도 해당 서비스 워커가 곧바로 active 상태가 되는 것은 아니라는 말입니다.
하지만 해당 코드에는 이에 대해 고려를 하지 않고 getTokenInternal()를 호출하는데요, 함수를 살펴보면 다음과 같이 호출하고 있습니다.
getTokenInternal()
async function getTokenInternal(e) { const t = await (async function getPushSubscription(e, t) { const n = await e.pushManager.getSubscription(); if (n) return n; return e.pushManager.subscribe({ userVisibleOnly: !0, applicationServerKey: base64ToArray(t), }); // ...
pushManager의 구독 목록을 얻고, 구독이 없다면 구독을 진행하게 됩니다. 하지만, pushManager에서 활성화 된 서비스 워커가 없이 구독을 시도하게 되면 아래의 메시지를 마주하게 됩니다.

Failed to execute 'subscribe' on 'PushManager': Subscription failed - no active Service Worker
사실 이 예외에 대해 고려하지 않는 것도 어쩌면 당연한 게, 설치를 하고 나서 활성화가 될 때까지는 찰나의 시간이 걸리기 때문입니다.

물론,, 원인에 대해선 어디까지나 뇌피셜이지만 해당 문제를 계속 재현해봤을 때 간헐적으로 성공하는 걸 봤을 땐 그런 경향이 있었습니다. 그러므로 비동기 딜레이에 의한 이슈라고도 판단하는 건 무리가 아닐 겁니다!
여튼 그래서, 이를 임시로 해결하는 방법이 createNewFirewbaseWorker() 처럼 같은 스코프로 미리 등록을 하고, active 상태까지 만든 다음 getToken()을 불러오는 것입니다. 그렇게 되면 내부 로직에 의해 이미 등록된 서비스 워커가 있으니 새로 등록은 하지 않을 테고, 활성화 된 서비스 워커가 있으니 Push Manager의 subscribe()도 정상 작동하게 됩니다.
listenFCMEvent()
const listenFCMEvent = (firebaseSW: ServiceWorkerRegistration) => { const messaging = getMessaging(); onMessage(messaging, async (payload) => { console.log('Foreground Message received. ', payload); if (Notification.permission !== 'granted') { /** * FCM 서버에서 메시지가 왔을 때 알림 권한 요청을 하는 모양새는 부자연스러으므로 * 무시를 하거나 따로 처리가 필요합니다. \*/ console.warn('FCM 서버에서 푸시는 받았는데 알림 권한이 없어요.'); return; } const { title, options } = createNotificationItem(payload); firebaseSW.showNotification(title, options); }); };
이후 FCM 이벤트인 onMessage()를 구독하게 됩니다. 해당 메시지는 Foreground 일 때 받는 핸들러인데요, 자세하게는 FCM 서버에서 직접 받는 게 아니긴 합니다. 서비스 워커에서 먼저 받고 현재 활성화 된 윈도우가 있다면 postMessage()로 쏴주는 거죠. 그래서 onMessage()인 것도 있겠습니다.
여튼, 활성화된 창이 있을 때 푸시가 온다면 선택지가 두 개가 있습니다. toast로 처리하거나 알림을 띄우거나. 어디까지나 테스트 목적이라 저는 알림을 띄웠습니다만, Toast로 따로 처리를 하는 게 유저 경험에 좋을 듯 합니다.
또, 사실은 Foreground 일 때 알림을 띄우는 과정에서 굉장히 삽질을 많이 했었는데요, 이와 관련해서는 포스트의 끝 부분에서 다뤄보겠습니다. 진짜 힘드러써요
verifyNotificationPermission()
export const verifyNotificationPermission = async () => { const messaging = getMessaging(); try { const fcmToken = await getToken(messaging, { vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPIDKEY, }); // localStorage.setItem('FCM_TOKEN', fcmToken); } catch (error) { /** 권한 관련 에러 체크 \*/ if (Notification.permission !== 'granted') { // return localStorage.removeItem('FCM_TOKEN'); console.log('알림 권한 요청 거절 당함 ㅜㅜ'); return; } /** 다른 오류 \*/ console.error(error); } };
이후 알림 권한을 요청하게 되는데요, 알림 권한에 관련된 코드가 전혀 없죠? 사실 getToken()이 바로 그 역할을 합니다. getToken()은 알림 권한이 없으면 권한 요청을 합니다. 위에서 getToken()의 내부 코드를 보면 이런 로직이 있었습니다.
if ( ('default' === Notification.permission && (await Notification.requestPermission()), 'granted' !== Notification.permission) )
거절이나 승인 상태가 아닌 default 상태라면 권한 요청을 하게 됩니다. denied 상태를 고려하지 않는 이유는, 알림 권한을 거부한다면 유저가 직접 브라우저에서 따로 설정하지 않는 이상 요청할 수 없기 때문이에요.
그렇기 때문에 해당 로직을 이용해서 getToken()만을 호출하는 것입니다. 해당 함수가 알림 권한을 요청하기 때문! (그러고 보면 getToken()에서 많은 일이 일어나고 있네요, 서비스 등록도 하고 알림 권한도 요청하고 토큰도 반환하고...)
Notification의 requestPermission()은 거절 당하거나 취소를 누르면 reject를 반환합니다. 이에 따라 오류를 처리하면 되겠습니다.
마지막으로 살펴볼 건 서비스 워커 파일이 되겠습니다. 사실, 서비스 워커도 위와 같은 흐름과 다를 게 없지만 조금 다른 건 모듈이 아니라 CommonJS로 다뤄야 한다는 점일까요.
서비스 워커도 모듈을 지원합니다만, 우리의 FCM에서는 서비스 워커를 등록할 때 type을 classic으로 지정하고 등록하기 때문입니다. (생략 시 classic)
그렇기 때문에, 모듈식이 아닌 네임스페이스로 다뤄야 합니다. 이를 위해 compat 스크립트를 불러오고 시작합니다.
firebase-messaging-sw.js
importScripts('https://www.gstatic.com/firebasejs/10.4.0/firebase-app-compat.js'); importScripts('https://www.gstatic.com/firebasejs/10.4.0/firebase-messaging-compat.js'); // ... const listenBackgroundMessage = () => { const messaging = firebase.messaging(); messaging.onBackgroundMessage((payload) => { console.log('[firebase-messaging-sw.js] Received background message ', payload); const { title, options } = createNotificationItem(payload); return self.registration.showNotification(title, options); }); }; const listenNotificationClick = () => { self.addEventListener('notificationclick', function (event) { console.log('[firebase-messaging-sw.js] Received Notification Click ', event); const url = '/'; event.notification.close(); event.waitUntil(clients.openWindow(url)); }); }; const listenNotificationEvents = () => { listenBackgroundMessage(); listenNotificationClick(); }; const initServiceWorker = async () => { if (firebase.apps.length === 0) { firebase.initializeApp(firebaseConfig); } const isMessagingSupported = await firebase.messaging.isSupported(); if (!isMessagingSupported) { return console.warn('[firebase-messaging-sw.js] Messaging is not supported.'); } listenNotificationEvents(); }; console.log('[firebase-messaging-sw.js] Loaded Service Worker'); initServiceWorker();
함수형 프로그래밍은 역시 이렇게 콜백 형태로 작성해줘야 제맛이죠.
sdk로 불러오게 되면 Global에 firebase가 등록되게 됩니다. 참고로 서비스 워커에서 최상위 객체는 window가 아닌 ServiceWorkerGlobalScope 입니다. 그렇기에 window와 다른 점을 살짝 짚어보자면, 서비스 워커는 push와 notificationclick 이라는 이벤트를 받을 수 있습니다.
FCM에서는 메시지가 발생하면 push 이벤트로 내려주게 됩니다. 이것을 내부적으로 구현한 것이 onBackgroundMessage()구요.
알림을 클릭하게 되면 notificationclick 이벤트를 받을 수 있습니다. 알아두어야 할 점은 만약 Foreground 상태로 알림을 띄웠다고 하더라도, 해당 알림의 클릭 이벤트는 서비스 워커에 등록된 notificationclick으로 콜백됩니다. 조금만 생각해보면 당연하게도, 알림 자체는 서비스 워커의 동작이니까요.
재밌는 것은 알림에 actions 속성이 있을 때인데요, action에 따라 버튼이 생기게 되고 해당 버튼 마다 동작을 다르게 만들 수 있습니다. action을 클릭하면, event의 action에 해당 액션의 이름이 들어옵니다.

반대로 알림 자체를 클릭하면 action에는 빈 문자열이 들어오게 됩니다.

이에 따라 적절하게 처리하면 되겠습니다.
받기 위한 코드는 전부 작성했으니, 이제 FCM쪽으로 보내는 일만 남았네요! 사실 이 부분은 꽤나 간단해서 쓱 훑어보면 됩니다.
FCM 서버에 메시지를 쏘는 방법은 rest로 쏘는 방법도 있으나, 제일 간단한 건 firebase-admin을 설치해서 사용하는 것입니다.
Firebase 콘솔 > 프로젝트 설정 > 서비스 계정으로 들어가서 새 비공개 키 생성을 누르면 JSON파일이 하나 다운로드 되는데, 이 파일로 프로젝트 설정하면 되겠습니다.

참고로 firebase admin은 아직 네임스페이스로밖에 지원이 안 되는 듯 합니다.
JSON 파일의 내용을 env로 등록해서 하나하나 설정해줄 수도 있지만, 환경변수에 위에서 다운로드한 json 파일이 위치한 주소를 등록하는 방법도 있어요. 파이어베이스에서도 해당 방식을 권장하고 있기도 하고요.
admin.initializeApp({ credential: admin.credential.applicationDefault(), });
이렇게 applicationDefault()를 호출하면 아래의 환경변수를 찾아서 읽어옵니다.
GOOGLE_APPLICATION_CREDENTIALS="{PATH}/service-account-key.json"
그렇지만,, Vercel에 배포하기 위해서는 해당 파일이 Github Repo에 업로드 되어야 한다는 함정(!) 그러면 안 되므로,, 번거롭게 환경 변수에 하나하나 적어줍시다.
env.local
# Firebase Config NEXT_PUBLIC_FIREBASE_API_KEY= NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= NEXT_PUBLIC_FIREBASE_PROJECT_ID= NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= NEXT_PUBLIC_FIREBASE_APP_ID= # Firebase Cloud Messaging NEXT_PUBLIC_FIREBASE_VAPIDKEY= # Firebase Admin Config FIREBASE_ADMIN_PROJECT_ID= FIREBASE_ADMIN_PRIVATE_KEY= FIREBASE_ADMIN_CLIENT_EMAIL= # GOOGLE_APPLICATION_CREDENTIALS=
그리고 d.ts 파일을 작성해서 env에 대한 타입을 지정할 수 있게 해요. 이때, env.d.ts이 아니라 environment.d.ts로 작성하면 따로 tsconfig.json에 include할 필요 없이 자동으로 인식합니다. 짱!
environment.d.ts
namespace NodeJS { interface ProcessEnv { NEXT_PUBLIC_FIREBASE_API_KEY: string; NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: string; NEXT_PUBLIC_FIREBASE_PROJECT_ID: string; NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: string; NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: string; NEXT_PUBLIC_FIREBASE_APP_ID: string; NEXT_PUBLIC_FIREBASE_VAPIDKEY: string; FIREBASE_ADMIN_PROJECT_ID: string; FIREBASE_ADMIN_CLIENT_EMAIL: string; FIREBASE_ADMIN_PRIVATE_KEY: string; // GOOGLE_APPLICATION_CREDENTIALS: string; } }

그 후 아래와 같이 설정해주면 돼요.
const serviceAccount: ServiceAccount = { clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL, privateKey: process.env.FIREBASE_ADMIN_PRIVATE_KEY, projectId: process.env.FIREBASE_PROJECT_ID, }; admin.initializeApp({ credential: admin.credential.cert(serviceAccount), });
firebase-admin instance 설정 끝!
이제 FCM에 메시지를 보내봅시다. 참고로 firebase-admin 패키지는 '서버' 라이브러리 집합입니다. 그렇기 때문에 RSC에서 작성되는 것이 맞겠지요.
sendNotificationToFCM()
"use server" export const sendNotificationToFCM = async (data: NotificationData & { token: string }) => { if (admin.apps.length === 0) { admin.initializeApp({ credential: admin.credential.cert(serviceAccount), }); } const { token, ...rest } = data; const message: Message = { data: { ...rest, clickActions: JSON.stringify(rest.clickActions) }, token: token, }; try { const { failureCount, responses } = await admin.app().messaging().sendEach([message]); if (failureCount > 0) { responses.map(({ success, error }) => { if (!success) { console.error(error); } }); } } catch (error) { console.error(error); } };
코드가 조금 정리가 안 됐는데, 중요한 포인트만 살펴볼게요.
const { failureCount, responses } = await admin.app().messaging().sendEach([message]);
특정 사용자에게 Push를 보내려면 send() 메소드를 사용하면 되겠지만, 대부분의 상황에서는 여러 사용자에게 Push를 보내는 상황일 거에요.
이전까지는 다음과 같은 메소드로 보냈습니다.
이전까지 쓰인 메소드들
admin.app().messaging().sendAll admin.app().messaging().sendMulticast admin.app().messaging().sendToDevice admin.app().messaging().sendToDeviceGroup
그런데 놀랍게도,, 이 모든 메소드들은 deprecated 되었습니다.

대신, sendEach() 메소드와 sendEachForMulticast() 메소드가 이를 대신하게 되었습니다. 이에 대한 보다 자세한 정보는 공식문서를 참고해주세요. 절대 귀찮아서 이러는 거 아님.
sendEach()의 인자에는 Message 타입의 객체가 배열로 들어가게 되는데요, 각 Message에는 데이터와 token이 필요합니다. 이때의 token은 바로 getToken()으로 얻은 토큰입니다.
그러므로, 여러 사용자들에게 푸시를 보내기 위해선 각각의 Token을 알아야 할 필요가 있습니다. 그렇기에 서버의 DB에는 각 사용자의 Token을 저장해야 합니다.
이 토큰은 디바이스 별, 정확히는 서비스 워커 별로 부여되기 때문에 해당 유저 당 하나의 토큰이 아니라 여러 토큰이 저장이 될 수 있습니다. 물론 이런 부분들은 서비스 정책에 의해서 정의되는 편입니다.
const message: Message = { data: { ...rest, clickActions: JSON.stringify(rest.clickActions) }, token: token, };
앞서 언급했던 내용이 있는데요, 알림에는 Action이라는 버튼을 생성하는 것이 가능합니다. 실제로 NotificationOption['data'] 타입에 actions는 string[]타입으로 지정되어 있습니다.
하지만, FCM로 보낼 때는 객체를 보낼 수 없고 오로지 string만 가능합니다. 이를 위해 보낼 때는 JSON으로 stringify해서 보내고, 받는 쪽에서는 parse를 통해 받아야 합니다. 조금 번거롭지만, 구조 상 어쩔 수 없지요!
그래서 저는 받을 때는 다음과 같은 helper function을 만들어서 사용하고 있어요.
createNotificationItem()
const createNotificationItem = (payload) => { const { data: { title, body, image, clickAction }, } = payload; const clickActionArray = clickAction ? JSON.parse(clickAction) : []; const options = { body, image, actions: clickActionArray.map(createActionItem), // icon: '/firebase-logo.png', // 루트 경로 기준으로 접근 }; return { title, options }; }; const createActionItem = (clickAction) => ({ action: clickAction, title: clickAction, icon: 'https://velog.velcdn.com/images/sangpok/profile/617ed7e6-c276-402f-b01b-444aae69e053/image.png', });
이때의 payload는 onMessage, 또는 onBackgroundMessage로 들어온 payload 그대로입니다.
createActionItem()의 icon은, 액션 버튼에도 아이콘을 지정할 수 있습니다..만, 아직 실험적인 기능이라 그런지 지원하는 곳은 많이 없는 것 같아요. action의 icon은 다음과 같은 느낌입니다.

const { failureCount, responses } = await admin.app().messaging().sendEach([message]); if (failureCount > 0) { responses.map(({ success, error }) => { if (!success) { console.error(error); } }); }
sendEach() 메소드에서 발생하는 에러, 정확하게는 전송에 실패한 메시지는 safey하게 처리됩니다. 오류를 던지는 대신 성공과 실패 갯수, 그리고 리스폰스를 반환하게 됩니다. 실패한 전송이 있다면 따로 콘솔로 에러를 보여주는 식으로 처리했어요.
만약 try-catch 문에 잡히게 된다면 다른 오류이므로 적절하게 처리해주어야 합니다.
push 이벤트는 활성화된 서비스 워커에서 받을 수 있습니다. 이 말이 굉장히 중요한데, 다시 말하자면 사용자가 탭을 활성화 중인 'Foreground' 상태여도, 탭을 보고 있지 않거나 종료한 'Background' 상태여도 전부 서비스 워커가 받는다는 말이죠.
하지만 우리의 Firebase에서는 친절하게도 사용자가 어떤 상태인지에 따라 메시지를 따로 처리할 수 있게 핸들링을 제공하고 있습니다. Foreground 상태면 onMessage(), Background 상태면 onBackgroundMessage()으로 push 이벤트를 처리할 수 있죠.
다만 개발하다보니 좀 이상한 것이, onMessage의 경우에는 메시지가 FCM 서버에서 왔을 경우에 별다른 처리는 하지 않고 호출만 해주는데, onBackgroundMessage()의 경우에는 별다른 처리를 하지 않았음에도 자체적으로 알림을 만들어서 보여주더라요. 때문에 onMessage() 안에서 처리했던 로직처럼 알림을 자체적으로 생성하면 알림이 두 번 생성이 됩니다.

어 으응.. 안녕 반갑다
이를 해결하기 위해 여러 삽질을 해봤는데,,, 영 안 풀리길래 결국 sdk를 깠습니다. 조금 읽기 좋게 큰 흐름만 따라갈 수 있게끔 보면 다음과 같아요.
async function onPush(event, options) { // 1. 메시지 페이로드를 추출 const payload = (function getMessagePayloadInternal({ data }) { if (!data) return null; try { return data.json(); } catch (error) { return null; } })(event); // 2. 페이로드가 없으면 함수 종료 if (!payload) return; // 3. 설정에 따라 BigQuery로 로그 전송 if (options.deliveryMetricsExportedToBigQueryEnabled) { await stageLog(options, payload); } // 4. 클라이언트 리스트 가져오기 const clientList = await getClientList(); // 5. 가시성 있는 클라이언트가 있는지 확인 const hasVisibleClients = (function hasVisibleClients(clients) { return clients.some(client => client.visibilityState === 'visible' && !client.url.startsWith('chrome-extension://')); })(clientList); if (hasVisibleClients) { // 6. 가시성 있는 클라이언트에 메시지 전송 (function sendMessagePayloadInternalToWindows(clients, message) { message.isFirebaseMessaging = true; message.messageType = S.PUSH_RECEIVED; for (const client of clients) { client.postMessage(message); } })(clientList, payload); return; } // 7. 알림이 있는 경우 알림 표시 if (payload.notification) { await (function showNotification(notification) { // 최대 액션 개수 제한 경고 const { actions } = notification; const { maxActions } = Notification; if (actions && maxActions && actions.length > maxActions) { console.warn(`This browser only supports ${maxActions} actions. The remaining actions will not be displayed.`); } // 알림 표시 return self.registration.showNotification( notification.title || '', notification ); })(payload.notification); } // 8. 백그라운드 메시지 핸들러 호출 if (options && options.onBackgroundMessageHandler) { const externalPayload = (function externalizePayload(message) { const external = { from: message.from, collapseKey: message.collapse_key, messageId: message.fcmMessageId, notification: { title: message.notification.title, body: message.notification.body, image: message.notification.image, icon: message.notification.icon }, data: message.data, fcmOptions: { link: message.fcmOptions?.link || message.notification?.click_action, analyticsLabel: message.fcmOptions?.analyticsLabel } }; return external; })(payload); if (typeof options.onBackgroundMessageHandler === 'function') { await options.onBackgroundMessageHandler(externalPayload); } else { options.onBackgroundMessageHandler.next(externalPayload); } } }
일단 푸시 이벤트는 서비스 워커가 받고 있습니다. 그런 다음 보여지고 있는 클라이언트가 있는지 확인하고, 있으면 그쪽으로 postmessage를 보내고 있습니다. 이게 onMessage()가 되겠습니다.
여기에 알림이 존재한다면 띄워버리고, 그 이후 onBackgroundMessage()를 호출합니다.
열받게도, onMessage() 쪽은 자체적으로 알림을 띄우는 코드가 없더라구요.
navigator.serviceWorker.addEventListener('message', (event) => { (async function messageEventListener(event, messageData) { const data = messageData.data; // 1. Firebase Messaging 메시지인지 확인 if (!data.isFirebaseMessaging) return; // 2. 메시지 타입이 PUSH_RECEIVED이고 onMessageHandler가 설정되어 있는 경우 처리 if (event.onMessageHandler && data.messageType === S.PUSH_RECEIVED) { const payload = externalizePayload(data); if (typeof event.onMessageHandler === 'function') { event.onMessageHandler(payload); } else { event.onMessageHandler.next(payload); } } // 3. 데이터에서 콘솔 메시지인지 확인하고 로깅 const messagePayload = data.data; const isConsoleMessage = (payload) => { return typeof payload === 'object' && !!payload && 'google.c.a.c_id' in payload; }; if (isConsoleMessage(messagePayload) && messagePayload['google.c.a.e'] === '1') { await logToScion(event, data.messageType, messagePayload); } })(t, event); });
그러니 onMessage() 쪽에서는 알림을 직접 띄워도 하나만 떴던 것....
그렇지만 이상했던 게, 여타 다른 분들이 작성한 코드를 보면 그냥 push 이벤트를 구독해도 같은 이슈는 없었던 모양이에요. 그래서 흠좀무하면서 좀 더 분석했더니 다음과 같은 코드가 나왔습니다.
!(function (e) { (e[(e.DATA_MESSAGE = 1)] = 'DATA_MESSAGE'), (e[(e.DISPLAY_NOTIFICATION = 3)] = 'DISPLAY_NOTIFICATION'); })(T || (T = {}))
T에 대한 열거형을 정의하는 코드인데, 정리하면 아래와 같아요.
T
{ 1: 'DATA_MESSAGE', 3: 'DISPLAY_NOTIFICATION', DATA_MESSAGE: 1, DISPLAY_NOTIFICATION: 3 }
뭔진 몰라도 데이터 메시지로 받는 타입과, 알림을 보여줘야만 하는 타입이 있는 듯 하죠? 그리고 후자의 경우는 바로 위와 같은 상황인 것!
이걸 토대로 검색을 좀 해봤더니, 공식문서 하나를 발견했습니다.
https://firebase.google.com/docs/cloud-messaging/concept-options?hl=ko
앱이 백그라운드에서 실행 중일 때 FCM SDK가 알림 표시를 자동으로 처리하게 하려면 알림 메시지를 사용하세요. 자체 클라이언트 앱 코드로 메시지를 처리하려면 데이터 메시지를 사용하세요.
아오ㅋㅋ
이걸 왜 이제 봤냐며,, 결국, 푸시 알림을 받는 걸 테스트하기 위해, 테스트 알림을 하는 곳에서 보낸 메시지가 '알림 메시지' 였기 때문에 발생한 해프닝이었습니다.
그러면 테스트 알림을 보내는 곳에 알림 데이터로 전송된다고 써놓던가,,
그러면 이제 좀 이해가는 게 데이터 메시지로 보내면 notification은 없을 테니, 알림이 안 뜨고 바로 onBackgroundMessage()로 넘어가기에 알림은 한 번만 뜨게 될 것입니다.
브라우저에서 알림을 띄우기 위해서는 서비스 워커에 접근해야 합니다. 이를 위해 많이 떠돌아 다니는 코드가 다음과 같더라구요.
떠돌아다니는 코드
window.navigator.serviceWorker.ready.then((registeration) => { registeration.showNotification(~) } )
다만 저의 경우에는 해당 서비스 워커가 절대 레디하지 않았습니다(...) reject도 반환하지 않아서 그저 아무 반응이 없는 모습을 지켜만 봐야했습니다.
그 이유를 찾아봤을 때, 가장 유력했던 것은 파일 위치의 차이였습니다.
One subtlety with the register method is the location of the service worker file. You'll notice in this case that the service worker file is at the root of the domain. This means that the service worker's scope will be the entire origin. In other words, this service worker will receive fetch events for everything on this domain. If we register the service worker file at /example/sw.js, then the service worker would only see fetch events for pages whose URL starts with /example/ (i.e. /example/page1/, /example/page2/).
서비스 워커 파일을 어디에 두느냐가 중요한데요, 만약 도메인의 루트에 두게 되면 이 서비스 워커가 그 도메인 내의 모든 것들에 대해서 가져오기 이벤트를 들을 수 있게 됩니다. 즉, 전체 웹사이트가 서비스 워커의 영역이 되는 거죠.
반면에, 서비스 워커 파일을 /example/sw.js와 같은 특정 경로에 두게 되면, 이 서비스 워커는 /example/로 시작하는 URL들, 예를 들어 /example/page1/, /example/page2/ 등의 페이지에 대한 fetch 이벤트만 처리할 수 있어요.
그렇지만,, 같은 경로에 있음에도 불구하고 레디가 되지 않더라구요. 그래서 이런 저런 방법을 찾아보다가, 아래와 같은 방식으로 해결해주었어요.
const firebaseWorker = await window.navigator.serviceWorker.getRegistration("https://localhost:3000/firebase-cloud-messaging-push-scope") firebaseWorker.showNotification(title, options)
실행 중인 서비스 워커를 가져와서 띄워주는 방법입니다. 이때, getRegistration의 인자에 FCM의 서비스 워커 Scope를 기입해주면 해당 서비스 워커를 찾아올 수 있어요.

꺄오 ㅋㅋ

불친절한 FCM 문서.. 널 증오해.. 그러나! 알고 보면 친절한 당신,, 하우에버! 결국 SDK 까게 만든 너.. 네버쓰레스! 알고 보면 기깔나게 만들어 놓은 너.. 진짜.. 내 맘은 뭘까...?