PWA + Android 앱에 푸시 알림 구현하기 (Firebase FCM)

dobby·2026년 7월 10일
post-thumbnail

들어가며

사용자에게 새로운 활동을 알려주는 푸시 알림 기능을 추가하자는 피드백이 들어왔다.

기록 서비스, 특히 공동 기록 서비스 특성상 다른 사용자가 기록을 수정하거나 추가하는 등의 활동에 대해서는 알림으로 알리는게 필요하다고 생각했다.

이번 글은 Next.js app router 기반 PWA와 Capacitor로 패키징한 Android 앱에 Firebase FCM을 활용해 푸시 알림을 구현한 과정에 대해 정리하고자 한다.

구현 목표는 다음과 같았다.

  • 그룹 활동(멤버 합류, 기록 작성 등)발생 시 관련 사용자에게 알림 발송
  • 알림 클릭 시 해당 기록 또는 그룹 페이지로 딥링크
  • 백그라운드/포그라운드 모두에서 알림 수신

푸시 알림 지원 환경과 이유

지원 플랫폼

간단하게, 최종적으로 어떤 플랫폼에 대해 알림 기능을 지원하도록 했는지 표로 정리해봤다.

플랫폼지원 여부비고
Android Chrome / PWAWeb Push API
Android 네이티브 앱(Capacitor)WebView는 미지원이나 네이티브 플러그인으로 우회
macOS SafariWeb Push API (Apple이 표준 지원)
iOS Safari/홈 화면 PWA기술적으로는 가능하나, APNs 키 없음
iOS Capacitor 앱(WKWebView)APNs 키 없음

플랫폼별 지원 여부 상세

Android: 브라우저, 네이티브 앱

MDN 호환성 표를 보면 WebView Android는 Push API를 지원하지 않는다.
Capacitor Android 앱은 내부적으로 WebView를 사용하므로 Web Push API로는 푸시를 받을 수 없다.

하지만 Capacitor는 @capacitor/push-notifications 플러그인을 통해 네이티브 레이어에서 직접 FCM을 처리하므로 WebView의 제약을 우회할 수 있다.
Chrome Android는 Web Push API를 정상 지원한다.

iOS를 지원하지 않는 이유

iOS 16.4부터 Safari 브라우저와 홈 화면에 설치된 PWA에서 Web Push를 지원한다.
하지만 iOS에서 푸시 알림을 보내려면 Apple의 APNs(Apple Push Notification service)를 거쳐야 한다.
APNs 인증 키 발급을 위해서는 연 $99의 Apple Developer Program 가입이 필요하다.

우리 팀은 아직 서비스가 완전하게 출시되지 않았기도 하고, 비용 부담의 이유로 apple은 지원하지 않는 것으로 됐다.

반면 macOS에서는 Web Push API를 통해 Apple ID만으로도 웹 푸시가 가능하다.
Apple이 macOS용 웹 푸시를 W3C 표준으로 개발했기 때문이다.

참고


Firebase FCM 선택 이유

웹과 Android 모두를 단일 서버에서 처리하기 위해 FCM을 선택했다.
직접 Web Push(VAPID)를 구현하는 방법도 있겠지만, 이를 직접 검증해야 하며 개인이 검증하는 것이기에 안전하지 않다고 판단했다.

FCM을 사용하면

  • 웹(VAPID)와 Android(FCM) 토큰을 단일 Admin SDK API로 발급 가능
  • 만료 토큰 감지, 재시도 등 안정적인 전송 인프라 제공
  • Firebase Console에서 발송 로그 모니터링 가능

위처럼 개인이 직접 구현하는 것보다는 이미 잘 만들어져 관리되고 있는 서비스를 사용하는게 더 낫다고 생각했다.

중개 서버를 거치지 않고 자체적으로 구현한다면?

FCM이나 APNs 같은 중개 서버를 거치지 않고 100% 자체 인프라로 실시간 알림을 직접 구현하려면, 클라이언트와 자체 서버 간에 지속적인 연결을 유지하는 기술을 사용해야 한다.

대표적으로는 3가지 정도가 있다.

1. WebSocket

가장 보편적인 양방향 실시간 통신 방식이다.

  • 클라이언트와 서버가 한 번 연결되면, 계속 연결을 유지하며 데이터를 주고받는다.
  • 실시간성이 매우 뛰어나고 데이터 전송 오버헤드가 적다.

