[Flutter] 내 앱에 생명을 불어넣는 알림 기능, FCM으로 완성한 첫 연결

서연·2025년 10월 29일
post-thumbnail

📖 Firebase Cloud Messaging

  • Google Firebase에서 제공하는 무료 푸시 알림 서비스로 앱 서버 ↔︎ 클라이언트 앱 간 메시지를 안정적이고 효율적으로 전송할 수 있도록 돕는다.
  • Flutter에서는 firebase_messaging 패키지를 사용하여 FCM을 손쉽게 연동하고 서버에서 전송한 알림을 모바일 앱으로 수신할 수 있다.

⚙️ 초기 설정 과정

기본 세팅

  • Firebase 프로젝트 생성 및 앱 등록
  • flutter pub add firebase_messaging 설치

플랫폼별 설정

  • Androidgoogle-services.jsonandroid / app 폴더에 추가
  • iOSGoogleService-Info.plistRunner 폴더에 추가

iOS 추가 설정

  • Apple Developer 계정에서 APNs 인증서 생성 후 Firebase 콘솔에 업로드해야 푸시가 정상 작동한다.

💌 메시지 유형

Notification Message

  • OS가 자동으로 알림 표시 (백그라운드에서 자동 처리)
  • 일반적인 알림용

Data Message

  • 앱이 직접 데이터를 처리 (개발자 로직 필요)
  • Foreground 시 커스텀 동작

Mixed Message

  • 알림 + 데이터 모두 포함
  • 백그라운드는 알림, 포그라운드에서는 로직을 처리한다.

🔄 앱 상태별 메시지 처리

ForegroundFirebaseMessaging.onMessage
BackgroundFirebaseMessaging.onBackgroundMessage
Terminated (종료 상태)FirebaseMessaging.getInitialMessage

🪪 FCM 토큰 관리

  • 앱이 처음 실행되면 FCM 서버로부터 디바이스 고유 토큰이 발급된다.
  • getToken()으로 획득 후 서버 DB에 저장되므로 특정 사용자 타겟팅이 가능하다.
  • onTokenRefresh 스트림을 사용하여 토큰 갱신 시점 모니터링이 가능하다.
  • 토큰은 로그인 / 디바이스 변경 시 갱신되므로 주기적인 업데이트 로직이 필요하다.

🔔 포그라운드 알림 처리

  • 앱이 실행 중일 때는 알림이 자동 표시되지 않는다.
  • flutter_local_notifications 패키지와 함께 사용하여 로컬 알림으로 직접 표시해야 한다.
  • 알림 채널 설정으로 소리, 진동, 중요도 등을 커스터마이징 가능하다.

🚪 알림 클릭 동작

  • 알림을 탭 하면 FirebaseMessaging.onMessageOpenedApp을 통해 감지한다.
  • 딥링크를 활용하면 알림 클릭 시 특정 화면으로 바로 이동 가능하다.

📢 토픽 기반 푸시 (Topic Messaging)

  • 사용자 그룹별로 알림을 보내고 싶을 때 사용한다.
  • 구독은 subscribeToTopic('news')이다.
  • 구독 취소는 unsubscribeFromTopic('news')이다.

⚡️ 메시지 전송 우선순위 & 속성

우선순위 설정이 가능하다.

  • 긴급 알림은 높은 우선순위 일반 알림은 낮은 우선순위로 두어 배터리 절약에 유리하다.

메시지 페이로드 (payload)

  • 제목, 본문, 이미지, 배지, 사운드 등 포함 가능하다.
  • 플랫폼별로 속성을 다르게 설정할 수 있다.

🧰 서버 전송 방식

  • Firebase Console에서 직접 테스트 가능하다.
  • 실제 서비스에서는 Firebase Admin SDK 또는 FCM REST API를 이용해 백엔드 서버에서 프로그래밍 방식으로 전송한다.

🔒 권한 요청

  • iOS는 requestPermission() 호출이 필수이고 Android 13 이상은 런타임 권한 요청이 필요하다.
  • 권한이 없으면 알림이 수신되지 않는다.

📏 제한 및 주의사항

  • 무료로 무제한 메시지 전송이 가능하다.
  • 단 메시지 전송률, 페이로드 최대 크기 4KB 제한이 있다.
  • 시뮬레이터에서는 정상 작동 안 될 수 있어 실제 디바이스 테스트가 필수이다.

📱 플랫폼별 차이 요약

AndroidiOS
자동 알림 표시백그라운드 자동 표시포그라운드 자동 표시 불가
알림 채널 설정필수 (ID, 중요도 등)불필요
APNs 인증 필요X필요
런타임 권한Android 13 이상만 항상 필요

🧠 코드

// 알림을 탭했을 때 실행할 함수
typedef NotificationTapHandler = void Function(NotificationDeepLink link);

