logoRawon_Log
홈블로그소개

Built with Next.js, Bun, Tailwind CSS and Shadcn/UI

Mobile

FCM (Firebase Cloud Messaging)

Rawon
2025년 8월 3일
목차
Firebase Cloud Messaging(FCM)을 활용한 Push 알림 구현 가이드 (Node.js 서버 및 Flutter 클라이언트)
1. FCM이란 무엇인가?
2. Firebase 프로젝트 설정
Firebase 프로젝트 생성
앱 등록 (Android & iOS)
설정 파일 다운로드 및 추가
3. Node.js 서버 구현
필수 패키지 설치
Firebase Admin SDK 초기화
메시지 발송 API 구현
4. Flutter 클라이언트 구현
필수 패키지 추가
Firebase 초기화 설정
FCM 서비스 클래스 생성 (lib/services/fcm_service.dart)
5. 메시지 유형별 처리 심층 분석
6. 테스트 및 디버깅
7. 결론
비유

목차

Firebase Cloud Messaging(FCM)을 활용한 Push 알림 구현 가이드 (Node.js 서버 및 Flutter 클라이언트)
1. FCM이란 무엇인가?
2. Firebase 프로젝트 설정
Firebase 프로젝트 생성
앱 등록 (Android & iOS)
설정 파일 다운로드 및 추가
3. Node.js 서버 구현
필수 패키지 설치
Firebase Admin SDK 초기화
메시지 발송 API 구현
4. Flutter 클라이언트 구현
필수 패키지 추가
Firebase 초기화 설정
FCM 서비스 클래스 생성 (lib/services/fcm_service.dart)
5. 메시지 유형별 처리 심층 분석
6. 테스트 및 디버깅
7. 결론
비유

Firebase Cloud Messaging(FCM)을 활용한 Push 알림 구현 가이드 (Node.js 서버 및 Flutter 클라이언트)

안녕하세요! Firebase Cloud Messaging(FCM)을 사용하여 Node.js 서버와 Flutter 클라이언트 앱 간에 Push 알림 기능을 구현하는 방법에 대해 자세히 알아보겠습니다.


1. FCM이란 무엇인가?

Firebase Cloud Messaging(FCM)은 Firebase에서 제공하는 교차 플랫폼 메시징 솔루션으로, 서버에서 클라이언트 앱으로 메시지를 안정적으로 전송할 수 있게 해줍니다. FCM을 사용하면 사용자에게 알림을 보내 참여를 유도하거나, 데이터 동기화를 지시하는 등의 다양한 용도로 활용할 수 있습니다.

즉, FCM은 앱이 실행 중이지 않을 때도 사용자에게 알림을 보내거나 데이터를 전달할 수 있게 해주는 서비스로,

집 주소로 편지가 오는 것처럼, FCM은 각 앱(정확히는 앱이 설치된 각 기기)에 고유한 "주소"(토큰)를 부여하고, 이 주소로 메시지를 보내는 역할을 합니다.

주요 장점:

  • 무료 사용: 대부분의 기능을 무료로 제공합니다.
  • 높은 안정성: Google 인프라를 기반으로 안정적인 메시지 전송을 보장합니다.
  • 다양한 플랫폼 지원: Android, iOS, 웹 등 다양한 플랫폼을 지원합니다.
  • 유연한 메시지 타겟팅: 특정 사용자, 사용자 그룹(주제 구독), 또는 모든 사용자에게 메시지를 보낼 수 있습니다.

2. Firebase 프로젝트 설정

FCM을 사용하기 위해서는 먼저 Firebase 프로젝트를 설정해야 합니다.

Firebase 프로젝트 생성

  1. Firebase 콘솔로 이동하여 Google 계정으로 로그인합니다.
  2. "프로젝트 추가"를 클릭하고 프로젝트 이름을 입력합니다.
  3. Google 애널리틱스 설정을 선택하고 (선택 사항) 프로젝트를 만듭니다.

앱 등록 (Android & iOS)

프로젝트가 생성되면 Android 및 iOS 앱을 Firebase 프로젝트에 등록해야 합니다.

Android 앱 등록:

  1. 프로젝트 개요 페이지에서 Android 아이콘을 클릭합니다.
  2. Android 패키지 이름: android/app/build.gradle 파일에 있는 applicationId를 입력합니다. (예: com.example.your_app)
  3. (선택 사항) 앱 닉네임과 디버그 서명 인증서 SHA-1을 입력합니다. SHA-1 키는 다음 명령어로 얻을 수 있습니다:
    • macOS/Linux: keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
    • Windows: keytool -list -v -keystore "%USERPROFILE%\\.android\\debug.keystore" -alias androiddebugkey -storepass android -keypass android
  4. "앱 등록"을 클릭합니다.

