[Flutter] Firebase 연동 후기: FCM과 알림 구현하며 겪은 삽질기

KoEunseo·2026년 1월 9일

flutter

목록 보기
47/50

앱 개발을 한다? Firebase 필수라고 생각한다. 일단 FCM이 압도적임.

본인도 Firebase를 연동하고 FCM(Firebase Cloud Messaging)을 통해 푸시 알림을 구현했다. 처음에는 공식 문서만 보고 하면 되겠지 싶었는데, 실제로는 플랫폼별 차이와 예상치 못한 이슈들이 꽤 있었다.. 신경쓸 게 생각보다 많음.

왜 Firebase를 선택했나?

처음에는 단순히 푸시 알림만 필요했는데, 프로젝트를 진행하다 보니 크래시 리포팅(Crashlytics)과 분석(Analytics)도 붙여야겠다 싶었다. 회사 내부에서 테스트를 하는데, 테스터의 말이나 문서만 봐서는 문제 원인을 파악하기 힘들었기 때문에... 회사 내에 있는 60~70명의 임직원들이 테스트하고 문제가 있는지 여부에 대해서 모니터링할 필요성이 있었다.
Firebase는 이 세 가지를 모두 제공하고, Flutter와의 통합도 잘 되어 있다.


1. Firebase 프로젝트 설정

Firebase Console에서 프로젝트 생성

Firebase Console에서 프로젝트를 만드는 건 간단하다. 다만 한 가지 주의할 점은 Android와 iOS를 각각 별도로 등록해야 한다는 것.

  1. Firebase Console 접속
  2. "프로젝트 추가" 클릭
  3. 프로젝트 이름 입력
  4. Android 앱 추가 → 패키지 이름 입력 → google-services.json 다운로드
  5. iOS 앱 추가 → 번들 ID 입력 → GoogleService-Info.plist 다운로드

여기서 패키지 이름과 번들 ID는 나중에 변경하기가 어려우니 신중해야한다. 나같은 경우 처음에 앱 이름이 안나와서 임의로 노무관리 앱의 경우 회사명_work 라고 지었는데 추후에 pro라고 이름이 나와서... 어떻게 할까 고민중이다. 프로젝트 삭제하고 다시 만들어야하나 싶어서...ㅠ


2. Flutter 프로젝트에 Firebase 추가 - FlutterFire CLI 활용

FlutterFire CLI 설치 및 설정

dart pub global activate flutterfire_cli
flutterfire configure

이 명령어 하나로:

  • Firebase 프로젝트 선택
  • 플랫폼 선택 (Android, iOS)
  • firebase_options.dart 파일 자동 생성

모두 자동으로 처리됨.

pubspec.yaml에 패키지 추가

dependencies:
  # Firebase
  firebase_core: ^3.14.0
  firebase_messaging: ^15.2.7
  firebase_crashlytics: ^4.3.7
  firebase_analytics: ^11.5.0

  # 로컬 알림
  flutter_local_notifications: ^18.0.1

3. Android 설정

3.1 google-services.json 파일 추가

다운로드한 google-services.json 파일을 android/app/ 디렉토리에 붙여넣는다.

3.2 build.gradle 설정

프로젝트 레벨 (android/build.gradle)

처음에는 버전을 명시하지 않았는데 나중에 충돌이 발생했다. 그래서 명시적으로 버전을 지정함.

buildscript {
    dependencies {
        classpath "com.google.gms:google-services:4.4.0"
        classpath "com.google.firebase:firebase-crashlytics-gradle:2.9.9"
    }
}

앱 레벨 (android/app/build.gradle)

여기서 중요한 건 com.google.gms.google-services 플러그인을 추가하는 것. 이걸 빼먹으면 Firebase가 제대로 작동하지 않는다.

plugins {
    id "com.android.application"
    id "kotlin-android"
    id "dev.flutter.flutter-gradle-plugin"
    id 'com.google.gms.google-services'  // 이거 필수!!!!!!
}