2. SSE (Server-Sent Event)

서버가 클라이언트에게 단방향으로 데이터를 밀어주는 방식이다.

  • HTTP 프로토콜을 그대로 사용하며, 연결을 끊지 않고 서버가 이벤트를 스트리밍한다.
  • 웹소켓보다 구현이 간단하고, 연결이 끊겼을 때 자동 재연결 기능이 내장되어 있다.
  • 보통 뉴스 피드 업데이트, 알림 피드, 실시간 점수 판 등에 활용된다.

3. MQTT

모바일 및 사물인터넷 환경에 최적화된 가벼운 메시징 프로토콜이다.

  • 발행과 구독 패턴으로 동작하며, 가벼운 브로커를 두고 통신한다.
  • 네트워크 전력 소비가 매우 적어서 모바일 배터리 절약에 유리하다.
  • IoT 기기 제어, 모바일 메신저 백그라운드 통신 등에 활용된다.

포그라운드 상태에서는 위 방식으로 완벽하게 작동한다.
하지만 모바일 앱이 꺼져 있거나 백그라운드 상태에서는 OS 제약이 발생하게 된다.


  • iOS(Apple) 제한: iOS는 앱이 백그라운드로 가면 전력 절약을 위해 네트워크 연결을 강제로 귾어버린다. 따라서 FCM을 쓰지 않더라도, 결국 Apple이 공식 제공하는 APNs 서버로는 무조건 메시지를 보내야만 아이폰 화면에 push 알림을 띄울 수 있다.
  • Android 제한: 안드로이드 역시 백그라운드 배터리 최적화로 인해 백그라운드 소켓 연결을 유지하기 어렵다. 백그라운드에서 상시 연결을 유지하려면 '포그라운드 서비스'를 띄워 상단 바에 고정 알림을 상시 노출해야 하므로 사용자 경험에 좋지 않다.

그렇기에 앱이 켜져 있을 때는 WebSockets/SSE로 직접 빠르게 알림을 처리하고, 앱이 꺼져 있을 때는 FCM/APNs를 백업으로 호출하는 하이브리드 방식을 채택하기도 한다.

하지만 두 방식을 모두 지원하기엔 소규모 팀에선 리소스 소모가 크다고 생각했다.
그래서 FCM만을 사용해 push 알림을 구현하고자 했다.

참고


전체 아키텍처

푸시 알람의 흐름은 다음과 같다.

앱 실행
-> 알림 권한 요청
-> FCM 토큰 발급
-> 백엔드에 식별자 저장

그룹 활동 발생(POST_CREATE, MEMBER_JOIN 등)
-> 그룹 활동에 대한 service 메소드 호출 (GroupActivityService.recordActivity())
-> 신규: 그룹 멤버들의 FCM 식별자 조회
-> Firebase Admin SDK로 푸시 발송
-> 기기에 알림 표시

웹과 안드로이드의 식별자가 다른 이유
Firebase Android SDK에서 getToken(), deleteToken(), onNewToken()이 deprecated되고 웹과 동일하게 onRegistered() 기반 FID 방식으로 전환됐다. 단 AndroidManifest.xml에 opt-in 플래그를 추가해야 활성화된다.
하지만 Capacitor 공식 문서는 현재도 registration 이벤트가 Android에서 FCM Token을 반환한다고 명시한다.
Capacitor 플러그인이 내부적으로 구 API를 래핑하고 있고, 위 opt-in 플래그도 설정하지 않기 때문에 아직 FID가 아닌 토큰을 돌려준다.
Capacitor 공식문서

Firebase Admin SDK?

서버 사이드 전용 SDK이다.
클라이언트가 아닌 신뢰할 수 있는 서버 환경에서 Firebase 서비스를 제어할 목적으로 사용한다.
우리 팀은 NestJS 백엔드에서 FCM 메시지를 발송하는 데 사용하고 있다.

// notification.service.ts
import { getMessaging } from 'firebase-admin/messaging';

await getMessaging(this.app).sendEach(messages); // ← 여기

클라이언트 SDK vs Admin SDK 차이

.클라이언트 SDK(firebase)Admin SDK(firebase-admin)
실행 환경브라우저 / 앱서버 (node.js 등)
인증 방식사용자 로그인 (google, 이메일 등)서비스 계정 키 (JSON 파일)
권한로그인한 사용자 권한 범위관리자 권한 (모든 데이터 접근)
용도사용자가 직접 조작서버가 대신 처리