iOS 앱 등록:

  1. 프로젝트 개요 페이지에서 iOS 아이콘을 클릭합니다.
  2. iOS 번들 ID: Xcode에서 프로젝트 설정의 "General" 탭에 있는 Bundle Identifier를 입력합니다. (예: com.example.yourApp)
  3. (선택 사항) 앱 닉네임과 App Store ID를 입력합니다.
  4. "앱 등록"을 클릭합니다.

설정 파일 다운로드 및 추가

앱 등록 후 Firebase 설정 파일을 다운로드하여 각 플랫폼별 프로젝트에 추가해야 합니다.

Android:

  1. google-services.json 파일을 다운로드합니다.

  2. 다운로드한 파일을 Flutter 프로젝트의 android/app/ 디렉터리로 이동합니다.

  3. android/build.gradle 파일에 다음 classpath를 추가합니다:

    plain
    buildscript {
        // ...
        dependencies {
            // ...
            classpath 'com.google.gms:google-services:4.4.1' // 최신 버전 확인
        }
    }
  4. android/app/build.gradle 파일 하단에 다음 플러그인을 추가합니다:

    plain
    apply plugin: 'com.google.gms.google-services'

iOS:

  1. GoogleService-Info.plist 파일을 다운로드합니다.
  2. Xcode를 사용하여 Flutter 프로젝트의 ios/Runner/ 디렉터리에 다운로드한 파일을 추가합니다. (파일을 드래그 앤 드롭할 때 "Copy items if needed" 옵션을 선택해야 합니다.)
  3. APNs 인증 키 설정:
    • Firebase 콘솔 > 프로젝트 설정 > 클라우드 메시징 탭으로 이동합니다.
    • iOS 앱 구성 섹션에서 "APNs 인증 키" 아래 "업로드" 버튼을 클릭합니다.
    • Apple Developer Member Center에서 APNs 인증 키(.p8 파일)를 생성하고 다운로드합니다.
    • 키 ID와 팀 ID를 입력하고 .p8 파일을 업로드합니다.

3. Node.js 서버 구현

Node.js 서버는 클라이언트 앱으로 FCM 메시지를 발송하는 역할을 합니다.

필수 패키지 설치

먼저, Node.js 프로젝트에 firebase-admin 패키지를 설치합니다.

bash
npm install firebase-admin
# 또는
yarn add firebase-admin

Firebase Admin SDK 초기화

Firebase Admin SDK를 사용하면 서버에서 Firebase 서비스와 상호 작용할 수 있습니다. 서비스 계정 키를 사용하여 SDK를 초기화합니다.

  1. Firebase 콘솔 > 프로젝트 설정 > 서비스 계정 탭으로 이동합니다.
  2. "새 비공개 키 생성"을 클릭하여 JSON 형식의 서비스 계정 키 파일을 다운로드합니다.
  3. 다운로드한 키 파일을 Node.js 프로젝트의 안전한 위치에 저장합니다. (예: 프로젝트 루트의 config 폴더) 주의: 이 키 파일은 민감한 정보를 포함하므로, 버전 관리 시스템(Git 등)에 직접 커밋하지 않도록 .gitignore에 추가하세요.

다음은 Admin SDK 초기화 예시입니다 (index.js 또는 유사한 파일):

plain
import * as admin from "firebase-admin";

// Firebase가 이미 초기화되었는지 확인
export function getFirebaseApp() {
  try {
    return admin.app(); // 이미 존재하는 앱 반환
  } catch (error) {
    // 앱이 존재하지 않는 경우 초기화
    const serviceAccount = require("../../your-service-account-key.json");
    return admin.initializeApp({
      credential: admin.credential.cert(serviceAccount as admin.ServiceAccount),
    });
  }
}

// 앱과 서비스 내보내기
export const firebaseApp = getFirebaseApp();

export const messaging = admin.messaging();

메시지 발송 API 구현

클라이언트로부터 특정 요청을 받으면 해당 클라이언트의 FCM 토큰으로 메시지를 보내는 API 엔드포인트를 구현합니다.

javascript
// ... (Firebase Admin SDK 초기화 코드 이후)

/**
 * 특정 FCM 토큰으로 메시지를 보냅니다.
 * @param {string} fcmToken - 메시지를 받을 클라이언트의 FCM 토큰
 * @param {string} title - 알림 제목
 * @param {string} body - 알림 본문
 * @param {object} data - 알림과 함께 전송할 추가 데이터 (선택 사항)
 */
async function sendMessageToToken(fcmToken, title, body, data = {}) {
  const message = {
    notification: {
      title: title,
      body: body,
    },
    token: fcmToken, // 특정 디바이스 토큰
    data: data,      // 선택적 데이터 페이로드
    android: { // Android 특정 옵션
      priority: 'high',
      notification: {
        sound: 'default',
        channel_id: 'channel_id' // Flutter에서 설정한 채널 ID와 일치
      }
    },
    apns: { // iOS 특정 옵션
      payload: {
        aps: {
          sound: 'default',
          badge: 1, // 앱 아이콘 배지 숫자
        }
      }
    }
  };

  try {
    const response = await admin.messaging().send(message);
    console.log('Successfully sent message:', response);
    return { success: true, messageId: response };
  } catch (error) {
    console.error('Error sending message:', error);
    return { success: false, error: error.message };
  }
}