dependencies {
    implementation platform('com.google.firebase:firebase-bom:33.1.2')
    implementation 'com.google.firebase:firebase-analytics'
    implementation 'com.google.firebase:firebase-crashlytics'
}

3.3 AndroidManifest.xml 설정

권한 추가

Android 13(API 33) 이상부터는 POST_NOTIFICATIONS 권한을 명시적으로 요청해야 한다. 유저로부터 알림을 받을 것인지 허락을 받아야 함. 허락이 안되어있으면 알람 안온다. 이전 버전에서는 그냥 아주 개발자 마음대로 했음ㅋㅋㅋㅋ(부럽) 테스터 중에 예전 폰을 가진 분이 있어서, 버전에 따라 다르게 동작한다는 것을 알게 되었다.

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>

FCM 메타데이터 설정 - 왜 자동 초기화를 끄기로 했나?

처음에는 Firebase의 자동 초기화를 그대로 사용했다. 그냥 앱이 시작되자마자 FCM이 초기화되면서 사용자가 로그인하기 전에도 토큰이 생성되고, 서버에 등록되는 문제가 있었음.

그래서 수동으로 제어하기로 결정했습니다:->

<application>
    <!-- FCM 기본 알림 아이콘 -->
    <meta-data
        android:name="com.google.firebase.messaging.default_notification_icon"
        android:resource="@drawable/ic_logo" />

    <!-- FCM 자동 초기화 비활성화 - 수동 제어를 위해 -->
    <meta-data
        android:name="firebase_messaging_auto_init_enabled"
        android:value="false" />

    <!-- 기본 알림 채널 ID -->
    <meta-data
        android:name="com.google.firebase.messaging.default_notification_channel_id"
        android:value="@string/default_notification_channel_id"/>
</application>

이렇게 하면 FirebaseMessaging.instance.setAutoInitEnabled(true)를 호출할 때만 FCM이 초기화된다. 사용자가 로그인한 후에만 초기화하도록 제어할 수 있다.

strings.xml에 채널 ID 추가

android/app/src/main/res/values/strings.xml:

<resources>
    <string name="default_notification_channel_id">high_importance_channel</string>
</resources>

4. iOS 설정

안드로이드는 vscode만 뚜들기면 되지만 ios는 xcode를 좀 만져야한다. xcode라는 익숙지 않은 산만 좀 넘으면 간단함.

4.1 GoogleService-Info.plist 추가

다운로드한 GoogleService-Info.plist 파일을 ios/Runner/ 디렉토리에 복사하고, Xcode에서 프로젝트를 열어 파일을 추가한다. 그냥 복사만 하지 말고 Xcode에서 추가가 되었는지 확인해야 함..

4.2 Info.plist 설정

백그라운드 모드 추가

iOS에서 백그라운드 알림을 받으려면 UIBackgroundModesremote-notification을 추가해야 한다.

<key>UIBackgroundModes</key>
<array>
    <string>remote-notification</string>
    <string>processing</string>
    <string>fetch</string>
</array>

알림 권한 설명

iOS는 사용자에게 알림 권한을 요청할 때 왜 필요한지 설명해야 한다. 이 설명이 없으면 앱 심사에서 거절된다. 설명이 성의없어도 거절될 수 있다. 우리 회사는 이런거 기획 안되어있어서... 내가 그냥 아래와 같이 잘(?) 작성함. 그냥 개발자 간 설명을 위한 게 아니고, 앱을 사용하는 유저에게 작성한 문구가 알람에 뜨기 때문에 대충 쓰지 말고 예쁘게 쓰는 게 좋겠다.

<key>NSLocalNotificationUsageDescription</key>
<string>진료 예약 알림, 처방전 도착 알림, 비대면 진료 시작 알림 등 중요한 의료 관련 정보를 놓치지 않기 위해 알림 권한이 필요합니다.</string>

Firebase 자동 초기화 비활성화

Android와 마찬가지로 iOS에서도 자동 초기화를 비활성화.

<key>FirebaseMessagingAutoInitEnabled</key>
<false/>

