Notice
Recent Posts
Recent Comments
Link
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

유리의 개발새발

[RN] Firebase FCM 02 본문

React Native

[RN] Firebase FCM 02

yuri_ 2025. 8. 26. 16:46
반응형

지난 시간에는 RN 앱에 Firebase SDK를 붙여봤습니다.
이번에는 백엔드에서 FCM을 직접 호출하는 부분을 구현해 보겠습니다.
(저는 애플 개발자 계정이 없으니, Android 기준으로만 테스트합니다 🙏)
 

1. Firebase Admin 설정

  1. Firebase 콘솔 → 프로젝트 개요 → 프로젝트 설정 → 서비스 계정
  2. “새 비공개 키 생성” 버튼 클릭 → JSON 키 파일 다운로드
  3. 이 파일을 Express 프로젝트 루트에 둡니다.

이 키가 백엔드에서 Firebase Admin SDK를 초기화하는데 사용됩니다.

 
Springr과 Express 중... 음..... Express로 하겠습니다!
Node.js로 설정하고 다운ㄱㄱ
그리고 그걸 백엔드 프로젝트 루트에 둡니다!

 
다음으로 패키지 설치

npm i firebase-admin cors

 
firebase.js 파일을 만들어 Firebase Admin 초기화 코드를 작성합니다.

const admin = require("firebase-admin");
const serviceAccount = require("./practice-rn-xxxx-firebase-adminsdk.json");

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
});

module.exports = admin;

 
이제 이 admin 객체를 가져다 쓰면 admin.messaging() 같은 메서드로 FCM을 전송할 수 있습니다.
 

3. Express 서버 app.js

라우터를 등록합니다

var express = require("express");
var path = require("path");
var cookieParser = require("cookie-parser");
var logger = require("morgan");
var cors = require("cors");

var indexRouter = require("./routes/index");
var usersRouter = require("./routes/users");
var fcmRouter = require("./routes/fcm"); // 추가

var app = express();

app.use(cors()); // 클라이언트에서 호출하려면 열어두기(필요 시 도메인 제한)
app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));

app.use("/", indexRouter);
app.use("/users", usersRouter);
app.use("/fcm", fcmRouter); // 추가

module.exports = app;

4. FCM 라우터 구현

이제 핵심 로직입니다. routes/fcm.js
 

const express = require("express");
const admin = require("../firebase");
const router = express.Router();

// 데모용 토큰 저장소 (실서비스라면 DB를 써야 합니다)
const deviceTokens = new Set();

// --- 1. 토큰 등록 ---
router.post("/register-token", (req, res) => {
  const { token } = req.body || {};
  if (!token) return res.status(400).json({ error: "token required" });
  deviceTokens.add(token);
  console.log("[register-token]", token);
  res.json({ ok: true });
});

// --- 2. 단일 토큰 알림 푸시 ---
router.post("/send", async (req, res) => {
  const { token, title, body, data } = req.body || {};
  if (!token) return res.status(400).json({ error: "token required" });

  const message = {
    token,
    notification: { title: title || "Hello", body: body || "From Express" },
    data: stringifyValues(data),
    android: { priority: "high", notification: { channelId: "default" } },
    apns: { headers: { "apns-priority": "10" }, payload: { aps: { sound: "default" } } },
  };

  try {
    const id = await admin.messaging().send(message);
    res.json({ ok: true, messageId: id });
  } catch (e) {
    console.error(e);
    res.status(500).json({ ok: false, error: String(e) });
  }
});

// --- 3. 단일 토큰 데이터-only 푸시 ---
router.post("/send-data", async (req, res) => {
  const { token, data } = req.body || {};
  if (!token) return res.status(400).json({ error: "token required" });

  const message = {
    token,
    data: stringifyValues(data) ?? { title: "Data-only", body: "local notification" },
    android: { priority: "high" },
    apns: { headers: { "apns-priority": "10" }, payload: { aps: { "content-available": 1 } } },
  };

  try {
    const id = await admin.messaging().send(message);
    res.json({ ok: true, messageId: id });
  } catch (e) {
    console.error(e);
    res.status(500).json({ ok: false, error: String(e) });
  }
});

// --- 4. 토픽 구독/해제 & 발송 ---
router.post("/subscribe", async (req, res) => {
  const { token, topic } = req.body || {};
  if (!token || !topic) return res.status(400).json({ error: "token & topic required" });
  await admin.messaging().subscribeToTopic([token], topic);
  res.json({ ok: true });
});