// 예시: Express.js를 사용한 API 엔드포인트
// app.post('/send-notification', async (req, res) => {
//   const { token, title, body, data } = req.body;
//
//   if (!token || !title || !body) {
//     return res.status(400).send({ error: 'Missing required fields: token, title, body' });
//   }
//
//   const result = await sendMessageToToken(token, title, body, data);
//   if (result.success) {
//     res.status(200).send({ message: 'Notification sent successfully', messageId: result.messageId });
//   } else {
//     res.status(500).send({ error: 'Failed to send notification', details: result.error });
//   }
// });

// // 서버 시작
// const PORT = process.env.PORT || 3000;
// app.listen(PORT, () => {
//   console.log(`Server is running on port ${PORT}`);
// });

참고
위 코드에서 channel_id는 Flutter 클라이언트에서 로컬 알림을 위해 설정한 Android 알림 채널 ID와 일치해야 합니다.


4. Flutter 클라이언트 구현

Flutter 앱에서 FCM 메시지를 수신하고 처리하는 방법을 설정합니다.

필수 패키지 추가

pubspec.yaml 파일에 다음 패키지들을 추가합니다:

yaml
dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^2.27.2 # 최신 버전 확인
  firebase_messaging: ^14.8.0 # 최신 버전 확인
  flutter_local_notifications: ^17.0.0 # 최신 버전 확인
  flutter_dotenv: ^5.1.0 # .env 파일 사용 시
  http: ^1.2.1 # 서버로 토큰 전송 시

패키지를 추가한 후 터미널에서 flutter pub get을 실행합니다.

Firebase 초기화 설정

main.dart

앱이 시작될 때 Firebase를 초기화하고 백그라운드 메시지 핸들러를 설정합니다.

dart
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; // .env 사용 시
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'services/fcm_service.dart'; // FCM 서비스 클래스
// ... 기타 import

// 백그라운드/종료 상태에서 FCM 메시지 수신 시 호출될 핸들러
// 이 핸들러는 애플리케이션 컨텍스트 외부에 있어야 합니다 (최상위 레벨 또는 static 함수).
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // 백그라운드에서 Firebase가 여러 번 초기화되는 것을 방지
  if (Firebase.apps.isEmpty) {
    await Firebase.initializeApp(
      // options: FirebaseOptions(...) // main에서 사용하는 옵션과 동일하게 설정 가능
      // 또는 DefaultFirebaseOptions.currentPlatform 사용 (flutterfire configure로 생성된 경우)
    );
  }
  print("백그라운드 메시지 처리: ${message.messageId}");
  // 여기서 로컬 알림을 직접 표시하거나, 메시지 데이터를 기반으로 다른 백그라운드 작업을 수행할 수 있습니다.
  // 예: FCMService().showLocalNotification(...) - 단, FCMService 인스턴스 생성 방식에 주의
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: ".env"); // .env 사용 시

  // Firebase 초기화
  // flutterfire configure를 통해 생성된 firebase_options.dart 사용 권장
  // await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  // 또는 수동 설정
  if (Firebase.apps.isEmpty) {
    await Firebase.initializeApp(
      options: FirebaseOptions(
        apiKey: dotenv.env['FIREBASE_API_KEY'] ?? '',
        appId: dotenv.env['FIREBASE_APP_ID'] ?? '',
        messagingSenderId: dotenv.env['FIREBASE_MESSAGING_SENDER_ID'] ?? '',
        projectId: dotenv.env['FIREBASE_PROJECT_ID'] ?? '',
      ),
    );
  }


  // 백그라운드 메시지 핸들러 등록
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  // FCM 서비스 초기화 (포그라운드 메시지 처리 및 로컬 알림 설정 포함)
  await FCMService().init();

  runApp(const ProviderScope(child: MyApp())); // Riverpod 사용 시
  // runApp(const MyApp()); // 일반적인 경우
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // ... 앱 설정
      home: SplashScreen(), // 예시 시작 화면
    );
  }
}

Android 특정 설정 (android/app/src/main/AndroidManifest.xml):

flutter_local_notifications를 사용할 때, Android 12 (API 31) 이상에서는 예약된 알림을 사용하기 위해 SCHEDULE_EXACT_ALARM 권한이 필요할 수 있습니다.