// FCM 토큰을 저장할 함수
typedef TokenSaver = Future<void> Function(String token);

class FirebaseMessagingService {
  TokenSaver? _saveToken;

  // 싱글톤: 앱 전체에서 딱 하나의 객체만 만들어지는 패턴
  //Private 생성자로 외부에서 직접 생성 불가
  FirebaseMessagingService._internal();
  // 앱 시작시 딱 한번만 만들어짐
  static final FirebaseMessagingService _instance =
      FirebaseMessagingService._internal();
  // 매번 새로 안 만들고 기존 거 돌려줌
  factory FirebaseMessagingService.instance() => _instance;
   // Firebase는 알림을 받기만 하고 실제로 화면에 표시하는건 localNotification임
  LocalNotificationsService? _localNotificationsService;
  NotificationTapHandler? _onTap;

  // 초기 설정
  Future<void> init({
    required LocalNotificationsService localNotificationService,
    NotificationTapHandler? onNotificationTap,
    TokenSaver? onTokenUpdated,
  }) async {
    // 로컬 알림 서비스 연결
  // Firebase는 알림을 받기만 함
    _localNotificationsService = localNotificationService; // 실제로 화면에 띄우는 건 LocalNotificationsService가 함
    _onTap = onNotificationTap;
    _saveToken = onTokenUpdated;

    // FCM 토큰 처리
    // FCM 토큰은 이 기기의 고유 주소
    await _handlePushNotificationToken(); // 서버가 이 주소로 알림 보냄

    // 알림 권한 요청
    await _requestPermission();

    // 앱이 실행 중이고 화면에 보이는 상태
    FirebaseMessaging.onMessage.listen(_onForegroundMessage);

    // 알림을 탭 했을 때 앱 실행 페이지 이동
    FirebaseMessaging.onMessageOpenedApp.listen(_onMessageOpenedApp);

    // 앱이 완전히 꺼져있을 때
    final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
    if (initialMessage != null) {
      _onMessageOpenedApp(initialMessage);
    }
  }

  // FCM 토큰 관리
  Future<void> _handlePushNotificationToken() async {
    // 토큰 가져오기
    final token = await FirebaseMessaging.instance.getToken();
    if (token != null && _saveToken != null) {
      await _saveToken!(token); // 첫 토큰 서버에 저장
    }

    // 토큰 갱신 감지 => 앱 재설치, 앱 데이터 삭제, 기기 변경 등
    FirebaseMessaging.instance.onTokenRefresh.listen(
      (fcmToken) async {
        if (_saveToken != null) {
          await _saveToken!(fcmToken); // 토큰 변경 시 업데이트
        }
      },
    );
  }

  // 권한 요청
  Future<void> _requestPermission() async {
    final result = await FirebaseMessaging.instance.requestPermission(
      alert: true,
      announcement: true,
      badge: true,
      carPlay: true,
      criticalAlert: true,
      provisional: true,
      sound: true,
      providesAppNotificationSettings: true,
    );
  }

  // 앱 실행 중 알림 처리
  // 서버에서 알림 도착
  void _onForegroundMessage(RemoteMessage message) {
    // 우선 데이터 페이로드를 JSON으로 직렬화해 payload에 넣음
    final payloadJson = jsonEncode(message.data); // 서버가 보낸 추가 정보

    // 가능하면 클라이언트 로케일로 문구 구성
    // 실패 시 서버 제공 타이틀/바디 사용 
    String? title = message.notification?.title; // 새 댓글
    String? body = message.notification?.body; // ..님이 댓글을 남겼습니다

  // 다국어 처리
    final composed = _composeLocalizedContent(message); // 사용자 언어 설정에 맞춰 메시지 변환
    title = composed.$1 ?? title;
    body = composed.$2 ?? body;

    if (body != null || title != null) {
  // 화면에 표시
      _localNotificationsService?.showNotification(
        title,
        body,
        payloadJson,
      );
    }
  }

  // 알림 탭 시 화면 이동
  void _onMessageOpenedApp(RemoteMessage message) {
    final link = _mapMessageToDeepLink(message);
    if (link != null && _onTap != null) {
      _onTap!(link);
    }
  }

  NotificationDeepLink? mapMessageToDeepLink(RemoteMessage message) {
    return _mapMessageToDeepLink(message);
  }

