푸시메시지와 FCM
1. 푸시 메시지란?
푸시 메시지는 사용자가 앱을 활발하게 사용하지 않고 있거나 화면이 꺼진 상태에서도, 서버가 특정 이벤트에 반응하여 사용자의 기기로 직접 정보를 전송(Push)하는 방식의 통신 시스템입니다. 현대 모바일 서비스에서 푸시는 단순히 정보를 전달하는 기능을 넘어, 유저를 앱으로 다시 불러들이는 역할을 할 수 있습니다.
출처: 카텍스 푸시 메시지 이미지
핵심 역할
- 실시간 정보 전달: 채팅 메시지 수신, 배달 현황 업데이트, 긴급 공지 등 사용자가 즉각 알아야 하는 정보를 지연 없이 전달합니다.
- 유저 리텐션 및 재방문 유도: 개인화된 마케팅 알림이나 타겟팅된 쿠폰 제공을 통해 이탈하려는 유저를 앱으로 복귀시킵니다.
- 백그라운드 동기화: 사용자에게 알림 UI를 보여주지 않더라도, 앱 내부의 데이터를 미리 최신화하여 앱 실행 시 최상의 경험을 제공합니다.
왜 Push 인가?
전통적인 웹 방식인 Pull(당기기)은 클라이언트가 서버에 "새로운 데이터가 있니?"라고 주기적으로 물어봐야 합니다. 이는 배터리와 네트워크 자원을 낭비합니다. 반면 Push(밀기) 방식은 서버가 필요할 때만 기기에 신호를 보내기 때문에 리소스를 효율적으로 관리하면서도 압도적인 실시간성을 보장합니다.
주요 알림 종류
- 알림(Notification): 상단 바에 텍스트와 이미지로 표시되는 일반적인 알림
- 데이터(Data): 사용자에게는 보이지 않고 앱 내부 로직에서 처리되는 데이터 전송
- 사일런트(Silent): UI 노출 없이 백그라운드 동작(위치 업데이트 등)을 수행
- 인터랙티브(Interactive): 알림창에서 즉시 답장이나 버튼 클릭 등의 상호작용 가능
2. 푸시 메시지의 동작 아키텍처
푸시 메시지는 보안과 효율성을 위해 서버에서 디바이스로 직접 전송되지 않습니다. 각 플랫폼 운영사(Google, Apple)가 제공하는 중계 서버를 거치는 아키텍처를 가집니다.
출처: Firebase 공식 아키텍처 개요 문서
- 권한 요청 및 획득: 앱 설치 후 OS 차원에서 사용자에게 알림 수신 동의를 구합니다.
- 디바이스 토큰 생성: 기기가 고유 식별자인 FCM 토큰을 중계 서버로부터 발급받습니다. 이는 메시지가 배달될 '주소'와 같습니다.
- 앱 서버로 토큰 전송 및 저장: 발급받은 토큰을 우리 서비스의 백엔드 서버 DB에 유저 정보와 매핑하여 저장합니다.
- 전송 요청: 특정 조건이 충족되면 앱 서버가 FCM 서버에 "A 토큰 주소로 이 메시지를 보내줘"라고 요청합니다.
- 중계 및 배달: FCM은 해당 토큰이 연결된 기기를 찾아 인터넷 연결 상태를 확인한 후 메시지를 최종 전달합니다.
- 수신 및 표시: 기기 내 OS가 메시지를 수신하여 상단 바에 알림을 띄우거나 앱 내부로 데이터를 전달합니다.
3. FCM의 역할
Firebase란? Firebase는 Google에서 제공하는 모바일 및 웹 애플리케이션 개발 플랫폼입니다. 그중 핵심 서비스인 FCM은 서로 다른 환경의 Android와 iOS 기기에 푸시 알림을 손쉽게 보낼 수 있도록 돕는 클라우드 메시징 브릿지입니다.
과거에는 Android용 GCM과 iOS용 APNs를 각각 따로 구현해야 했지만, 이제 FCM은 모든 플랫폼을 연결하는 통합 중계 허브 역할을 수행합니다.
- Android 최적화: Google Play 서비스를 통해 Android 기기에 가장 빠르고 안정적으로 메시지를 직접 전달합니다.
- iOS와의 연동(APNs 중계): iOS 기기의 경우, 개발자가 복잡한 APNs 프로토콜을 직접 다루지 않아도 FCM 서버가 Apple의 APNs와 통신하여 메시지를 대신 전달해 줍니다.
- 개발 생산성 향상: 단일 SDK와 통합된 인터페이스를 사용하여 플랫폼별 파편화된 API(예: APNs용 HTTP/2 통신 등)를 직접 관리해야 하는 번거로움을 완전히 제거합니다.
4. FCM을 이용한 통합 설정
실제 프로젝트 Fienmee는 다음과 같은 환경에서 푸시 시스템 설정을 진행하였습니다.
- @react-native-firebase/app:22.2.0
- @react-native-firebase/messaging:22.2.0
- react-native-device-info: 14.0.4
- firebase-admin: 13.4.0
푸시 메시지를 보내기 위해서는 먼저 Firebase 프로젝트를 설정하고, 각 플랫폼에 맞게 앱을 구성해야 합니다. 프로젝트는 Firbase Console에 접속하여 프로젝트 만들기 버튼을 통해 진행할 수 있습니다.
4.1. Android 환경 설정
(1) Firebase 프로젝트 생성 및 앱 등록
Firebase 콘솔에 접속하여 새로운 프로젝트를 생성하거나 기존 프로젝트를 선택한 뒤 프로젝트에 Android 앱을 추가합니다. 이때 앱 등록 시 앱의 패키지 이름을 정확히 입력합니다. 패키지 이름은 React Native 프로젝트의 다음 내용에서 확인할 수 있습니다. (com.yourcompany.yourapp )
android/app/src/main/AndroidManifest.xml파일의<manifest package="your.app.package.name">속성android/app/build.gradle파일의applicationIdandroid/app/java/yourapp/appname/MainActivity.kt파일의 상단 패키지 선언(package your.app.package.name)에서 확인할 수 있습니다.
(2) Gradle 설정
먼저 android/app/build.gradle 파일에 Google 서비스 플러그인을 추가하고 Firebase SDK 종속성을 설정합니다.
// ...
apply plugin: "com.google.gms.google-services"
// ...
dependencies {
implementation ("com.facebook.react:react-android")
implementation project(':react-native-config')
implementation platform('com.google.firebase:firebase-bom:32.3.1')
implementation ("com.google.firebase:firebase-messaging")
// ...
}그 다음으로, android/build.gradle 파일의 buildscript 블록 내 dependencies에 Google 서비스 플러그인 클래스패스를 추가합니다.
// ....
dependencies {
// ...
classpath("com.google.gms:google-services:4.4.2")
}
// ...마지막으로 /android/app/src/main/AndroidManifest.xml에 필요한 설정을 추가합니다.
<manifest>
<!-- 기기 재부팅 후에도 메시지를 받기 위한 권한 (선택 사항이지만 권장) -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application>
<!-- Firebase Messaging Service: 포그라운드/백그라운드 메시지 수신 처리 -->
<service
android:name="io.invertase.firebase.messaging.ReactNativeFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Firebase Headless Service: 백그라운드에서 데이터 메시지 처리 (선택 사항) -->
<service
android:name="io.invertase.firebase.messaging.ReactNativeFirebaseMessagingHeadlessService"
android:exported="false" />
</application>
</manifest>4.2. IOS 환경 설정
(1) Firebase 프로젝트 생성 및 앱 등록
Firebase 콘솔에 접속하여 iOS 앱을 추가합니다. 앱 등록 시 앱의 번들 ID를 등록합니다. 이 값은 Xcode 프로젝트의 General 탭에서 확인할 수 있습니다. 등록 완료 후 GoogleService-Info.plist 파일을 다운로드합니다.
(2) APNs 인증 키 생성 및 업로드 (APNs 키를 생성하기 위해서는 먼저 개발자 등록이 필요합니다)
- Apple 개발자 계정에 로그인합니다.
- “Certificates, Identifiers & Profiles” 섹션으로 이동합니다.
- 왼쪽 메뉴에서 “Keys” 탭을 선택하고 “+” 버튼을 클릭하여 새로운 키를 생성합니다.
- 키 생성 시 “Apple Push Notifications service (APNs)” 체크박스를 반드시 선택하고 진행합니다.
- 생성된 키의
.p8파일을 다운로드하고,key ID를 기록해 둡니다. 이 파일은 한 번만 다운로드 할 수 있습니다. - 다시 Firebase 콘솔로 돌아와 해당 프로젝트의 “프로젝트 설정” > “클라우드 메시징” 탭으로 이동합니다.
- “iOS 앱 구성” 영역에서 다운로드한
.p8APNs 인증 키를 업로드하고, 기록된 둔key ID와 Apple 개발자 계정의 팀 ID를 입력합니다.
(3) GoogleService-Info.plist 파일 추가
React Native 프로젝트의 ios 디렉토리 안에 있는 .xcodeproj 파일을 Xcode로 열고 난 후 GoogleService-Info.plist 파일을 Xcode 프로젝트 네비게이터의 ios/<AppName> 폴더로 복사합니다. 이때 “Copy items if needed”를 체크하고, “Add to targets”에 앱 타겟이 선택되어 있는지 확인합니다. “Create groups”를 선택하는 것이 일반적입니다.
(4) CocoaPods 설정 및 구성
터미널을 열고 React Native ios 디렉토리로 이동합니다. Podfile 을 열고 @react-native-firebase/messaging 에서 필요한 Pod 들을 추가합니다. 일반적으로 다음과 같이 추가합니다.
# ...
target 'AppName' do
# ...
pod 'Firebase/Messaging'
pod 'Firebase/Core'
# ...
endPodfile 을 저장한 후 터미널에서 pod install 명령어를 실행하여 Firebase 관련 Pod들을 설치합니다. pod install 후에는 반드시 .xcworkspace 파일을 통해 프로젝트를 열어야 합니다.
(5) Xcode Capabilities 활성화
Xcode에서 프로젝트를 열고, 프로젝트 네비게이터에서 가장 상위의 프로젝트를 선택한 다음, "Signing & Capabilities" 탭으로 이동합니다. 그 후 + Capability 버튼을 클릭하여 다음 두 가지 기능을 추가합니다:
Push Notifications: 푸시 알림을 받기 위한 필수 기능입니다.Background Modes: 이 기능을 추가한 후Remote notifications체크박스를 선택합니다. 앱이 백그라운드나 종료 상태일 때 푸시 알림을 받을 수 있도록 합니다.
(6) AppDelegate.mm (또는 .m )파일 설정
ios/<AppName>/AppDelegate.mm 파일을 열고, 다음과 같이 Firebase SDK를 초기화하고 푸시 알림 관련 델리게이트 메서드를 추가합니다.
#import "AppDelegate.h"
#import <Firebase.h> // 이 줄 추가
#import <UserNotifications/UserNotifications.h> // 이 줄 추가 (iOS 10+ 알림 처리용)
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
// ... 기존 코드 (React Native 초기화 등)
// Firebase 초기화
[FIRApp configure];
// 푸시 알림 권한 요청 (iOS 10 이상)
if (@available(iOS 10.0, *)) {
[UNUserNotificationCenter currentNotificationCenter].delegate = (id<UNUserNotificationCenterDelegate>) self;
UNAuthorizationOptions authOptions = UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge;
[[UNUserNotificationCenter currentNotificationCenter]
requestAuthorizationWithOptions:authOptions
completionHandler:^(BOOL granted, NSError * _Nullable error) {
// 권한 요청 결과 처리 (필요에 따라 로깅 또는 사용자에게 알림)
}];
// 앱이 시작될 때 원격 알림 등록을 시도합니다.
[application registerForRemoteNotifications];
} else {
// iOS 9 이하 버전 처리 (구식 API 사용)
UIUserNotificationType types = UIUserNotificationTypeBadge | UIUserNotificationTypeSound | UIUserNotificationTypeAlert;
UIUserNotificationSettings *settings = [UIUserNotificationSettings settingsForTypes:types categories:nil];
[application registerUserNotificationSettings:settings];
[application registerForRemoteNotifications];
}
return YES;
}
// APNs 장치 토큰 수신 시 호출되는 델리게이트 메서드
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
// APNs 토큰을 Firebase Messaging SDK에 전달합니다.
[FIRMessaging messaging].APNSToken = deviceToken;
}
// 푸시 알림 수신 시 호출 (앱이 포그라운드 상태일 때 iOS 10 이상)
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
willPresentNotification:(UNNotification *)notification
withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler API_AVAILABLE(ios(10.0)) {
// 알림 데이터를 React Native로 전달하거나, 포그라운드에서 알림을 표시할지 결정합니다.
// 예: completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionSound | UNNotificationPresentationOptionBadge);
// 이 부분을 비워두면 React Native Firebase가 자동으로 처리합니다.
}
// 푸시 알림 탭 시 호출 (앱이 백그라운드 또는 종료 상태일 때 iOS 10 이상)
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
withCompletionHandler:(void (^)(void))completionHandler API_AVAILABLE(ios(10.0)) {
// 알림 클릭 시 React Native로 이벤트 전달 등 추가 로직을 구현합니다.
// 이 부분을 비워두면 React Native Firebase가 자동으로 처리합니다.
completionHandler();
}
// ... 다른 AppDelegate 메서드
@end5. FCM 토큰 발급
Firebase와 관련된 설정이 완료되면, React Native 프로젝트에 @react-native-firebase/app과 @react-native-firebase/messaging 을 이용해 앱 설치 또는 최초 실행 시 FCM 토큰을 발급받을 수 있도록 코드를 작성합니다. 실제 Fienmee 프로젝트에 적용한 내용은 아래와 같습니다.
// apps/app/src/services/pushNotificationServvice.ts
import { AuthorizationStatus, deleteToken, getMessaging, getToken, onTokenRefresh, requestPermission } from '@react-native-firebase/messaging'
import { getUniqueId } from 'react-native-device-info'
import { Platform } from 'react-native'
import { INotificationToken, PlatformType } from '@fienmee/types/api/notification'
import notifee from '@notifee/react-native'
class PushNotificationService {
private messaging = getMessaging()
async requestPermission(): Promise<boolean> {
try {
const authStatus = await requestPermission(this.messaging) // 알림 권한 요청
return authStatus === AuthorizationStatus.AUTHORIZED || authStatus === AuthorizationStatus.PROVISIONAL
} catch (error) {
throw error
}
}
async getFcmToken(): Promise<string> {
return await getToken(this.messaging) // FCM 토큰 발급
}
async getDeviceInfo(): Promise<INotificationToken> {
const hasPermission = await this.requestPermission()
if (!hasPermission) {
throw new Error('Notification permission denied')
}
const token = await this.getFcmToken()
const deviceId = await getUniqueId()
const platform = Platform.OS === 'ios' ? PlatformType.IOS : PlatformType.ANDROID
return {
token,
deviceId,
platform,
}
}
async deleteFcmToken(): Promise<void> {
await deleteToken(this.messaging) // FCM 토큰 삭제
}
listenRefreshToken(callback: (token: string) => void) {
return onTokenRefresh(this.messaging, callback) // FCM 토큰 재발급 리스너 생성
}
setMessageHandler() { // FCM 메시지 핸들러
this.messaging.setBackgroundMessageHandler(async remoteMessage => {
console.log('Background message received: ', JSON.stringify(remoteMessage))
})
this.messaging.onMessage(async remoteMessage => {
const { title, body } = remoteMessage.notification ?? {}
await notifee.displayNotification({
title,
body,
android: {
channelId: 'default',
smallIcon: 'ic_launcher', // TODO: add logo img in android/app/src/main/res/drawable
pressAction: {
id: 'default',
launchActivity: 'default',
},
},
})
})
}
}
export default new PushNotificationService()
6. 서버 측 구현: Firebase Admin SDK 활용
푸시메시지를 서버에서 전송하도록 하기 위해서는 Firebase Admin SDK를 사용해야 하고, 여기서는 firebase-admin 패키지를 이용합니다.
Firebase Admin SDK 초기화
Firsebase Admin SDK를 초기화하기 위해서는 서비스 계정 키 파일이 필요합니다. 이 파일은 Firebase 콘솔에서 다운로드할 수 있습니다. (Firebase 콘솔 - 프로젝트 설정 - 서비스 계정 - 새 비공개 키 생성)
// apps/server/src/config/index.ts
import * as firebase from 'firebase-admin';
firebase.initializeApp({
credential: firebase.credential.cert(firebaseServiceAccount),
projectId: FIREBASE_PROJECT_ID,
});알림 생성 및 멀티캐스트 전송 로직
한 유저가 여러 대의 기기(스마트폰, 태블릿 등)를 사용할 수 있으므로, 해당 유저에게 연결된 모든 토큰으로 메시지를 보내는 멀티캐스트 방식을 채택했습니다.
// apps/server/src/models/notification.ts
export class Notification extends defaultClasses.TimeStamps {
/**
* 알림 데이터 생성 및 FCM 전송
*/
public static async createAndSendNotification(
this: ReturnModelType<typeof Notification>,
type: NotificationType,
userId: mongoose.Types.ObjectId,
title: string,
body: string,
navigate: string,
): Promise<void> {
// 1. DB에 알림 내역 기록
const notification = await this.create({
userId, type, title, body, navigate,
});
// 2. 해당 유저의 모든 기기 토큰 조회
const tokens = await NotificationTokenModel.find({ userId }).exec();
if (tokens.length === 0) return;
// 3. FCM 메시지 페이로드 정의
const message: messaging.MulticastMessage = {
notification: {
title: notification.title,
body: notification.body,
},
data: {
userId: userId.toHexString(),
type: NotificationType[notification.type],
navigate: notification.navigate,
createdAt: notification.createdAt.toISOString(),
},
tokens: tokens.map(t => t.token),
};
try {
// 4. 멀티캐스팅 전송 실행
const result = await messaging().sendEachForMulticast(message);
logger.info(`FCM Sent: ${result.successCount} success, ${result.failureCount} failure.`);
// 5. 유효하지 않은 토큰 정화 (중요)
// 전송 실패한 토큰은 사용자가 앱을 삭제했거나 토큰이 만료된 경우이므로 DB에서 삭제합니다.
if (result.failureCount > 0) {
result.responses.forEach((res, idx) => {
if (!res.success) {
NotificationTokenModel.deleteOne({ _id: tokens[idx]._id });
}
});
}
} catch (error) {
throw new FCMMulticastSendError(error);
}
}
}Comments (0)
댓글을 불러오는 중...