xml
<manifest xmlns:android="<http://schemas.android.com/apk/res/android>"
    package="com.example.your_app">
    <uses-permission android:name="android.permission.INTERNET"/>
    <!-- Android 12 (API 31) 이상에서 정확한 알람 권한 (필요한 경우) -->
    <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
    <!-- Android 13 (API 33) 이상에서 알림 권한 -->
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
   <application
        android:label="your_app_name"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <activity
            // ...
            >
            <!-- FCM 클릭 시 열릴 액티비티 설정 -->
            <intent-filter>
                <action android:name="FLUTTER_NOTIFICATION_CLICK" />
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
        </activity>
        <!-- ... -->
        <!-- FCM 메시지 수신을 위한 서비스 (firebase-messaging 플러그인이 자동으로 추가할 수 있음) -->
        <!-- <service
            android:name=".MyFirebaseMessagingService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service> -->
        <meta-data
            android:name="com.google.firebase.messaging.default_notification_channel_id"
            android:value="00000_channel_id" /> <!-- 서버에서 보낼 때 channel_id와 일치시키거나, 여기서 기본 채널을 지정합니다. -->
    </application>
</manifest>

Android 13 (API 33)부터는 알림을 표시하기 위해 런타임 권한 POST_NOTIFICATIONS이 필요합니다. FCMService().requestPermission()에서 이 권한을 요청합니다.

iOS 특정 설정:

Push 알림을 사용하려면 Xcode에서 프로젝트의 "Signing & Capabilities" 탭으로 이동하여 "+ Capability"를 클릭하고 "Push Notifications"와 "Background Modes" (Background fetch, Remote notifications 체크)를 추가해야 합니다.

FCM 서비스 클래스 생성 (lib/services/fcm_service.dart)

FCM 관련 로직을 캡슐화하는 서비스 클래스를 만듭니다. 현재 프로젝트의 FCMService 클래스와 유사한 구조를 가집니다.

dart
// lib/services/fcm_service.dart
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; // .env 사용 시
import 'package:http/http.dart' as http; // 서버로 토큰 전송 시
import 'dart:convert'; // 서버로 토큰 전송 시

class FCMService {
  final FirebaseMessaging _messaging = FirebaseMessaging.instance;
  final FlutterLocalNotificationsPlugin _flutterLocalNotifications =
      FlutterLocalNotificationsPlugin();
  static final FCMService _instance = FCMService._internal();
  bool _isInitialized = false;

  factory FCMService() {
    return _instance;
  }

  FCMService._internal();

