React Native에서 FCM 푸시 알림 구현하기: 시뮬레이터와 실제 기기 모두 대응

oversleep·2025년 3월 5일
0

app-development

목록 보기
35/38
post-thumbnail

소개

모바일 앱에서 푸시 알림은 사용자 참여를 유도하고 중요한 정보를 전달하는 핵심 기능입니다. React Native 앱에서 Firebase Cloud Messaging(FCM)을 사용하여 푸시 알림을 구현할 때, 시뮬레이터와 실제 기기 모두에서 동작하는 코드를 작성하는 방법을 알아보겠습니다.

이 글에서는 특히 개발 과정에서 발생할 수 있는 문제점을 해결하고, 시뮬레이터에서의 테스트와 실제 기기에서의 동작을 모두 고려한 접근 방법을 제안합니다.

핵심 개념

FCM 토큰이란?

FCM(Firebase Cloud Messaging) 토큰은 각 기기를 고유하게 식별하는 문자열입니다. 이 토큰을 서버에 저장하면 해당 기기로 푸시 알림을 보낼 수 있습니다.

시뮬레이터와 실제 기기의 차이

  • 실제 기기: Firebase 서비스에 연결하여 실제 푸시 알림을 수신할 수 있습니다.
  • 시뮬레이터: 하드웨어 제한으로 인해 실제 푸시 알림을 수신할 수 없지만, UI와 상태는 테스트할 수 있습니다.

구현 방법

1. FCM 토큰 관리를 위한 커스텀 훅 구현

먼저 FCM 토큰 관리를 위한 커스텀 훅을 만들어 보겠습니다. 이 훅은 토큰 획득, 저장, 삭제 등의 기능을 제공합니다.

// src/utils/FCMTokenManager.js
import messaging from "@react-native-firebase/messaging";
import { useState, useEffect } from "react";
import axiosInstance from "../../api/axios-interceptor";
import { getCurrentUserId } from "../auth";
import { Platform } from "react-native";
import { getApp } from "@react-native-firebase/app";
import * as Device from "expo-device";

// FCM 토큰 관리를 위한 타입 정의
type DeviceInfo = {
  fcmToken: string;
  memberId?: number;
  deviceType: "IOS" | "ANDROID";
  status: boolean;
};

type DeviceUpdateInfo = {
  deviceId: number;
  status: boolean;
};

type FCMTokenManagerReturn = {
  fcmToken: string | null;
  saveFcmTokenToServer: (token: string) => Promise<void>;
  updateDeviceStatus: (deviceId: number, status: boolean) => Promise<void>;
  deleteFcmToken: () => Promise<void>;
};