4.3 AppDelegate.swift 설정

iOS에서는 알림 권한을 요청하고, 포그라운드에서도 알림을 표시하도록 설정해야 한다.

import UIKit
import Flutter
import UserNotifications

@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GeneratedPluginRegistrant.register(with: self)

    // iOS 10+ 알림 권한 요청
    if #available(iOS 10.0, *) {
      UNUserNotificationCenter.current().delegate = self
      let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
      UNUserNotificationCenter.current().requestAuthorization(
        options: authOptions,
        completionHandler: {_, _ in })
    }

    application.registerForRemoteNotifications()

    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  // 포그라운드에서 알림 표시 - 이걸 안 하면 포그라운드에서 알림이 안 보입니다
  @available(iOS 10.0, *)
  override func userNotificationCenter(_ center: UNUserNotificationCenter,
                              willPresent notification: UNNotification,
                              withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
    completionHandler([.alert, .badge, .sound])
  }
}

4.4 Capabilities 설정

Xcode에서 프로젝트를 열고:

  1. Target > Signing & Capabilities
  2. "+ Capability" 클릭
  3. "Push Notifications" 추가
  4. "Background Modes" 추가 → "Remote notifications" 체크

이걸 안 하면 백그라운드 알림을 받을 수 없다.


5. Flutter 코드 작성

5.1 Firebase 초기화

Firebase는 앱 시작 시점에 초기화를 꼭 해야한다. main 함수에서 WidgetsFlutterBinding.ensureInitialized() 다음에 바로 초기화

import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Firebase.initializeApp(
    name: '{앱이름}',
    options: DefaultFirebaseOptions.currentPlatform,
  );

  runApp(const MyApp());
}

5.2 FCM 백그라운드 핸들러

앱이 완전히 종료된 상태에서 알림을 받으면, 별도의 isolate에서 실행된다. 그래서 @pragma('vm:entry-point') 어노테이션이 필요하고, Firebase를 다시 초기화해야 한다.

('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // 백그라운드 핸들러는 별도 isolate에서 실행되므로 Firebase를 다시 초기화해야 합니다
  await Firebase.initializeApp();

  debugPrint('[FCM] 백그라운드 메시지 수신: ${message.messageId}');
  debugPrint('[FCM] 데이터: ${message.data}');
  debugPrint('[FCM] 알림: ${message.notification?.title}');
}

void main() async {
  // ...

  // 백그라운드 메시지 핸들러 등록 - main 함수 최상단에 위치해야 합니다
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  // ...
}

처음에는 이 핸들러를 Provider 안에 넣으려고 했는데, 작동하지 않았다... 왜냐하면 백그라운드 핸들러는 top-level 함수여야 하기 때문...ㅠ

5.3 알림 권한 요청

앱 시작 시점에 알림 권한을 요청한다. iOS는 여기서 요청하고, Android 13+도 여기서 요청한다. 알림 권한 꺼놓고 테스트해봐야 푸시 안온다.

await FirebaseMessaging.instance.requestPermission(
  alert: true,
  badge: true,
  sound: true,
  provisional: false,
);

6. FCM 토큰 관리

6.1 FCM 토큰 가져오기 및 서버 등록

FCM 토큰은 서버에 등록해야 푸시 알림을 받을 수 있다. 로그인할 때만 등록하는 게 아니고 토큰이 갱신될 때를 고려해야 함.

class NotificationService {
  final FirebaseMessaging _firebaseMessaging;
  final Dio _dio;

  Future<void> setupAndRegisterFCMToken() async {
    try {
      final token = await _firebaseMessaging.getToken();
      if (token == null) {
        throw Exception('FCM 토큰을 받지 못했습니다.');
      }

      // 서버에 토큰 등록
      await _registerTokenToServer(token);
      log('[NotificationService] FCM Token registered: $token');
    } catch (e) {
      log('[NotificationService] FCM 토큰 설정 실패: $e');
    }
  }