  Future<void> init() async {
    if (_isInitialized) return;

    // 1. 로컬 알림 플러그인 초기화
    // Android 설정
    const AndroidInitializationSettings initializationSettingsAndroid =
        AndroidInitializationSettings('@mipmap/ic_launcher'); // 앱 아이콘 사용

    // iOS 설정
    const DarwinInitializationSettings initializationSettingsIOS =
        DarwinInitializationSettings(
      requestAlertPermission: false, // 권한 요청은 별도로 처리
      requestBadgePermission: false,
      requestSoundPermission: false,
    );

    const InitializationSettings initializationSettings =
        InitializationSettings(
      android: initializationSettingsAndroid,
      iOS: initializationSettingsIOS,
    );

    await _flutterLocalNotifications.initialize(
      initializationSettings,
      onDidReceiveNotificationResponse: (NotificationResponse response) async {
        // 알림 클릭 시 로직 (예: 특정 화면으로 이동)
        print("로컬 알림 클릭됨. Payload: ${response.payload}");
        if (response.payload != null) {
          // final Map<String, dynamic> data = jsonDecode(response.payload!);
          // Navigator.of(GlobalContext.navigatorKey.currentContext!).pushNamed('/detail', arguments: data);
        }
      },
    );

    // 2. Android 알림 채널 생성 (로컬 알림용)
    await _createNotificationChannel();


    // 3. 포그라운드 메시지 리스너 설정
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      print('포그라운드 메시지 수신: ${message.messageId}');
      RemoteNotification? notification = message.notification;
      AndroidNotification? android = message.notification?.android;
      AppleNotification? apple = message.notification?.apple;

      // notification 페이로드가 있고, Android의 경우 channelId가 설정되어 있으면 Firebase가 자동으로 알림을 표시할 수 있음
      // 하지만, 커스텀 로컬 알림을 사용하거나 iOS에서 포그라운드 알림을 항상 표시하려면 직접 처리
      if (notification != null) {
        // iOS의 경우 포그라운드에서 기본적으로 알림이 표시되지 않으므로 로컬 알림으로 표시
        // Android도 커스텀하게 처리하고 싶다면 여기서 로컬 알림 표시
        _showLocalNotification(
          title: notification.title ?? '알림',
          body: notification.body ?? '',
          payload: jsonEncode(message.data), // message.data를 payload로 전달
        );
      }
    });

    // 4. 앱이 종료된 상태에서 알림을 클릭하여 열렸을 때 메시지 가져오기
    FirebaseMessaging.instance.getInitialMessage().then((RemoteMessage? message) {
      if (message != null) {
        print('앱 종료 상태에서 알림 클릭으로 실행됨: ${message.messageId}');
        // 여기서도 message.data를 사용하여 특정 화면으로 네비게이션 등의 작업 수행
        // 예: Navigator.of(GlobalContext.navigatorKey.currentContext!).pushNamed('/detail', arguments: message.data);
      }
    });


    // 5. 앱이 백그라운드 상태에서 알림을 클릭하여 열렸을 때 메시지 처리 리스너
    FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
      print('백그라운드에서 알림 클릭으로 앱 열림: ${message.messageId}');
      // message.data를 사용하여 특정 화면으로 네비게이션 등의 작업 수행
      // 예: Navigator.of(GlobalContext.navigatorKey.currentContext!).pushNamed('/detail', arguments: message.data);
    });


    // 6. iOS 포그라운드 알림 표시 설정 (선택 사항, 기본적으로는 로컬 알림으로 처리)
    // 이 설정을 하면 iOS 포그라운드에서도 시스템 알림이 표시됨.
    // flutter_local_notifications로 직접 표시하는 경우 중복될 수 있으므로 주의.
    await _messaging.setForegroundNotificationPresentationOptions(
      alert: true, // Required to display a heads up notification
      badge: true,
      sound: true,
    );

    _isInitialized = true;
    print('FCMService initialized.');
  }

  // Android 알림 채널 생성
  Future<void> _createNotificationChannel() async {
    const AndroidNotificationChannel channel = AndroidNotificationChannel(
      '000000_channel_id', // ID (서버에서 보낼 때 이 ID를 사용하거나, 기본 채널로 지정)
      '000 알림', // Title
      description: '0000 중요 알림 채널입니다.', // Description
      importance: Importance.max, // 중요도
      playSound: true,
      sound: RawResourceAndroidNotificationSound('notification_sound'), // res/raw/notification_sound.mp3 (확장자 없이)
    );

    await _flutterLocalNotifications
        .resolvePlatformSpecificImplementation<
            AndroidFlutterLocalNotificationsPlugin>()
        ?.createNotificationChannel(channel);
    print('Android Notification Channel "0000_channel_id" created.');
  }


  /// 알림 권한 요청 (iOS & Android 13+)
  Future<bool> requestPermission() async {
    NotificationSettings settings = await _messaging.requestPermission(
      alert: true,
      announcement: false,
      badge: true,
      carPlay: false,
      criticalAlert: false,
      provisional: false, // provisional은 사용자가 명시적으로 허용하지 않아도 조용한 알림을 받을 수 있게 함
      sound: true,
    );

    if (settings.authorizationStatus == AuthorizationStatus.authorized) {
      print('알림 권한 허용됨');
      return true;
    } else if (settings.authorizationStatus == AuthorizationStatus.provisional) {
      print('임시 알림 권한 허용됨 (iOS)');
      return true;
    } else {
      print('알림 권한 거부됨');
      return false;
    }
  }

  /// FCM 토큰 가져오기
  Future<String?> getToken() async {
    try {
      String? token = await _messaging.getToken();
      print("FCM Token: $token");
      // APNs 토큰 (iOS 전용, 디버깅 용도)
      // if (defaultTargetPlatform == TargetPlatform.iOS) {
      //   String? apnsToken = await _messaging.getAPNSToken();
      //   print("APNS Token: $apnsToken");
      // }
      return token;
    } catch (e) {
      print("FCM 토큰 가져오기 실패: $e");
      return null;
    }
  }

  /// 서버에 토큰 등록 (예시)
  Future<bool> registerTokenOnServer(String token, String userId) async {
    // 실제 서버 URL로 교체해야 합니다.
    final String serverUrl = dotenv.env['TOKEN_SERVER_URL'] ?? '<http://your-server.com/register-token>';
    try {
      final response = await http.post(
        Uri.parse(serverUrl),
        headers: {"Content-Type": "application/json"},
        body: jsonEncode({"fcm_token": token, "user_id": userId}),
      ).timeout(const Duration(seconds: 10));

      if (response.statusCode == 200 || response.statusCode == 201) {
        print("FCM 토큰 서버에 등록 성공");
        return true;
      } else {
        print("FCM 토큰 서버 등록 실패: ${response.statusCode}, ${response.body}");
        return false;
      }
    } catch (e) {
      print("FCM 토큰 서버 등록 중 오류: $e");
      return false;
    }
  }

  /// 로컬 알림 표시
  Future<void> _showLocalNotification({
    required String title,
    required String body,
    String? payload, // JSON 문자열 형태의 데이터 전달
  }) async {
    // 고유한 알림 ID 생성 (매번 다른 알림으로 표시하기 위함)
    final int notificationId = DateTime.now().millisecondsSinceEpoch.remainder(100000);

    // Android 알림 상세 설정
    const AndroidNotificationDetails androidPlatformChannelSpecifics =
        AndroidNotificationDetails(
      '000_channel_id', // 채널 ID (위에서 생성한 채널과 일치)
      '00000 알림',    // 채널 이름
      channelDescription: '0000 등 중요 알림 채널입니다.',
      importance: Importance.max,
      priority: Priority.high,
      ticker: 'ticker', // 상태 표시줄에 잠시 표시되는 텍스트
      playSound: true,
      sound: RawResourceAndroidNotificationSound('notification_sound'), // res/raw/notification_sound.mp3 (확장자 없이)
      // icon: '@mipmap/ic_launcher', // 알림 아이콘 (설정하지 않으면 앱 아이콘 사용)
    );

    // iOS 알림 상세 설정
    const DarwinNotificationDetails iOSPlatformChannelSpecifics =
        DarwinNotificationDetails(
      presentAlert: true,
      presentBadge: true,
      presentSound: true,
      sound: 'notification_sound.aiff', // ios/Runner/Assets 폴더에 추가 (확장자 포함)
      // badgeNumber: 1, // 앱 아이콘 배지 숫자
    );

    const NotificationDetails platformChannelSpecifics = NotificationDetails(
      android: androidPlatformChannelSpecifics,
      iOS: iOSPlatformChannelSpecifics,
    );

    await _flutterLocalNotifications.show(
      notificationId,
      title,
      body,
      platformChannelSpecifics,
      payload: payload,
    );
    print('로컬 알림 표시됨: $title - $body');
  }

  // 외부에서 로컬 알림을 쉽게 호출할 수 있도록 public 메소드 제공 (선택 사항)
  Future<void> showCustomLocalNotification({
    required String title,
    required String body,
    String? payload,
  }) async {
    await _showLocalNotification(title: title, body: body, payload: payload);
  }


  // 주제 구독/구독 취소 (선택 사항)
  Future<void> subscribeToTopic(String topic) async {
    await _messaging.subscribeToTopic(topic);
    print('Subscribed to topic: $topic');
  }

  Future<void> unsubscribeFromTopic(String topic) async {
    await _messaging.unsubscribeFromTopic(topic);
    print('Unsubscribed from topic: $topic');
  }
}