router.post("/unsubscribe", async (req, res) => {
  const { token, topic } = req.body || {};
  if (!token || !topic) return res.status(400).json({ error: "token & topic required" });
  await admin.messaging().unsubscribeFromTopic([token], topic);
  res.json({ ok: true });
});

router.post("/send-topic", async (req, res) => {
  const { topic, title, body, data } = req.body || {};
  if (!topic) return res.status(400).json({ error: "topic required" });

  const message = {
    topic,
    notification: { title: title || "Topic title", body: body || "Topic body" },
    data: stringifyValues(data),
    android: { priority: "high", notification: { channelId: "default" } },
    apns: { headers: { "apns-priority": "10" }, payload: { aps: { sound: "default" } } },
  };

  try {
    const id = await admin.messaging().send(message);
    res.json({ ok: true, messageId: id });
  } catch (e) {
    console.error(e);
    res.status(500).json({ ok: false, error: String(e) });
  }
});

function stringifyValues(obj) {
  if (!obj) return undefined;
  const out = {};
  for (const [k, v] of Object.entries(obj)) out[k] = String(v);
  return out;
}

module.exports = router;

 

5. 엔드포인트별 기능 요약

POST /fcm/register-token 앱이 발급받은 FCM 토큰 등록 { "token": "abc123", "platform": "android" }
POST /fcm/send 단일 기기에 알림(Notification) 푸시 { "token": "abc123", "title": "안녕", "body": "테스트" }
POST /fcm/send-data 단일 기기에 Data-only 푸시 (앱에서 직접 알림 표시 필요) { "token": "abc123", "data": { "chatId": "42" } }
POST /fcm/subscribe 특정 토큰을 토픽에 구독 { "token": "abc123", "topic": "news" }
POST /fcm/unsubscribe 토큰을 토픽에서 구독 해제 { "token": "abc123", "topic": "news" }
POST /fcm/send-topic 토픽 구독자 전체에 알림 발송 { "topic": "news", "title": "속보", "body": "RN 0.81 출시!" }

저 엔드포인트의 역할은 알겠는데 어디서 쓰이는가가 와닿지가 않을 수 있죠?

1. 앱이 발급받은 FCM 토큰 등록 왜 해야됨?

  • 토큰 = 기기/앱을 식별하는 주소 같은 거예요.
  • FCM 서버는 “누구한테 보낼지”를 토큰으로 구분합니다.
  • RN 앱이 처음 실행될 때 messaging().getToken()으로 토큰을 받아오죠.
  • 그걸 서버(Express)로 등록해둬야, 백엔드에서 특정 사용자 기기로 푸시를 보낼 수 있음.
  • 실서비스에서는 이 토큰을 DB에 userId랑 매핑해서 저장해요.
    • 예: userId=123 → token=xyzabc…
    • 그래야 “userId 123번한테 알림 보내” 같은 로직이 가능하죠.

2. 단일 기기에 알림(Notification) 푸시

  • 이건 서버에서 특정 기기 하나한테 직접 푸시 쏘는 것이에요.
  • Firebase Admin SDK(admin.messaging().send())를 이용해서 Express API 호출 → Firebase → 기기로 가는 구조.
  • "어드민 페이지"라기보다, 보통은 백엔드 비즈니스 로직에서 트리거해요.
    • 예: A가 B한테 채팅을 보냄 → 서버에서 B의 토큰 찾아서 /send 호출 → B 기기에 알림.

3. 단일 기기에 Data-only 푸시

  • 이것도 마찬가지로 서버에서 쏩니다.
  • 차이는 알림(Notification) 객체가 없고 data만 담음.
  • 그래서 OS가 자동으로 알림을 띄워주지 않아요.
  • 앱이 이 데이터를 받아서 직접 Notifee 같은 라이브러리로 알림 표시해야 함.
  • 주로 “앱이 열려 있을 때만 특정 UI를 갱신” 같은 데 많이 씀.
    • 예: “새 메시지가 왔다 → 리스트를 리프레시 해라”

4.특정 토큰을 토픽에 구독

  • 이게 사실상 알림 설정 기능이라고 보면 돼요.
  • 토큰을 특정 토픽에 묶어두면, 나중에 서버가 /send-topic으로 쏠 때 그 토픽 구독자 전부가 알림을 받아요.
  • 예:
    • user1, user2, user3 → topic = “event-news” 구독
    • 서버가 “event-news”로 알림 쏘면 세 명 모두 알림 받음.
  • 흔히 “카테고리 구독”, “공지 알림 받기” 같은 기능에 활용됨.