const FCMTokenManager = (): FCMTokenManagerReturn => {
  const [fcmToken, setFcmToken] = useState<string | null>(null);
  const [deviceId, setDeviceId] = useState<number | null>(null);

  useEffect(() => {
    // Firebase 초기화 확인
    try {
      getApp(); // 최신 방식으로 앱 확인
      console.log("Firebase 이미 초기화됨");
      // 초기화된 경우에만 FCM 토큰을 가져옴
      getFcmToken();

      // 토큰 갱신 이벤트 리스너
      const unsubscribe = messaging().onTokenRefresh(async (newToken) => {
        console.log("FCM 토큰 갱신:", newToken);
        setFcmToken(newToken);
        await saveFcmTokenToServer(newToken);
      });

      return unsubscribe;
    } catch (error) {
      console.log(
        "Firebase가 초기화되지 않았습니다. 메시징 기능을 사용할 수 없습니다.",
        error
      );
    }
  }, []);

  // FCM 토큰 가져오기
  const getFcmToken = async (): Promise<string | null> => {
    try {
      if (Device.isDevice) {
        await messaging().registerDeviceForRemoteMessages();
        const token = await messaging().getToken();
        console.log("FCM 토큰:", token);
        setFcmToken(token);
        return token;
      } else {
        // 시뮬레이터용 가짜 토큰 생성
        const fakeToken = `simulator-${Math.random().toString(36).substring(2, 15)}`;
        console.log("시뮬레이터 가짜 FCM 토큰:", fakeToken);
        setFcmToken(fakeToken);
        return fakeToken;
      }
    } catch (error) {
      console.error("FCM 토큰 가져오기 실패:", error);
      return null;
    }
  };

  // 서버에 FCM 토큰 저장
  const saveFcmTokenToServer = async (token: string): Promise<void> => {
    try {
      // memberId 가져오기
      const memberId = await getMemberId();

      // 기기 타입 확인
      const deviceType = getDeviceType();

      const deviceInfo: DeviceInfo = {
        fcmToken: token,
        memberId: memberId,
        deviceType: deviceType,
        status: true,
      };

      const response = await axiosInstance.post("/device", deviceInfo);

      // 응답에서 deviceId 저장 (API가 이를 반환한다고 가정)
      if (response.data && response.data.deviceId) {
        setDeviceId(response.data.deviceId);
      }

      console.log("토큰 저장 결과:", response.data);
    } catch (error) {
      console.error("FCM 토큰 저장 실패:", error);
      // 시뮬레이터에서는 실패해도 무시할 수 있음
    }
  };

  // 디바이스 상태 업데이트
  const updateDeviceStatus = async (
    deviceId: number,
    status: boolean
  ): Promise<void> => {
    try {
      const updateInfo: DeviceUpdateInfo = {
        deviceId: deviceId,
        status: status,
      };

      const response = await axiosInstance.put("/device", updateInfo);

      console.log("디바이스 상태 업데이트 결과:", response.data);
    } catch (error) {
      console.error("디바이스 상태 업데이트 실패:", error);
    }
  };

  // FCM 토큰 삭제
  const deleteFcmToken = async (): Promise<void> => {
    if (!fcmToken) {
      console.warn("삭제할 FCM 토큰이 없습니다.");
      return;
    }

    try {
      // 서버에서 디바이스 정보 삭제
      const response = await axiosInstance.delete("/device", {
        data: {
          fcmToken: fcmToken,
        },
      });

      console.log("토큰 삭제 결과:", response.data);

      // 실제 기기에서만 토큰 삭제
      if (Device.isDevice) {
        await messaging().deleteToken();
      }
      
      setFcmToken(null);
      setDeviceId(null);
    } catch (error) {
      console.error("FCM 토큰 삭제 실패:", error);
      // 시뮬레이터에서는 실패해도 무시할 수 있음
    }
  };

  return {
    fcmToken,
    saveFcmTokenToServer,
    updateDeviceStatus,
    deleteFcmToken,
  };
};

const getMemberId = async (): Promise<number> => {
  const userId = await getCurrentUserId();
  return userId ?? 0; // null이면 0 반환 또는 다른 기본값
};

const getDeviceType = (): "IOS" | "ANDROID" => {
  return Platform.OS === "ios" ? "IOS" : "ANDROID";
};

export default FCMTokenManager;

이 코드의 핵심은 Device.isDevice를 사용하여 시뮬레이터와 실제 기기를 구분하고, 시뮬레이터에서는 가짜 토큰을 생성하는 것입니다.

2. 알림 설정 모달 구현

사용자가 앱에서 알림을 활성화/비활성화할 수 있는 UI를 구현합니다.

// src/components/modals/NotificationSettingsModal.tsx
import React, { useState, useEffect } from "react";
import {
  Modal,
  View,
  Text,
  TouchableOpacity,
  StyleSheet,
  Switch,
  Platform,
  Alert,
} from "react-native";
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import { colors } from "../../styles/colors";
import AsyncStorage from "@react-native-async-storage/async-storage";
import FCMTokenManager from "../../utils/FCMTokenManager";

type TNotificationSettingsModal = {
  isVisible: boolean;
  onClose: () => void;
};