// 알림 클릭 시 Context 접근을 위한 GlobalKey (신중하게 사용해야 함)
// class GlobalContext {
//   static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
// }

주의사항 및 추가 설정:

  • 알림 아이콘 (Android): @mipmap/ic_launcher는 기본 앱 아이콘을 사용합니다. 커스텀 아이콘을 사용하려면 android/app/src/main/res/drawable (또는 mipmap 폴더)에 아이콘 파일을 추가하고 AndroidInitializationSettings 및 AndroidNotificationDetails의 icon 파라미터에 파일 이름을 지정합니다 (확장자 제외).
  • 알림 사운드:
    • Android: res/raw 폴더에 사운드 파일(예: notification_sound.mp3)을 추가하고 RawResourceAndroidNotificationSound('notification_sound')로 지정합니다.
    • iOS: Xcode를 사용하여 Runner/Assets (또는 유사한) 폴더에 사운드 파일(예: notification_sound.aiff 또는 .caf)을 추가하고 DarwinNotificationDetails의 sound 파라미터에 파일 이름을 지정합니다. (확장자 포함)
  • GlobalContext.navigatorKey: 알림 클릭 시 특정 화면으로 이동하기 위해 NavigatorState의 BuildContext가 필요할 수 있습니다. MaterialApp에 navigatorKey를 설정하고 이를 static 변수로 관리하여 서비스 클래스 등 어디서든 접근할 수 있게 할 수 있지만, 전역 상태 관리는 신중하게 사용해야 합니다. Riverpod과 같은 상태 관리 솔루션과 결합하여 더 깔끔하게 처리할 수 있습니다.
  • 로그인 후 토큰 등록: 사용자가 로그인에 성공하면 FCMService().getToken()을 호출하여 FCM 토큰을 가져오고, FCMService().registerTokenOnServer(token, userId)를 통해 백엔드 서버에 해당 사용자의 토큰을 등록합니다. (현재 프로젝트의 login_screen.dart 참고)
dart
// lib/screens/login_screen.dart (일부)
// ...
Future<void> _login() async {
  if (_formKey.currentState!.validate()) {
    // ... 로그인 로직 ...
    if (loginSuccess) {
      final fcmToken = await FCMService().getToken();
      if (fcmToken != null && mounted) {
        // 실제 사용자 ID 또는 식별자 사용
        await FCMService().registerTokenOnServer(fcmToken, "user_id_from_login");
      }
      // 홈 화면으로 이동
      Navigator.pushReplacementNamed(context, '/home');
    }
    // ...
  }
}
// ...

5. 메시지 유형별 처리 심층 분석