  Future<void> _registerTokenToServer(String token) async {
    await _dio.put(
      '/api/user/fcm-token',
      data: {'token': token},
    );
  }
}

6.2 토큰 갱신 처리

FCM 토큰은 다음 경우에 갱신된다:

  • 앱 재설치
  • 앱 데이터 삭제
  • 새 기기에서 로그인
  • Firebase 프로젝트 삭제 후 재생성

토큰이 갱신되면 서버에도 업데이트해야 한다. 이걸 안 하면 알림을 못 받게 됨.. 위에서 말했지만 나는 로그인 처리 할때마다 refresh함.

// 토큰 갱신 리스너
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
  _registerTokenToServer(newToken);
});

7. 알림 처리 구현 - 플랫폼별 대응

7.1 알림 상태별 처리 - 세 가지 케이스를 모두 고려해야 합니다

앱이 푸시 알람을 보내려면 앱의 상태에 대해서 인지하고 각 상태에 대해 대응해야 한다. 여기서 알아야 하는 상태는 포그라운드, 백그라운드, 터미네이티드. 각 상태에 대해 처리하고 충분한 테스트가 필요하다.

포그라운드: 앱 실행중인 상태
백그라운드: 앱은 실행중인데 백그라운드(뒤)에서 실행중인 상태. 홈 버튼 누른 상태랄까
터미네이티드: 앱이 종료되어있는 상태

FCM 핸들러 구현

(keepAlive: true)
class FcmHandler extends _$FcmHandler {
  StreamSubscription<RemoteMessage>? _onMessageSubscription;
  StreamSubscription<RemoteMessage>? _onMessageOpenedAppSubscription;

  
  Future<void> build() async {
    await _setupListeners();
  }

  Future<void> _setupListeners() async {
    // 1. 앱 종료 상태에서 알림 탭 처리
    // 앱이 종료된 상태에서 알림을 탭하면, 앱이 시작될 때 이 메시지를 가져올 수 있습니다
    final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
    if (initialMessage != null) {
      _processMessage(initialMessage);
    }

    // 2. 백그라운드에서 알림 탭 처리
    // 앱이 백그라운드에 있을 때 알림을 탭하면 이 리스너가 호출됩니다
    _onMessageOpenedAppSubscription =
        FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
      _processMessage(message);
    });

    // 3. 포그라운드에서 메시지 수신 처리
    _onMessageSubscription =
        FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      // 현재 보고 있는 채팅방의 메시지라면 알림을 띄우지 않음
      final currentRoomId = ref.read(chatProvider).currentChatRoomId;
      final messageRoomId = message.data['room_id'];

      if (currentRoomId == messageRoomId) {
        return;
      }

      // 안드로이드에서는 포그라운드 알림을 직접 띄워줘야 함
      // iOS는 AppDelegate에서 자동으로 처리되지만, 안드로이드는 안 됩니다
      if (Platform.isAndroid) {
        ref.read(notificationServiceProvider).showChatNotification(
              sender: message.data['sender_name'] ?? '보낸이',
              message: message.notification?.body ?? '새 메시지가 도착했습니다.',
              chatRoomId: message.data['room_id'] as String,
              data: message.data,
            );
      }
    });
  }

  void _processMessage(RemoteMessage message) {
    final roomId = message.data['room_id'] as String?;
    if (roomId != null) {
      ref.read(globalNavigationServiceProvider).navigateToChatRoom(roomId);
    }
  }
}

7.2 왜 로컬 알림을 함께 사용했나? - 안드로이드 포그라운드 문제

처음에는 FCM만 사용 했는데, 안드로이드에서 포그라운드 상태일 때 FCM 알림이 자동으로 표시되지 않는 문제가 있었다. iOS는 AppDelegate에서 willPresent 메서드를 구현하면 자동으로 표시되지만, 안드로이드는 그렇지 않다.

그래서 안드로이드 포그라운드에서는 FCM 메시지를 받아서 로컬 알림으로 변환해서 표시했다.

class NotificationService {
  final FlutterLocalNotificationsPlugin _localNotifications;