Admin SDK가 주로 쓰이는 곳은 다음과 같다.

  • FCM 발송: 특정 사용자에게 푸시 알림 보내기
  • Firestore/Realtime DB 관리: 보안 규칙을 우회해서 서버에서 직접 데이터 읽기/쓰기
  • Auth 관리: 사용자 계성 생성/삭제, 커스텀 토큰 발급
  • Storage 관리: 파일 업로드/삭제

사용자 대신 서버가 Firebase를 직접 제어해야 할 때 쓰며, 클라이언트에서 Admin SDK를 쓰면 서비스 계정 키가 노출되기 때문에 절대 사용하면 안된다.


Firebase는 Topic 구독으로 Topic별로 알림을 수신할 수 있도록 하는 기능도 제공한다.
검색하면 나오는 관련 글을 참고하면 좋을 것 같다.

우리 팀도 처음에는 Topic 구독으로 단위를 나눠 알림을 제공할 수 있었지만, 그룹 별로 알림 수신 여부를 다르게 설정할 수 있는 기능을 추가해서 Topic 단위로는 부족했다.
그래서 그룹 내에서도 '특정 멤버'에게만 보내기 편한 FID 직접 관리 방식으로 구현했다.


환경변수 발급받기

필요한 환경변수는 다음과 같다.

위치필요한 값
백엔드Firebase Amdin SDK 서비스 계정 JSON
프론트Firebase 웹앱 config(apiKey, projectId 등) + VAPID 키

위와 값은 모두 Firebase 콘솔에서 발급할 수 있다.
혹시나 나 같이 이런 타 서비스 사용에 있어서 어려움을 겪는 사람을 위해 발급 과정을 하나하나 정리해봤다.

먼저, 설정의 일반 페이지로 넘어간다.
내리면 '내 앱' 세션에서 앱을 등록해주는데, 나는 </> 표시의 웹으로 등록을 먼저 해주었다.
(웹으로 동작하기 때문에)

sdk 설정은 npm으로 해주었다.
<script> 태그 방식은 CDN에서 직접 로드하는 방식인데, Next.js + pnpm 프로젝트에서는 번들러가 tree-shaking으로 사용하는 기능만 포함시켜 주니 npm으로 충분하다.

그리고 config 코드를 복사해서 코드에 붙여넣어준다.

// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries

// Your web app's Firebase configuration
const firebaseConfig = {
  apiKey: "AI...",
  authDomain: "fr...",
  projectId: "fr...",
  storageBucket: "fr...",
  messagingSenderId: "51...",
  appId: "1:51..."
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);

이제 VAPID를 발급하자

알림 페이지로 접근하면 아래 사진처럼 클라우드 메시징 탭이 있다.

여기서 아래로 스크롤하면 '웹 구성' 섹션이 보인다.

여기서 Generate key pair 버튼을 클릭해 VAPID(공개 키)를 발급받는다.

BHc7cP...

이제 마지막으로 백엔드 설정을 위한 Firebase Admin SDK 서비스 계정 JSON을 발급받아야 한다.

같은 페이지에서 '서비스 계정' 탭으로 이동한다.
여기서 백엔드 환경에 맞는 탭이 선택되어 있는지 확인한다.

우리 팀은 node를 사용하고 있어서, Node.js를 선택해줬다.

선택한 뒤, '새 비공개 키 생성' 버튼을 클릭한다.
팝업창이 뜨면 '키 생성'을 누른다.

'키 생성' 버튼을 클릭하면 자동으로 브라우저를 통해 .json 파일이 다운로드된다.
이 파일은 git 등의 공유 저장소에 업로드되지 않도록 안전하게 보관해야 하는 서비스 계정 키 파일이다.

서비스 계정 JSON 파일은 프로젝트의 모든 권한을 가진 마스터 키와 같기 때문에, 절대 GitHub 같은 공용 저장소에 업로드되지 않도록 gitignore 처리를 해줘야 한다.

gitignore에 json 파일을 추가해주고,

rontend/.envNEXT_PUBLIC_FIREBASE_* 7개의 값을 추가해줬다.
backend/.envFIREBASE_SERVICE_ACCOUNT_PATH 를 추가해줬다.

이제 capacitor android를 위한 앱을 추가한다.
기존의 웹 앱을 추가한 것처럼 진행하면 된다.
이번에는 android 아이콘을 선택하면 된다.