5. 토픽 구독자 전체에 알림 발송

  • 이것도 “어드민 페이지”보다는 서버에서 로직으로 트리거하는 게 일반적이에요.
  • 예시
    • 관리자가 새로운 공지 등록 → 서버에서 /send-topic 호출 → 구독한 사람 전부 푸시
  • 다만 테스트 용도로는 Postman 같은 도구로 직접 /send-topic 호출해서 보낼 수도 있어요.
  • 토큰 등록: 앱 → 서버 (DB 저장해서 나중에 발송할 대상 식별하려고)
  • /send, /send-data: 서버(비즈니스 로직) → 특정 기기(개별 사용자 알림)
  • /subscribe: 앱이 “이 토픽 알림 받고 싶다” 구독
  • /send-topic: 서버 → 해당 토픽 구독자 전부

그러니까 “어드민 페이지에서 버튼 눌러서 푸시 보내는 기능”을 만들 수도 있지만, 대부분은 **백엔드 이벤트(채팅, 주문, 댓글, 공지)**에 따라 자동으로 이 API들을 호출하게 된다~


자 여기까지 했으면, 이제 뱃지 기능을 구현해볼겁니다.

배지(아이콘 숫자/도트)란?

  • iOS: 앱 아이콘 오른쪽 위에 숫자가 뜸. 이 숫자는 앱이 직접 설정해야 함(예: 5).
  • Android: 대부분 도트(점) 표시. 숫자는 런처(제조사/런처앱) 지원에 따라 보일 수도/안 보일 수도 있음. 우리가 숫자를 강제하긴 어려움. “알림이 쌓여 있으면” 도트가 뜨고, 알림을 지우면 사라짐.

결론: iOS는 우리가 숫자를 관리해야 하고, Android는 알림의 존재로 배지가 표현된다고 보면 됨.

권장 설계(한 줄 요약)

“읽지 않은 알림(unread) 개수”를 하나의 소스(truth)로 관리한다 →

  • iOS: 이 값을 앱 아이콘 배지 숫자로 직접 세팅
  • Android: 이 값은 내부 상태로만 들고 가되, 실제 배지는 알림이 쌓였는지로 자연스럽게 표시되게 함(알림을 지우면 도트도 사라짐)

이를 위해 RN에서는 다음 조합을 씀

  • react-native-firebase/messaging: FCM 토큰/수신
  • @notifee/react-native: 로컬 알림 표시(채널/헤드업/포그라운드 표시/iOS 배지 세팅 등)
  • Zustand + persist(AsyncStorage): unread 카운트를 영구 저장(앱 종료 후에도 유지)

동작 흐름(그림으로 이해)

  1. 서버(또는 콘솔)에서 데이터-온리 푸시 전송(= notification 없이 data만 보냄)
  2. 앱이 받음
    • 포그라운드: onMessage에서 수신 → 우리가 notifee.displayNotification()으로 알림 표시 → unread + 1 → (iOS면) 앱 아이콘 배지 숫자 동기화
    • 백그라운드/종료: setBackgroundMessageHandler에서 수신 → 똑같이 notifee.displayNotification()unread + 1 → (iOS면) 배지 동기화
  3. 유저가 “알림함/인박스 화면”에 들어감 → 알림 패널 비우기 + unread = 0 → (iOS) 배지 0, (Android) 도트 사라짐
  4. 앱 재시작해도 unread가 복원되고, (iOS) 배지 맞춰짐

왜 “데이터-온리”?
시스템이 자동으로 띄우는 notification 메시지와 우리가 띄우는 로컬 알림이 중복될 수 있어서. “데이터-온리 → 항상 우리가 직접 표시”가 가장 깔끔함.

 

구현 체크리스트

0) 패키지

  • 이미 FCM/Notifee 설정됨 가정
yarn add zustand @react-native-async-storage/async-storage
cd ios && pod install

Android 권한(이미 했을 수 있음)

// AndroidManifest

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
 

iOS(나중에 테스트 시)

  • Xcode에서 Push Capabilities, Background Modes(“Remote notifications”) 활성화
  • iOS는 우리가 배지 숫자 세팅 가능

1) “unread/배지” 상태 저장소(전역) 만들기

왜 필요한가?

  • 앱이 죽었다 살아나도 iOS 배지는 “숫자”가 남아야 함 → 영구 저장 필요
  • 포그라운드/백그라운드 어디서든 같은 로직으로 +1 / 0 초기화 해야 함 → React 훅 바깥에서도 호출 가능한 모듈 필요