const NotificationSettingsModal = ({
  isVisible,
  onClose,
}: TNotificationSettingsModal) => {
  const [isEnabled, setIsEnabled] = useState(false);
  
  // FCMTokenManager 훅 사용
  const { fcmToken, saveFcmTokenToServer, deleteFcmToken } = FCMTokenManager();

  useEffect(() => {
    if (isVisible) {
      checkNotificationStatus();
    }
  }, [isVisible]);

  const checkNotificationStatus = async () => {
    try {
      // 시스템 권한 확인
      const { status } = await Notifications.getPermissionsAsync();

      // AsyncStorage에서 저장된 설정 가져오기
      const savedSettings = await AsyncStorage.getItem("notificationSettings");
      const savedEnabled = savedSettings === "true";

      // FCM 토큰이 있는지 확인하고 상태 설정
      setIsEnabled(status === "granted" && savedEnabled && !!fcmToken);
    } catch (error) {
      console.error("알림 상태 확인 실패:", error);
    }
  };

  const requestNotificationPermission = async () => {
    if (Platform.OS === "android") {
      await Notifications.setNotificationChannelAsync("default", {
        name: "default",
        importance: Notifications.AndroidImportance.MAX,
        vibrationPattern: [0, 250, 250, 250],
        lightColor: "#FF231F7C",
      });
    }

    if (Device.isDevice) {
      const { status: existingStatus } = await Notifications.getPermissionsAsync();
      let finalStatus = existingStatus;

      if (existingStatus !== "granted") {
        const { status } = await Notifications.requestPermissionsAsync();
        finalStatus = status;
      }

      if (finalStatus !== "granted") {
        Alert.alert("알림 권한 필요", "알림을 받으려면 권한이 필요합니다");
        return false;
      }
      return true;
    } else {
      console.log("에뮬레이터에서는 알림 기능이 제한됩니다");
      return true; // 에뮬레이터에서는 true 반환
    }
  };

  const toggleNotifications = async () => {
    try {
      if (!isEnabled) {
        // 1. 권한 요청
        const permissionGranted = await requestNotificationPermission();
        if (!permissionGranted) return;

        // 시뮬레이터인 경우 가짜 토큰으로 처리
        if (!Device.isDevice) {
          console.log("시뮬레이터에서 알림 설정 테스트 모드 활성화");
          await AsyncStorage.setItem("notificationSettings", "true");
          setIsEnabled(true);
          return;
        }

        // 2. 토큰이 없다면 Firebase에서 토큰 획득(FCMTokenManager 내부 로직)
        if (!fcmToken) {
          console.log("FCM 토큰을 획득하는 중...");
        }

        // 3. 서버에 토큰 등록(FCMTokenManager 사용)
        if (fcmToken) {
          await saveFcmTokenToServer(fcmToken);
          await AsyncStorage.setItem("notificationSettings", "true");
          setIsEnabled(true);
        } else {
          throw new Error("FCM 토큰을 획득할 수 없습니다");
        }
      } else {
        // 알림 비활성화
        if (!Device.isDevice) {
          console.log("시뮬레이터에서 알림 설정 테스트 모드 비활성화");
          await AsyncStorage.setItem("notificationSettings", "false");
          setIsEnabled(false);
          return;
        }

        await deleteFcmToken();
        await AsyncStorage.setItem("notificationSettings", "false");
        setIsEnabled(false);
      }
    } catch (error) {
      console.error("알림 설정 변경 실패:", error);
      Alert.alert(
        "알림 설정 실패",
        "알림 설정을 변경하는데 실패했습니다. 다시 시도해주세요."
      );
    }
  };

  return (
    <Modal
      animationType="fade"
      transparent={true}
      visible={isVisible}
      onRequestClose={onClose}
    >
      <TouchableOpacity
        style={styles.modalOverlay}
        activeOpacity={1}
        onPress={onClose}
      >
        <View style={styles.modalContainer}>
          <Text style={styles.title}>알림 설정</Text>
          <View style={styles.settingItem}>
            <Text style={styles.settingText}>매치 알림</Text>
            <Switch
              trackColor={{ false: "#767577", true: colors.primary }}
              thumbColor={isEnabled ? "#ffffff" : "#f4f3f4"}
              onValueChange={toggleNotifications}
              value={isEnabled}
            />
          </View>
          <Text style={styles.description}>
            매치 확정, 변경 등 중요한 알림을 받아보세요
          </Text>
          <TouchableOpacity style={styles.closeButton} onPress={onClose}>
            <Text style={styles.closeButtonText}>닫기</Text>
          </TouchableOpacity>
        </View>
      </TouchableOpacity>
    </Modal>
  );
};

export default NotificationSettingsModal;

주요 포인트 설명

1. 시뮬레이터와 실제 기기 구분

if (Device.isDevice) {
  // 실제 기기용 코드
  await messaging().registerDeviceForRemoteMessages();
  const token = await messaging().getToken();
} else {
  // 시뮬레이터용 코드
  const fakeToken = `simulator-${Math.random().toString(36).substring(2, 15)}`;
}