FCM 메시지는 앱의 상태(포그라운드, 백그라운드, 종료)에 따라 다르게 처리됩니다.

  • 포그라운드 (Foreground):
    • 앱이 현재 사용자에 의해 활발하게 사용 중인 상태입니다.
    • FirebaseMessaging.onMessage.listen 콜백이 호출됩니다.
    • 기본적으로 시스템 트레이에 알림이 자동으로 표시되지 않을 수 있습니다 (특히 iOS).
    • flutter_local_notifications를 사용하여 직접 로컬 알림을 표시하는 것이 일반적입니다.
    • message.notification (알림 페이로드)과 message.data (데이터 페이로드)를 모두 포함할 수 있습니다.
  • 백그라운드 (Background):
    • 앱이 홈 버튼 등으로 인해 백그라운드로 전환되었지만 완전히 종료되지 않은 상태입니다.
    • FCM 메시지에 notification 페이로드가 포함되어 있으면, 시스템이 자동으로 시스템 트레이에 알림을 표시합니다. 사용자가 이 알림을 클릭하면 앱이 포그라운드로 전환됩니다.
    • FirebaseMessaging.onMessageOpenedApp.listen 콜백이 알림 클릭 시 호출됩니다.
    • _firebaseMessagingBackgroundHandler는 notification 페이로드만 있는 메시지(데이터 메시지 없이)에 대해서는 일반적으로 호출되지 않고, 데이터 메시지 또는 알림+데이터 메시지에 대해 호출될 수 있습니다 (플랫폼 및 메시지 구성에 따라 다름). 주로 데이터 전용 메시지를 백그라운드에서 처리할 때 유용합니다.
  • 종료 (Terminated):
    • 앱이 사용자에 의해 명시적으로 종료되었거나 시스템에 의해 종료된 상태입니다.
    • notification 페이로드가 포함된 메시지는 시스템 트레이에 알림으로 표시됩니다. 사용자가 알림을 클릭하면 앱이 실행됩니다.
    • FirebaseMessaging.instance.getInitialMessage()를 통해 앱이 알림 클릭으로 인해 시작되었는지, 그렇다면 어떤 메시지인지 확인할 수 있습니다. 이 코드는 main 함수나 스플래시 화면 등 앱 초기 실행 시점에 호출하는 것이 좋습니다.
    • _firebaseMessagingBackgroundHandler는 앱이 종료된 상태에서 데이터 메시지나 알림+데이터 메시지를 수신했을 때 호출될 수 있습니다.

메시지 페이로드 종류:

  • 알림 메시지 (Notification Message):
    • notification 키를 포함합니다. (예: {"notification": {"title": "Hello", "body": "World"}, "to": "DEVICE_TOKEN"})
    • FCM이 자동으로 앱 아이콘, 제목, 본문을 사용하여 시스템 트레이에 알림을 표시합니다 (앱이 백그라운드/종료 상태일 때).
    • 포그라운드에서는 onMessage로 전달되지만, 자동 표시는 되지 않을 수 있습니다.
  • 데이터 메시지 (Data Message):
    • data 키만 포함합니다. (예: {"data": {"key1": "value1", "key2": "value2"}, "to": "DEVICE_TOKEN"})
    • 앱이 어떤 상태(포그라운드, 백그라운드, 종료)에 있든 항상 onMessage (포그라운드) 또는 _firebaseMessagingBackgroundHandler (백그라운드/종료)로 전달됩니다.
    • 시스템 트레이에 자동으로 알림이 표시되지 않으므로, 클라이언트 앱에서 직접 처리해야 합니다 (예: 로컬 알림 표시).
  • 알림 + 데이터 메시지 (Notification & Data Message):
    • notification과 data 키를 모두 포함합니다.
    • 백그라운드/종료 상태: notification 부분은 시스템 트레이에 표시되고, data 부분은 사용자가 알림을 클릭하여 앱을 열 때 onMessageOpenedApp 또는 getInitialMessage를 통해 전달됩니다.
    • 포그라운드 상태: onMessage로 notification과 data 모두 전달됩니다.

일반적으로 사용자에게 즉각적인 알림을 주려면 notification 페이로드를 사용하고, 앱 내부 로직 처리를 위한 숨겨진 데이터를 보내려면 data 페이로드를 사용합니다. 두 가지를 함께 사용하여 유연성을 높일 수도 있습니다.