Firebase 콘솔 -> 프로젝트 설정 -> 앱 추가 -> Android


작업 단계

처리해야 하는 작업들은 단계별로 정리했을 때 다음과 같다.

1. 백엔드: FCM 식별자 저장 테이블 및 API 추가

fcm_token 엔티티를 생성한다. (userId, token, platform: 'web'|'android' )

POST /api/fcm-tokens 엔드포인트로 토큰을 등록하고 갱신시킨다.

2. 백엔드: 푸시 발송 서비스 생성

firebase-admin 설치 후 PushNitficationService 를 생성한다.

pnpm --filter backend add firebase-admin

recordActivity() 끝에서 엑터를 제외한 그룹 멤버들에게 알림을 발송한다.

3. 프론트: PWA/Web

firebase 패키지를 설치한다.

pnpm --filter frontend add firebase

firebase-message-sw.js 서비스 워커를 추가한다. 이 서비스 워커는 백그라운드의 알림 수신용이다.

권한 요청 + FCM 토큰 발급 + 백엔드에 등록하는 훅을 작성해 관리하기 편하게 한다.

4. 프론트: Android/Capacitor

capacitor 환경은 지원하는 플러그인을 사용하는게 좋다.

@capacitor-firebase/messaging 플러그인을 설치하고, 같은 훅 안에서 플랫폼별로 분기 처리를 한다.


백엔드 구현

1. FCM 식별자 엔티티

플랫폼별로 다른 전송 방식이 필요하므로 platform 컬럼을 함께 저장한다.
사용자당 플랫폼별 하나의 식별자만 유지한다.

@Entity('fcm_tokens')
@Unique(['userId', 'platform'])
export class FcmToken {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  userId: string;

  @Column()
  token: string; // 웹: FID(Firebase Installation ID), Android: FCM 등록 토큰

  @Column()
  platform: 'web' | 'android';
}

2. FCM 발송 서비스

플랫폼별로 메시지 형식이 달라야 한다.

  • 웹: FidMessage - fid 필드에 FID(Firebase Installation ID)를 사용, data-only 메시지
  • Android: TokenMessage - token 필드에 FCM 등록 토큰을 사용, notification 필드 포함
async sendToUsers(
  userIds: string[],
  title: string,
  body: string,
  data?: Record<string, string>,
): Promise<void> {
  const tokens = await this.fcmTokenRepo.find({
    where: userIds.map((userId) => ({ userId })),
    select: ['token', 'platform'],
  });

  if (tokens.length === 0) return;

  // 만료 식별자 추적을 위해 순서 보존
  const identifiers = tokens.map(({ token }) => token);

  const messages = tokens.map(({ token, platform }) =>
    platform === 'web'
      ? {
          fid: token, // FidMessage — Admin SDK에서 웹 FID 전용 필드
          data: { title, body, ...(data ?? {}) },
          webpush: { headers: { Urgency: 'high' } },
        }
      : {
          token, // TokenMessage — Android FCM 등록 토큰
          notification: { title, body },
          ...(data ? { data } : {}),
        },
  );

  const response = await getMessaging(this.app).sendEach(messages);

  // 만료 식별자 정리
  const staleIdentifiers = response.responses
    .map((r, i) => ({ ...r, identifier: identifiers[i] }))
    .filter(
      (r) =>
        !r.success &&
        (r.error?.code === 'messaging/registration-token-not-registered' ||
          r.error?.code === 'messaging/invalid-argument'),
    )
    .map((r) => r.identifier);

  if (staleIdentifiers.length > 0) {
    await this.fcmTokenRepo
      .createQueryBuilder()
      .delete()
      .where('token IN (:...tokens)', { tokens: staleIdentifiers })
      .execute();
  }
}

왜 웹은 data-only?
Firebase compat SDK는 notification 필드가 있는 메시지를 수신하면 자동으로 알림을 표시하면서 동시에 onBackgroundMessage도 호출한다. 이 경우:
1. 알림이 두 번 표시된다
2. Firebase가 자동 표시한 알림의 evnet.notification.data는 FCM이 래핑한 구조라, postId, groupId 등 커스텀 데이터를 바로 꺼내기 어렵다.

data-only로 보내면 Firebase의 자동 표시 없이 onBackgroundMessage에서만 알림을 제어할 수 있다.