  Future<void> initialize() async {
    // 안드로이드 초기화 설정
    const initializationSettingsAndroid =
        AndroidInitializationSettings('@mipmap/ic_launcher');

    // iOS 초기화 설정
    // iOS는 FCM이 자동으로 알림을 표시하므로, 로컬 알림은 거의 사용하지 않습니다
    // 하지만 초기화는 해야 한다
    const initializationSettingsIOS = DarwinInitializationSettings(
      requestAlertPermission: false,  // 이미 FCM에서 요청했으므로
      requestBadgePermission: false,
      requestSoundPermission: false,
    );

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

    await _localNotifications.initialize(
      initializationSettings,
      onDidReceiveNotificationResponse: _onNotificationTap,
    );

    // 안드로이드 알림 채널 생성
    // Android 8.0 이상부터는 알림 채널이 필수!!!!!
    // 알림 채널명은 위에서 strings.xml에 추가한 채널 ID와 같아야함
    await _localNotifications
        .resolvePlatformSpecificImplementation<
            AndroidFlutterLocalNotificationsPlugin>()
        ?.createNotificationChannel(
          const AndroidNotificationChannel(
            'high_importance_channel',
            'High Importance Notifications',
            importance: Importance.max,
          ),
        );
  }
}

7.3 알림 표시 - 채널을 채팅방별로 분리한 이유

처음에는 모든 채팅 알림을 하나의 채널로 보냈는데, 사용자가 특정 채팅방의 알림만 끄고 싶을 때 문제가 생긴다. 그래서 채팅방별로 채널을 분리함.

Future<void> showChatNotification({
  required String sender,
  required String message,
  required String chatRoomId,
  Map<String, dynamic>? data,
}) async {
  // 채팅방별로 채널을 분리 - 사용자가 개별적으로 알림을 제어할 수 있도록
  final androidDetails = AndroidNotificationDetails(
    'chat_room_$chatRoomId',  // 채널 ID를 채팅방 ID로 설정
    'Chat Messages',
    importance: Importance.max,
    priority: Priority.high,
    groupKey: 'chat_messages',  // 같은 그룹으로 묶어서 표시
  );

  const iosDetails = DarwinNotificationDetails(
    presentAlert: true,
    presentBadge: true,
  );

  final details = NotificationDetails(
    android: androidDetails,
    iOS: iosDetails,
  );

  // notificationId를 채팅방 ID의 해시값으로 설정
  // 같은 채팅방의 알림은 덮어쓰기 됩니다
  await _localNotifications.show(
    chatRoomId.hashCode,
    sender,
    message,
    details,
    payload: jsonEncode(data),  // 알림 탭 시 사용할 데이터
  );
}

7.4 알림 탭 처리 - 딥링크처럼 동작하도록

알림을 탭하면 해당 채팅방으로 이동하도록 구현했다. payload에 채팅방 ID를 넣어서 처리한다.

void _onNotificationTap(NotificationResponse response) {
  if (response.payload == null) return;

  final payloadData = jsonDecode(response.payload!);
  final roomId = payloadData['room_id'] as String?;

  if (roomId != null) {
    _ref.read(globalNavigationServiceProvider).navigateToChatRoom(roomId);
  }
}

8. 인증된 사용자에게만 알림 초기화 - 리소스 절약

처음에는 앱 시작 시점에 바로 알림을 초기화했는데, 로그인하지 않은 사용자에게도 불필요하게 리소스를 사용하는 문제가 있었다. 그래서 로그인한 사용자에게만 초기화하도록 변경함.

(keepAlive: true)
Future<void> authenticatedInitializer(Ref ref) async {
  final authState = ref.watch(authProvider);

  if (authState.value is Authenticated) {
    await Future.wait([
      // 1. 로컬 알림 서비스 초기화
      ref.read(notificationServiceProvider).initialize(),
      // 2. FCM 메시지 리스너 등록
      ref.watch(fcmHandlerProvider.future),
      // 3. FCM 토큰을 가져와서 서버에 등록
      ref.read(notificationServiceProvider).setupAndRegisterFCMToken(),
    ]);
  }
}