6. 테스트 및 디버깅

  • Firebase 콘솔 사용:
    1. Firebase 콘솔 > 참여 > Messaging으로 이동합니다.
    2. "첫 번째 캠페인 만들기" 또는 "Send your first message"를 클릭합니다.
    3. "Firebase Notification messages"를 선택하고 "만들기"를 클릭합니다.
    4. 알림 제목, 텍스트 등을 입력합니다.
    5. "테스트 메시지 보내기" 섹션에서 FCMService().getToken()으로 얻은 FCM 등록 토큰을 추가하고 "테스트"를 클릭합니다.
    6. 또는 "다음"을 눌러 타겟(앱 선택), 예약 등을 설정하고 메시지를 발송할 수 있습니다.
  • Node.js 서버 API 사용:
    • Postman이나 curl과 같은 도구를 사용하여 직접 구현한 메시지 발송 API를 테스트합니다. FCM 토큰, 제목, 본문 등을 요청 바디에 포함하여 POST 요청을 보냅니다.
  • 로그 확인:
    • Flutter 앱의 print 문을 통해 포그라운드, 백그라운드 메시지 수신 및 토큰 관련 로그를 확인합니다.
    • Android Studio의 Logcat이나 Xcode의 Console을 사용하여 네이티브 레벨의 FCM 관련 로그도 확인할 수 있습니다.
  • 일반적인 문제:
    • Android에서 알림이 표시되지 않음:
      • google-services.json 파일이 올바르게 추가되었는지 확인합니다.
      • build.gradle 파일 설정이 올바른지 확인합니다.
      • 앱이 백그라운드에서 배터리 최적화 등으로 인해 제한되지 않았는지 확인합니다.
      • 알림 채널이 올바르게 생성되었는지, 서버에서 보낸 메시지의 channel_id가 일치하는지 확인합니다. (FCM이 자동으로 채널을 만들기도 하지만, 커스텀 사운드 등을 위해서는 직접 생성 권장)
      • Android 13 이상에서 POST_NOTIFICATIONS 권한이 허용되었는지 확인합니다.
    • iOS에서 알림이 표시되지 않음:
      • GoogleService-Info.plist 파일이 올바르게 추가되었는지 확인합니다.
      • Xcode에서 "Push Notifications" 및 "Background Modes" 기능이 활성화되었는지 확인합니다.
      • APNs 인증 키가 Firebase 콘솔에 올바르게 업로드되었는지 확인합니다.
      • 실제 기기에서 테스트하는지 확인합니다 (iOS 시뮬레이터는 푸시 알림을 지원하지 않습니다).
      • AppDelegate.swift (또는 Objective-C)에 푸시 알림 관련 코드가 중복되거나 충돌하지 않는지 확인합니다 (Flutter 플러그인이 대부분 처리해 줌).
      • 앱이 처음 실행될 때 알림 권한을 요청하고 사용자가 허용했는지 확인합니다.
    • 토큰 가져오기 실패: 네트워크 연결 문제 또는 Firebase 설정 오류일 수 있습니다.
    • 메시지가 onMessage 또는 백그라운드 핸들러로 전달되지 않음: 메시지 페이로드 형식이 올바른지, 앱 상태에 맞는 핸들러를 사용하고 있는지 확인합니다.

7. 결론

  1. 앱 시작
    1. main.dart에서 Firebase 앱 초기화
    2. 백그라운드 메시지 핸들러 등록
    3. FCMService 초기화, 포그라운드 메시지 리스너 설정
  2. 로그인 화면
    1. 사용자 알림 권한 요청(FCMService().requestPermission())
  3. 로그인 성공 시
    1. FCM 토큰을 가져옴 (FCMService().getToken())
    2. 가져온 토큰을 앱 서버에 등록 (FCMService().registerTokenOnServer())
  4. FCM 메시지 수신
    1. 포그라운드 : FCMService의 FirebaseMessaging.onMessage.listen 콜백 실행, _ showLocalNotification 실행
    2. 백그라운드 또는 종료 상태 : _ firebaseMessagingBackgroundHandler 함수 실행

비유

  • Firebase Cloud Messaging (FCM): 거대한 "우체국"이라고 생각하세요. 전 세계 앱들에게 메시지를 배달해 줍니다.
  • FCM 토큰: 각 앱(정확히는 앱이 설치된 기기)의 고유한 "집 주소"입니다. 우체국은 이 주소로 편지(메시지)를 보냅니다.
  • FCMService: 우리 앱의 "우편함 관리인"입니다. 편지(메시지)를 받고, 정리하고, 주인(사용자)에게 알려주는 역할을 합니다.
  • Firebase.initializeApp(): 우리 집을 우체국에 등록하는 절차와 같습니다. "우리 집도 이제 편지 받을 준비가 됐어요!"
  • requestPermission(): "앞으로 오는 편지(알림)를 받아보시겠어요?" 라고 집주인에게 물어보는 것입니다.
  • getToken(): 우리 집 "집 주소(토큰)"를 확인하는 것입니다.
  • registerTokenOnServer(): 우리 집 "집 주소(토큰)"를 우리 앱의 서버(관리실)에 알려줘서, 나중에 관리실에서 직접 편지를 보낼 수 있도록 하는 것입니다.
  • onMessage.listen (포그라운드): 집주인이 집에 있을 때 초인종이 울리면(메시지 도착) 바로 알려주는 것과 같습니다.
  • onBackgroundMessage (백그라운드/종료): 집주인이 외출 중이거나 자고 있을 때 우편함에 편지가 도착하면(메시지 도착), 나중에 확인할 수 있도록 하는 것입니다.

이 링크를 통해 구매하시면 제가 수익을 받을 수 있어요. 🤗

https://inf.run/ZC6AE