  // 데이터 페이로드 기반으로 로컬라이즈된 타이틀/바디를 구성합니다.
  // 다국어 알림 메시지 생성
  (String?, String?) _composeLocalizedContent(RemoteMessage message) {
    try {
      final data = message.data;
      // 사용자 언어 설정
      final locale = PlatformDispatcher.instance.locale;
      // 번역 객체
      final l10n = lookupAppLocalizations(locale);

      final type = _parseType(data['type']);
      if (type == null) return (null, null);

      final actorName = (data['actorName'] as String?) ?? l10n.unknownActor;

      String? title;
      String? body;

      switch (type) {
        case NotificationType.friendRequest:
          title = l10n.notificationsTitle;
          body = l10n.notifFriendRequest(actorName);
          break;
        case NotificationType.friendAccepted:
          title = l10n.notificationsTitle;
          body = l10n.notifFriendAccepted(actorName);
          break;
        case NotificationType.postUpdate:
          title = l10n.newPost;
          body = l10n.notifPostUpdate(actorName);
          break;
        case NotificationType.commentOnMyPost:
          title = l10n.notificationsTitle;
          body = l10n.notifCommentOnMyPost(actorName);
          break;
        case NotificationType.likeOnMyPost:
          title = l10n.notificationsTitle;
          body = l10n.notifLikeOnMyPost(actorName);
          break;
        case NotificationType.replyOnMyComment:
          title = l10n.notificationsTitle;
          body = l10n.notifReplyOnMyComment(actorName);
          break;
        case NotificationType.likeOnMyComment:
          title = l10n.notificationsTitle;
          body = l10n.notifLikeOnMyComment(actorName);
          break;
        case NotificationType.likeOnMyReply:
          title = l10n.notificationsTitle;
          body = l10n.notifLikeOnMyReply(actorName);
          break;
        case NotificationType.guestbookOnMyHome:
          title = l10n.notificationsTitle;
          body = l10n.notifGuestbookOnMyHome(actorName);
          break;
        case NotificationType.likeOnMyGuestbook:
          title = l10n.notificationsTitle;
          body = l10n.notifLikeOnMyGuestbook(actorName);
          break;
      }

      return (title, body);
    } catch (_) {
      return (null, null);
    }
  }

  // 딥링크 매핑
  NotificationDeepLink? _mapMessageToDeepLink(RemoteMessage message) {
    final data = message.data;
    if (data.isEmpty) return null;

    // 타입 파싱
    final typeString = data['type'];
    final type = _parseType(typeString);
    if (type == null) return null;

    // 타입별 분기 처리
    switch (type) {
      case NotificationType.commentOnMyPost:
        final entityId = data['entityId'];
        if (entityId == null) return null;
        return NotificationDeepLink.postDetail(entityId);

      case NotificationType.friendAccepted:
        final friendId = data['actorId'];
        if (friendId == null) return null;
        return NotificationDeepLink.friendHome(friendId);
      case NotificationType.friendRequest:
        final friendId = data['actorId'];
        if (friendId == null) return null;
        return NotificationDeepLink.friendHome(friendId);

      case NotificationType.likeOnMyComment:
        final postId = data['postId'];
        if (postId == null) return null;
        return NotificationDeepLink.postDetail(postId);

      case NotificationType.likeOnMyPost:
        final entityId = data['entityId'];
        if (entityId == null) return null;
        return NotificationDeepLink.postDetail(entityId);

      case NotificationType.likeOnMyReply:
        final postId = data['postId'];
        if (postId == null) return null;
        return NotificationDeepLink.postDetail(postId);

      case NotificationType.postUpdate:
        final entityId = data['entityId'];
        if (entityId == null) return null;
        return NotificationDeepLink.postDetail(entityId);

      case NotificationType.replyOnMyComment:
        final postId = data['postId'];
        if (postId == null) return null;
        return NotificationDeepLink.postDetail(postId);

      case NotificationType.guestbookOnMyHome:
        final ownerId = data['ownerId'];
        if (ownerId == null) return null;
        return NotificationDeepLink.guestBook(ownerId);
      default:
        return null;
    }
  }

  // 타입 문자열 파싱
  NotificationType? _parseType(String? value) {
    if (value == null) return null;
    try {
      return NotificationType.values.firstWhere(
        (type) => type.name == value,
      );
    } catch (e) {
      return null;
    }
  }
}

// 알림 탭 시 어떤 화면으로 이동할지 정보를 담는 클래스
class NotificationDeepLink {
  const NotificationDeepLink._({this.route, this.arguments});
  final String? route;
  final Map<String, dynamic>? arguments;

  factory NotificationDeepLink.postDetail(String entityId) {
    // 런타임이 아닌 컴파일 시점에 객체 생성
    return NotificationDeepLink._(
      route: '${RoutePaths.posts}/$entityId',
      arguments: {'entityId': entityId},
    );
  }

  factory NotificationDeepLink.friendHome(String friendId) {
    return NotificationDeepLink._(
      route: '/friends/$friendId/home',
      arguments: {'friendId': friendId},
    );
  }

  factory NotificationDeepLink.guestBook(String ownerId) {
    final route = RoutePaths.guestBook.replaceAll(':ownerId', ownerId);
    return NotificationDeepLink._(
      route: route,
      arguments: {'ownerId': ownerId},
    );
  }
}

0개의 댓글