참고로, Firebase JS SDK v12부터 웹 푸시 등록 방식이 getToken -> register + onRegistered 방식으로 변경되었다.
새 API에서 반환하는 식별자는 FCM 등록 토큰이 아니라 FID이다. Firebase Admin SDK에서 이에 대응하는 FidMessage 타입(fid 필드)가 추가됐고, 기존 TokenMessage는 웹에 대해서는 deprecated 처리됐다.

참고


프론트엔드 구현

1. Service Worker 설정 (firebase-messaging-sw.js)

self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) =>
  event.waitUntil(self.clients.claim()),
);

// ⚠️ 반드시 firebase.messaging() 호출 전에 등록해야 합니다
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const { groupId, postId } = event.notification.data ?? {};
  const url =
    postId && groupId
      ? `/record/${postId}?scope=group&groupId=${groupId}`
      : postId
        ? `/record/${postId}`
        : groupId
          ? `/group/${groupId}`
          : '/';

  event.waitUntil(clients.openWindow(url));
});

// firebase.messaging() 이후에 등록하면 Firebase 내부 핸들러가 먼저 실행됩니다
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js');

firebase.initializeApp({ /* firebaseConfig */ });
const messaging = firebase.messaging();

messaging.onBackgroundMessage((payload) => {
  // data-only 메시지이므로 payload.data에서 읽어야 합니다
  const title = payload.data?.title ?? '알림';
  const body = payload.data?.body ?? '';
  self.registration.showNotification(title, {
    body,
    icon: '/icon-192x192.png',
    data: payload.data ?? {},  // notificationclick에서 꺼낼 데이터
  });
});

구현하면서 notificationclick 등록 순서가 중요했다.
Firebase compat SDK는 firebase.messaging() 호출 시점에 내부적으로 notificationclick 이벤트 리스너를 등록한다.
이벤트 리스너는 등록 순서대로 실행되는데, Firebase 리스너가 먼저 실행되면 user gesture context를 소비해버린다.
이후 우리 리스너의 clients.openWindow()는 user gesture 없이 호출되므로 브라우저가 창 열기를 차단하게 된다.

이 때문에 나는 첫 번째 알림 클릭은 반응이 없고, 두 번째 알림 클릭부터 정상 동작하는 현상이 발생했다.

참고

2. FCM 등록 후