Device.isDevice를 사용하여 현재 환경이 실제 기기인지 시뮬레이터인지 구분합니다. 시뮬레이터에서는 가짜 토큰을 생성하여 UI 흐름을 테스트할 수 있게 합니다.

2. 에러 처리 및 예외 상황 관리

try {
  // 서버에 토큰 등록 시도
  const response = await axiosInstance.post("/device", deviceInfo);
} catch (error) {
  console.error("FCM 토큰 저장 실패:", error);
  // 시뮬레이터에서는 실패해도 무시할 수 있음
}

시뮬레이터에서는 서버 요청이 실패하더라도 앱 흐름이 중단되지 않도록 에러 처리를 추가했습니다.

3. UI 상태 관리

// 시뮬레이터인 경우 가짜 토큰으로 처리
if (!Device.isDevice) {
  console.log("시뮬레이터에서 알림 설정 테스트 모드 활성화");
  await AsyncStorage.setItem("notificationSettings", "true");
  setIsEnabled(true);
  return;
}

시뮬레이터에서는 AsyncStorage에 설정 상태를 저장하여 UI가 정상적으로 작동하도록 했습니다.

테스트 결과

구현을 완료한 후 테스트 결과, 시뮬레이터와 실제 기기 모두에서 코드가 예상대로 동작했습니다:

시뮬레이터 테스트 로그

(NOBRIDGE) LOG  Firebase 이미 초기화됨
(NOBRIDGE) LOG  시뮬레이터 가짜 FCM 토큰: simulator-lw2f8ov9m9f
(NOBRIDGE) LOG  시뮬레이터에서 알림 설정 테스트 모드 비활성화
(NOBRIDGE) LOG  에뮬레이터에서는 알림 기능이 제한됩니다
(NOBRIDGE) LOG  시뮬레이터에서 알림 설정 테스트 모드 활성화

실제 기기 테스트 (예상 로그)

LOG  Firebase 이미 초기화됨
LOG  FCM 토큰: eApM8MFNQ0WbJVioMZV7LC:APA91bHaAbCdEfGhIj...
LOG  토큰 저장 결과: {"deviceId": 123, "message": "Device registered successfully"}

발생할 수 있는 문제와 해결책

1. 시뮬레이터에서 서버 API 호출 실패

로그에서 볼 수 있듯이 시뮬레이터에서는 가짜 토큰을 사용하여 서버 API를 호출하면 실패할 수 있습니다. 이런 경우에는 다음과 같은 옵션을 고려할 수 있습니다:

  1. 개발 서버에서 시뮬레이터 토큰도 허용하도록 설정
  2. 시뮬레이터에서는 서버 요청을 건너뛰고 UI만 업데이트

두 번째 옵션이 더 간단하므로 본 구현에서는 이 방식을 선택했습니다.

2. React Native Firebase 경고

WARN  This method is deprecated... Please use `getApp()` instead.

React Native Firebase의 API가 변경됨에 따라 경고가 발생할 수 있습니다. 최신 방식으로 코드를 업데이트하면 이러한 경고를 해결할 수 있습니다.

결론

React Native 앱에서 FCM 푸시 알림을 구현할 때는 시뮬레이터와 실제 기기 간의 차이를 고려해야 합니다. 이 글에서 제안한 방식을 사용하면 다음과 같은 이점이 있습니다:

  1. 통합된 코드 베이스: 환경에 따라 코드를 분리할 필요 없이 하나의 코드로 두 환경 모두 처리
  2. 개발 효율성 향상: 시뮬레이터에서도 UI 흐름을 테스트할 수 있어 개발 속도 향상
  3. 견고한 에러 처리: 예외 상황에 대한 처리가 포함되어 있어 앱의 안정성 향상

실제 기기에서만 테스트 가능한 푸시 알림 수신 등의 기능을 제외하고는, 이 접근 방식을 통해 대부분의 알림 관련 기능을 시뮬레이터에서도 개발하고 테스트할 수 있습니다.

참고 자료

profile
궁금한 것, 했던 것, 시행착오 그리고 기억하고 싶은 것들을 기록합니다.

0개의 댓글