// src/stores/notificationStore.ts

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import notifee from '@notifee/react-native';
import { Platform } from 'react-native';

type NotificationState = {
  unread: number;
  incUnread: (delta?: number) => Promise<void>;
  setUnread: (n: number) => Promise<void>;
  clearUnread: () => Promise<void>;
  syncBadgeWithOS: () => Promise<void>;
};

export const useNotificationStore = create<NotificationState>()(
  persist(
    (set, get) => ({
      unread: 0,
      incUnread: async (delta = 1) => {
        const next = Math.max(0, get().unread + delta);
        set({ unread: next });
        await get().syncBadgeWithOS();
      },
      setUnread: async (n) => {
        set({ unread: Math.max(0, n) });
        await get().syncBadgeWithOS();
      },
      clearUnread: async () => {
        set({ unread: 0 });
        await get().syncBadgeWithOS();
      },
      syncBadgeWithOS: async () => {
        if (Platform.OS === 'ios') {
          // iOS는 아이콘 배지 숫자를 우리가 직접 세팅
          await notifee.setBadgeCount(get().unread);
        }
        // Android는 도트/숫자 표시는 런처가 관리: 알림 지우면 자연스럽게 사라짐
      },
    }),
    { name: 'notification-store', getStorage: () => AsyncStorage }
  )
);

여기서 포인트는 스토리지와 상태관리라이브러리 조합

  • persist로 AsyncStorage에 저장 → 앱 재시작 후에도 유지
  • 이 모듈은 React 컴포넌트 밖에서도 호출 가능(백그라운드 핸들러에서 사용)

2) 백그라운드 수신 핸들러에 “+1” 추가

index.js에 이미 FCM 백그라운드 핸들러를 구현했죠. 거기에 unread +1만 추가하면 됨.

import { AppRegistry, Platform } from 'react-native';
import App from './App';
import { name as appName } from './app.json';

import { getApp } from '@react-native-firebase/app';
import { getMessaging, setBackgroundMessageHandler } from '@react-native-firebase/messaging';
import notifee, { AndroidImportance } from '@notifee/react-native';
// ++
import { useNotificationStore } from './src/stores/notificationStore';

const app = getApp();
const messaging = getMessaging(app);

let channelReady = false;
async function ensureChannel() {
  if (channelReady || Platform.OS !== 'android') return 'default';
  await notifee.createChannel({
    id: 'default',
    name: 'Default',
    importance: AndroidImportance.HIGH,
  });
  channelReady = true;
  return 'default';
}

setBackgroundMessageHandler(messaging, async remoteMessage => {
  // 콘솔 "알림"으로 보낸 건 시스템이 이미 띄울 수 있으니, 중복 방지 위해 스킵
  if (remoteMessage.notification) return;

  const channelId = await ensureChannel();
  const title = remoteMessage.data?.title ?? 'FCM (bg)';
  const body = remoteMessage.data?.body ?? JSON.stringify(remoteMessage.data ?? {});

  await notifee.displayNotification({
    title,
    body,
    ios: { foregroundPresentationOptions: { alert: true, sound: true, badge: true } },
    android: { channelId, pressAction: { id: 'default' } },
  });

	// ++
	// ★ 백그라운드/종료 수신 시에도 unread +1 (iOS 배지도 동기화됨)
	await useNotificationStore.getState().incUnread(1);
});

AppRegistry.registerComponent(appName, () => App);

 

3) 포그라운드 수신 시에도 “+1” & 앱 시작 시 배지 동기화

App.tsx(현재 코드)에 두 가지만 추가

  1. 앱 시작 시, 저장됐던 unread로 iOS 배지 숫자 동기화
  2. onMessage에서 알림 띄운 후 unread +1
import React, { useEffect } from 'react';
...
import notifee, { AndroidImportance } from '@notifee/react-native';
// ++
import { useNotificationStore } from './src/stores/notificationStore';
import { AppState } from 'react-native';