Firebase JS SDK에서 getToken/deleteToken이 deprecated되고
register/unregister/onRegistered API로 전환됐다.

  • register: Promise를 반환하지 않는 콜백 기반 API
  • `onRegistered(messaging, callback): FID를 전달받는 콜백 등록, register 호출 전에 먼저 등록해야 한다.
  • unregister: 기존 FID 등록 정보 삭제, swRegistration 초기화 없이도 바로 호출 가능
import { register as fcmRegister, unregister, onRegistered } from 'firebase/messaging';
import type { Messaging } from 'firebase/messaging';

// onRegistered 콜백을 await 가능한 형태로 변환
function registerAndGetFid(
  messaging: Messaging,
  options: { vapidKey: string | undefined; serviceWorkerRegistration: ServiceWorkerRegistration },
): Promise<string | null> {
  return new Promise((resolve) => {
    // onRegistered를 먼저 등록한 뒤 register() 호출
    const unsubOnRegistered = onRegistered(messaging, (fid) => {
      unsubOnRegistered();
      resolve(fid);
    });
    fcmRegister(messaging, options).catch(() => {
      unsubOnRegistered();
      resolve(null);
    });
  });
}

async function getAndRegisterWebFcmToken() {
  const messaging = await getFirebaseMessaging();
  if (!messaging) return;

  const swReg = await navigator.serviceWorker.register(
    '/firebase-messaging-sw.js',
    { scope: '/firebase-cloud-messaging-push-scope' },
  );

  // SW가 활성화될 때까지 기다립니다 (race condition 방지)
  if (!swReg.active) {
    await new Promise<void>((resolve) => {
      const sw = swReg.installing ?? swReg.waiting;
      if (!sw) { resolve(); return; }
      const handler = () => {
        if (sw.state === 'activated') {
          sw.removeEventListener('statechange', handler);
          resolve();
        }
      };
      sw.addEventListener('statechange', handler);
      // 리스너 등록 후 상태를 다시 확인 (등록 전에 이미 activated된 경우 대비)
      if (sw.state === 'activated' || swReg.active) {
        sw.removeEventListener('statechange', handler);
        resolve();
      }
    });
  }

  // SW를 unregister하면 push subscription이 소멸합니다
  // 기존 구독이 없으면 FID 등록 정보가 무효화된 것이므로 강제 갱신합니다
  const existingSub = await swReg.pushManager.getSubscription().catch(() => null);
  if (!existingSub) {
    await unregister(messaging).catch(() => {});
  }

  const fid = await registerAndGetFid(messaging, {
    vapidKey: VAPID_KEY,
    serviceWorkerRegistration: swReg,
  });

  if (fid) {
    await registerFcmToken(fid, 'web').catch(() => {});
  }
}

참고

3. Android 네이티브 (Capacitor)

export async function registerAndroidToken() {
  const { PushNotifications } = await import('@capacitor/push-notifications');

  const result = await PushNotifications.requestPermissions();
  if (result.receive !== 'granted') return;

  await new Promise<void>((resolve) => {
    PushNotifications.addListener('registration', async ({ value: token }) => {
      await registerFcmToken(token, 'android').catch(() => {});
      await PushNotifications.removeAllListeners();
      resolve();
    });
    PushNotifications.addListener('registrationError', async () => {
      await PushNotifications.removeAllListeners();
      resolve();
    });
    PushNotifications.register();
  });
}

참고


구현 중 마주한 버그와 해결

1. 서비스워커 unregister 후 알림이 오지 않는 문제

DevTools에서 서비스 워커를 unregister하고 새로고침하면 알림 토큰이 재등록되지 않아 알림이 오지 않는 문제가 발생했다.

usePushNotification의 활성화 조건을 status === 'authenticated(nextauth)로 설정해뒀었는데, fast refresh나 서비스 워커 unregister 이후에 nextauth의 SessionProvider 상태가 리셋되어서 status가 일시적으로 unauthenticated가 된 것이다.

useAuthStore(Zustand + localStorage persist)의 userType !== null로 조건을 변경해줬다.
localStorage 기반이라 fast refresh에 영향을 받지 않는다.


2. 서비스워커 unregister 후 재등록시 FID가 갱신되지 않는 문제

DevTools에서 1번과 똑같이 unregister하면 push subscription이 소멸하는데, 재등록시 register()가 기존 FID 등록 정보를 재사용해서 push subscription을 생성하지 않았다.

Firebase는 FID 등록 정보를 내부적으로 캐싱한다.
push subscription이 없어진 상태에서도 기존 FID가 남아 있으면 register가 push subscription 재생성 없이 캐시된 FID를 그대로 반환하기에 발생한 문제이다.

push subscription이 없을 때 (pushManager.getSubscription() -> null) unregister(messaging)를 먼저 호출해 Firebase 내부의 FID 등록 정보를 초기화한다.
이렇게 하면 이후 register가 새 push subscription과 함께 FID를 재발급한다.

const existingSub = await swReg.pushManager.getSubscription().catch(() => null);
if (!existingSub) {
  // FID 등록 정보를 초기화해 register()가 새 push subscription으로 재등록하도록 강제
  await unregister(messaging).catch(() => {});
}
const fid = await registerAndGetFid(messaging, options);

결과적으로는 서비스워커 unregister 후에도 새로고침 후 재등록 된 후에는 알림이 잘 온다.

참고


3. 첫 번째 알림 클릭 시 아무 반응이 없던 문제

앱을 처음 실행하거나 서비스워커를 재등록한 직후에 첫 알림 클릭은 반응이 없고, 두 번째 알림 클릭부터 정상 동작하는 문제가 있었다.

Firebase compat SDK가 firebase.messaging() 호출 시 내부적으로 notificationclick 리스너를 등록한다.
우리 리스너가 Firebase 리스너 이후에 등록되면 Firebase가 먼저 실행되어 user gesture context를 소비하고, 이후 clients.openWindow()가 차단된다.

그래서 notificationclick 리스너를 importScripts/firebase.messaging() 이전에 등록해주었고, 잘 동작하는 것도 확인했다.


4. 첫 번째 알림 클릭 시 잘못된 페이지로 이동하는 문제

첫 알림 클릭 시 올바른 기록 페이지가 아닌 엉뚱한 페이지로 이동하는 문제가 발생했다.

백엔드에서 notification 필드를 포함한 메시지를 보내면 Firebase SW가 자동으로 알림을 표시하면서 onBackgroundMessage도 호출한다. 이로 인해서

  • 알림이 두 번 표시됨(Firebase 자동 + onBackgroundMessage에서 수동)
  • 자동 표시된 알림의 data는 Firebase가 FCM_MSG 키로 래핑한 구조 -> postId, groupId를 못 찾아 fallback URL로 이동

실제로 첫 알림이 빠르게 두 번 호출되는걸 확인했었다.

백엔드 쪽에는 웹 토큰에는 notification 필드 없이 data만 전송하도록 수정했다. (data-only 메시지)
서비스워커 쪽에서는 onBackgroundMessage에서 payload.data.title, payload.data.body를 읽도록 하고
showNotificationdata에 커스텀 필드를 전달해 notificationclick에서 바로 사용이 가능하도록 했다.


FCM 토큰 관리

기본 권장사항

토큰은 비활성 등록 토큰과 활성 토큰으로 나뉘는데, 토큰이 비활성화되는 이유는 여러 가지가 있다.
토큰이 손실되거나, 파손되거나, 스토리지로 넘어가거나, 잊혀진 경우이다.

토큰은 결국 db에 저장되고 관리되기 때문에, 비활성화된 토큰은 정리할 필요가 있다.
그렇지 않으면 리소스를 계속 잡아먹게 되기 때문에 불필요한 비용이 추가되게 된다.

Android의 경우 비활성 토큰이 270일 동안 활동이 없으면 FCM에서 만료된 것으로 간주한다.
토큰이 만료되면 FCM은 토큰을 유효하지 않은 것으로 표시하고 토큰으로의 전송을 거부한다.
iOS와 같은 다른 플랫폼의 경우 FCM은 기본 푸시 서비스(예: APNs)를 사용한다.
APNs는 270일 비활성 상태를 기준으로 한 토큰 만료가 적용되지 않기에, 토큰을 최신 상태로 유지하고 비활성 등록 토큰을 삭제하는 것이 좋다.

등록 토큰 검색 및 저장

그렇기에 앱을 처음 시작할 때 이 토큰을 검색해 타임스탬프와 함께 앱 서버에 저장하는 것이 좋다.
물론, 이 타임스탬프는 FCM SDK에서 제공하지 않기에, 서버에서 구현해야 한다.

또한 토큰이 변경될 때마다 타임스탬프를 업데이트하는 것이 중요하다.

  • 새 기기에서 앱 복원
  • 사용자가 앱 제거 또는 재설치
  • 사용자가 앱 데이터 소거
  • FCM에서 기존 토큰이 만료된 후 앱이 다시 활성화

토큰 최신 상태 유지 및 비활성 토큰 삭제

토큰이 최신 상태인지 비활성 상태인지를 판단해 토큰을 처리하는 것은 기준을 정해둬야 한다.

기본적으로 FCM은 앱 인스턴스가 한 달 동안 연결되지 않은 경우 토큰을 비활성 상태로 간주한다.
1개월이 지난 토큰은 비활성 기기일 가능성이 높으며 그 외의 활성 기기는 토큰을 갱신했을 것이기 떄문이다.

그렇기에 FCM에서 잘못된 토큰 응답을 감지하고 유효하지 않거나 만료된 등록 토큰을 시스템에서 삭제해 이에 대응하도록 관리해야 한다.

HTTP v1 API를 사용하면 다음과 같은 오류 메시지가 전송 요청이 잘못된 토큰이나 만료된 토큰을 타겟팅했음을 나타낸다.

  • UNREGISTERED(HTTP 404)
  • INVALID_ARGUMENT(HTTP 400)

INVALID_ARGUMENT는 메시지 페이로드에 문제가 있는 경우에도 반환될 수 있기에 페이로드가 완전히 유효한 경우에만 잘못된 토큰 신호를 보낸다.

메시지 페이로드가 유효하다고 확신한 상태에서, 타겟팅 된 토큰에 대해서 위와 같은 응답이 오면, 해당 토큰은 더 이상 유효한 상태가 아니므로 해당 레코드를 삭제하는 것이 안전하다.

정기적으로 토큰 업데이트

서버에서 모든 등록 토큰을 주기적으로 검색 및 업데이트하는 것이 좋다.

참고
Firebase FCM 등록 토큰 관리를 위한 권장사항  |  Firebase Cloud Messaging


웹 개발 모드에서의 테스트
mov -> gif 변환으로 인한 느림 주의

참고 자료

profile
성장통을 겪고 있습니다.

0개의 댓글