이렇게 하면 로그인한 사용자에게만 알림 관련 리소스가 할당된다.


9. 테스트 및 디버깅 - 실제로 겪은 문제들

9.1 FCM 토큰 확인

개발 중에는 FCM 토큰을 콘솔에 출력해서 기능이 잘 붙었나 확인하면 편하다.

final token = await FirebaseMessaging.instance.getToken();
print('FCM Token: $token');

이 토큰을 Firebase Console의 "테스트 메시지 전송"에 입력하면 바로 테스트할 수 있음!!

9.2 실제로 겪은 문제들

문제 1: 안드로이드에서 포그라운드 알림이 안 보임

증상: 백그라운드나 앱 종료 상태에서는 알림이 오는데, 포그라운드에서는 안 옴

원인: 안드로이드는 포그라운드에서 FCM 알림을 자동으로 표시하지 않음

해결: onMessage 리스너에서 로컬 알림으로 변환해서 표시

문제 2: iOS에서 알림 권한이 거부되면 알림이 안 옴

증상: 사용자가 알림 권한을 거부하면, 설정에서 다시 허용해도 알림이 안 옴

원인: requestPermission을 한 번만 호출하고, 거부되면 다시 요청하지 않음

해결: 권한 상태를 확인하고, 거부된 경우 설정 화면으로 안내하는 로직 추가

문제 3: 백그라운드 핸들러가 작동하지 않음

증상: 앱이 종료된 상태에서 알림을 받아도 핸들러가 실행되지 않음

원인: @pragma('vm:entry-point') 어노테이션을 빼먹음

해결: 어노테이션 추가 및 top-level 함수로 이동

9.3 디버깅 팁

Android

# Firebase 로그 확인
adb logcat | grep -i firebase

# FCM 로그 확인
adb logcat | grep -i fcm

별개로 팁을 좀 주자면 로그를 확인하는 게 제일 빠르다. vscode말고 android studio로 확인하면 오만 로그가 다 뜨기 때문에 거기서 확인하는 방법도 있음. 아무리 봐도 어떤 문제인지 모르겠을때 안드로이드 스튜디오를 사용한다.
필자같은 경우 앱을 켰지만 흰 화면만 나오고 실행이 도통 안돼서 디버깅이 불가능한 적이 있는데 이때 안드로이드 스튜디오 로그를 보고 해결했다. 어떤 문제였는지는 기억나지 않지만...

iOS

Xcode Console에서 Firebase 로그를 확인할 수 있다. 알림 권한 상태를 확인하는 게 제일 좋음.

final settings = await FirebaseMessaging.instance.getNotificationSettings();
print('Authorization status: ${settings.authorizationStatus}');

10. 마무리하며

Firebase와 FCM을 연동하면서 가장 많이 느낀 점은 플랫폼별 차이를 제대로 이해하는 게 중요하다는 것. iOS와 Android가 다르게 동작하는 부분이 많아서, 각각 테스트해봐야 한다. 거기다 앱 상태에 따라 각각 다르게 처리해야 함.

특히:

  • 안드로이드 포그라운드 알림 처리는 로컬 알림으로 해결
  • iOS는 AppDelegate에서 자동 처리되지만, 권한 관리가 중요
  • 백그라운드 핸들러는 별도 isolate에서 실행되므로 주의 필요
  • 토큰 갱신을 놓치면 알림을 못 받게 됨

아. 그리고 권한 관리할 때 permission_handler를 보통 쓸텐데, 어떤 메서드는 실제 권한 상태와 다르게 항상 false만 리턴하는 버그가 있었다. 그게 뭐더라...? 예시로 많이 쓰이던 메서드였는데. 지금은 해결이 되었는지 모르겠지만,, 잘 한거같은데 도통 안된다면 권한 상태가 제대로 인지되고있는지도 꼭 확인할것.


참고 자료

profile
주니어 플러터 개발자의 고군분투기

0개의 댓글