export default function App() {
  const isDarkMode = useColorScheme() === 'dark';

  useEffect(() => {
    (async () => {
      await notifee.requestPermission();
	   // ++
       // ★ 앱 시작 시, 저장된 unread로 iOS 배지 숫자 맞추기
       await useNotificationStore.getState().syncBadgeWithOS();

      let channelId = 'default';
      if (Platform.OS === 'android') {
        channelId = await notifee.createChannel({
          id: 'default',
          name: 'Default',
          importance: AndroidImportance.HIGH,
        });
      }

      const app = getApp();
      const messaging = getMessaging(app);

      const status = await requestPermission(messaging);
      const enabled =
        status === AuthorizationStatus.AUTHORIZED ||
        status === AuthorizationStatus.PROVISIONAL;

      if (enabled && Platform.OS === 'ios') {
        try { await registerDeviceForRemoteMessages(messaging); } catch {}
      }

      const token = await getToken(messaging);
      console.log('[FCM] token:', token);

      const unsubToken = onTokenRefresh(messaging, t => {
        console.log('[FCM] token refreshed:', t);
      });

      const unsubMsg = onMessage(messaging, async remoteMessage => {
        const title = remoteMessage.notification?.title ?? 'FCM';
        const body =
          remoteMessage.notification?.body ?? JSON.stringify(remoteMessage.data ?? {});

        await notifee.displayNotification({
          title,
          body,
          ios: {
            foregroundPresentationOptions: { alert: true, sound: true, badge: true },
          },
          android: { channelId, pressAction: { id: 'default' } },
        });
		// ++
        // ★ 포그라운드 수신 시 unread +1
        await useNotificationStore.getState().incUnread(1);
      });
		// ++
       // (선택) 앱 활성화될 때(iOS) 배지 숫자 재동기화
       const unsubAppState = AppState.addEventListener('change', async (s) => {
         if (s === 'active') {
           await useNotificationStore.getState().syncBadgeWithOS();
         }
       });

      return () => {
        unsubToken();
        unsubMsg();
        // ++
        unsubAppState.remove?.();
      };
    })();
  }, []);

 

4) “읽음 처리” (인박스 화면에서 0으로)

알림함/인박스 화면에 들어갔을 때

  • 알림 패널 비우기(Android 도트도 사라짐)
  • unread = 0(iOS 배지도 0으로)
// src/hooks/useClearNotificationsOnFocus.ts

import { useEffect } from 'react';
import notifee from '@notifee/react-native';
import { useNotificationStore } from '../stores/notificationStore';

export function useClearNotificationsOnFocus(navigation: any) {
  useEffect(() => {
    const unsub = navigation.addListener('focus', async () => {
      await notifee.cancelAllNotifications();          // 패널 비우기
      await useNotificationStore.getState().clearUnread(); // iOS 배지 0
    });
    return unsub;
  }, [navigation]);
}

 
어떻게 쓰냐? ex)

// InboxScreen.tsx
import { useClearNotificationsOnFocus } from '../hooks/useClearNotificationsOnFocus';

export default function InboxScreen({ navigation }) {
  useClearNotificationsOnFocus(navigation);
  return (/* ...알림 리스트... */);
}

 

5) 서버(또는 테스트) 발송 규칙

  • 권장: 데이터-온리로 보내라.
    • notification 필드 없이 data만 보내고, 앱에서 항상 notifee로 직접 표시한다.
    • 이렇게 해야 중복 표시 문제 없이, 우리 로직(unread +1)도 항상 타게 됨.
  • 만약 Firebase 콘솔에서 “알림 메시지”(notification 포함)로 보내면:
    • 백그라운드/종료 시 시스템이 이미 띄움 → 우리는 remoteMessage.notification 있으면 리턴(중복 방지)
    • 이 경우엔 unread +1이 안 늘 수 있으니, 실서비스에선 콘솔 알림 대신 서버에서 data-only로 가는 걸 추천
// sendDataOnlyToToken(token, {title, body})
await admin.messaging().send({
  token,
  data: { title, body }, // notification 없이 data만
});

 

마무리 요약

  • iOS: 아이콘 배지 숫자는 우리가 직접 세팅 → unread 상태를 소스로 삼아서 notifee.setBadgeCount()로 동기화
  • Android: 배지 도트알림이 존재하면 뜸 → 우리가 알림을 표시/지우는 흐름만 잘 설계
  • 공통: 데이터-온리 수신 → notifee로 표시 → unread +1 / “읽음 화면 진입 → 패널 비우고 unread 0”
  • 상태는 Zustand + persist로 영구 저장 → 앱 재시작/백그라운드에서도 일관

밥먹고 와서 직접 실습해보겠습니다.

반응형

'React Native' 카테고리의 다른 글

[RN] 채팅 기능 (socket.io)  (0) 2025.09.07
[RN] Native Module (legacy)  (0) 2025.09.04
[RN] Firebase FCM  (7) 2025.08.26
[RN] react-native-seoul/kakao-login issue  (0) 2025.02.15
[RN] apple login  (1) 2025.02.15