하이어코딩 RSS 태그 관리 글쓰기 방명록 mahiru
2026-03-16 10:16:47
728x90
반응형

알림 시스템 아키텍처

MICEMore의 알림 시스템은 3가지 계층으로 구성됩니다. Firebase Cloud Messaging(FCM)으로 서버 푸시 알림을 처리하고, Flutter Local Notifications로 포그라운드 알림을 표시하며, Firestore 기반 인앱 알림으로 알림 이력을 관리합니다.

계층 기술 역할 동작 조건
서버 푸시 Firebase Cloud Messaging 백그라운드/종료 상태 알림 앱 비활성 시
로컬 알림 flutter_local_notifications 포그라운드 알림 표시 앱 활성 시
인앱 알림 Firestore + Stream 알림 이력 관리/읽음 처리 항상

알림 흐름도

서버 이벤트 발생 (공지, 퀴즈, 추첨 등)
    │
    ▼
Cloud Function → FCM 메시지 전송
    │
    ├── 앱 종료/백그라운드 → 시스템 알림 트레이 표시
    │       └── 알림 탭 → getInitialMessage() / onMessageOpenedApp
    │                       └── 딥링크 라우팅
    │
    └── 앱 포그라운드 → onMessage 리스너
            └── flutter_local_notifications로 로컬 알림 표시
                    └── 알림 탭 → onDidReceiveNotificationResponse
                                    └── 딥링크 라우팅

FCMProvider: 알림 초기화와 라이프사이클 관리

FCMProvider는 알림 권한 요청부터 토큰 관리, 메시지 수신, 딥링크 처리까지 전체 알림 라이프사이클을 담당합니다.

초기화 프로세스

Future<void> initialize(String userId) async {
  // 1. 로컬 알림 플러그인 초기화
  await _initializeLocalNotifications();

  // 2. 알림 권한 요청
  NotificationSettings settings = await _messaging.requestPermission(
    alert: true,
    badge: true,
    sound: true,
  );

  if (settings.authorizationStatus == AuthorizationStatus.authorized) {
    // 3. FCM 토큰 발급 + Firestore 저장
    _fcmToken = await _messaging.getToken();
    if (_fcmToken != null) {
      await _saveFCMToken(userId, _fcmToken!);
    }

    // 4. 토큰 갱신 리스너 (토큰 만료 시 자동 갱신)
    _tokenRefreshSubscription = _messaging.onTokenRefresh.listen((newToken) {
      _fcmToken = newToken;
      _saveFCMToken(userId, newToken);
      notifyListeners();
    });

    // 5. 포그라운드 메시지 리스너
    _foregroundMessageSubscription = 
        FirebaseMessaging.onMessage.listen(_handleForegroundMessage);

    // 6. 백그라운드→앱 열림 메시지 리스너
    _messageOpenedAppSubscription = 
        FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageOpenedApp);

    // 7. 앱 종료 상태에서 알림으로 열렸을 때
    RemoteMessage? initialMessage = await _messaging.getInitialMessage();
    if (initialMessage != null) {
      _handleMessageOpenedApp(initialMessage);
    }
  }
}

핵심 설계 포인트

1. 3가지 메시지 수신 경로

  • onMessage: 앱 포그라운드 상태 — 로컬 알림으로 직접 표시
  • onMessageOpenedApp: 백그라운드에서 알림 탭 — 딥링크 처리
  • getInitialMessage: 앱 종료 상태에서 알림 탭 — 초기 딥링크 처리

2. FCM 토큰 자동 갱신

// Firestore에 FCM 토큰 저장
Future<void> _saveFCMToken(String userId, String token) async {
  await _firestore.collection('users').doc(userId).update({
    'fcmToken': token,
    'fcmTokenUpdatedAt': Timestamp.now(),
  });
}

토큰이 갱신될 때마다 Firestore의 사용자 문서에 자동 저장합니다. Cloud Function에서 알림을 보낼 때 이 토큰을 조회하여 사용합니다.

포그라운드 알림: 로컬 알림 표시

앱이 포그라운드 상태일 때 FCM 메시지를 수신하면, 시스템 알림 트레이에 직접 표시되지 않습니다. 이를 해결하기 위해 flutter_local_notifications를 사용합니다.

로컬 알림 초기화: 플랫폼별 설정

Future<void> _initializeLocalNotifications() async {
  // Android 설정
  const AndroidInitializationSettings androidSettings =
      AndroidInitializationSettings('@mipmap/ic_launcher');

  // iOS 설정
  const DarwinInitializationSettings iOSSettings =
      DarwinInitializationSettings(
    requestAlertPermission: true,
    requestBadgePermission: true,
    requestSoundPermission: true,
  );

  const InitializationSettings settings = InitializationSettings(
    android: androidSettings,
    iOS: iOSSettings,
  );

  await _localNotifications.initialize(
    settings,
    onDidReceiveNotificationResponse: _onNotificationTapped,
  );
}

포그라운드 메시지 핸들러

Future<void> _handleForegroundMessage(RemoteMessage message) async {
  final notification = message.notification;
  if (notification != null) {
    await _showLocalNotification(
      title: notification.title ?? '알림',
      body: notification.body ?? '',
      payload: message.data['eventId'] ?? '',
    );
  }
  notifyListeners();
}

Future<void> _showLocalNotification({
  required String title,
  required String body,
  String? payload,
}) async {
  const AndroidNotificationDetails androidDetails =
      AndroidNotificationDetails(
    'micemore_channel',           // 채널 ID
    'MICEMore Notifications',     // 채널 이름
    channelDescription: 'MICE 행사 알림',
    importance: Importance.high,
    priority: Priority.high,
    showWhen: true,
    icon: '@mipmap/ic_launcher',
  );

  const DarwinNotificationDetails iOSDetails = DarwinNotificationDetails(
    presentAlert: true,
    presentBadge: true,
    presentSound: true,
  );

  const NotificationDetails details = NotificationDetails(
    android: androidDetails,
    iOS: iOSDetails,
  );

  await _localNotifications.show(
    DateTime.now().millisecondsSinceEpoch % 100000, // 고유 ID 생성
    title,
    body,
    details,
    payload: payload,
  );
}

알림 고유 ID 생성: millisecondsSinceEpoch % 100000으로 간단하면서도 충돌 가능성이 낮은 고유 ID를 생성합니다. Android에서는 같은 ID의 알림은 교체되므로 이 방식이 효과적입니다.

딥링크: 알림 탭 시 특정 화면으로 이동

알림을 탭하면 관련 이벤트의 특정 화면으로 직접 이동합니다.

딥링크 데이터 구조

void _handleMessageOpenedApp(RemoteMessage message) {
  final data = message.data;
  if (data.containsKey('eventId')) {
    _pendingDeepLink = {
      'type': data['type'] ?? 'event',     // 'announcement', 'qna', 'lottery' 등
      'eventId': data['eventId'],
      'screen': data['screen'],            // 'detail', 'qna', 'announcements' 등
    };
    notifyListeners();
  }
}

// 로컬 알림 탭 핸들러
void _onNotificationTapped(NotificationResponse response) {
  if (response.payload != null && response.payload!.isNotEmpty) {
    _pendingDeepLink = {
      'type': 'notification',
      'eventId': response.payload!,
    };
    notifyListeners();
  }
}

pendingDeepLink는 UI 레이어에서 감시하여 라우팅을 실행합니다. 처리 완료 후 clearPendingDeepLink()를 호출하여 중복 라우팅을 방지합니다.

백그라운드 메시지 핸들러

FCM 백그라운드 핸들러는 최상위 함수여야 합니다. 클래스 메서드로는 등록할 수 없습니다.

// main.dart - 최상위 함수
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // Firebase 재초기화 (백그라운드 Isolate에서 필요)
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // 데이터 메시지만 처리
  // (notification 메시지는 시스템이 자동으로 트레이에 표시)
  if (message.data.isNotEmpty) {
    final type = message.data['type'] as String?;
    final eventId = message.data['eventId'] as String?;

    // Crashlytics에 비치명적 이벤트로 기록
    if (!kDebugMode && !kIsWeb) {
      FirebaseCrashlytics.instance.log(
        '백그라운드 FCM 수신: type=$type, eventId=$eventId',
      );
    }
  }
}

// main()에서 등록
if (!kIsWeb) {
  FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
}

@pragma('vm:entry-point') 어노테이션은 Dart 컴파일러에게 이 함수가 외부에서 호출될 수 있음을 알려, tree-shaking으로 제거되는 것을 방지합니다. 또한 백그라운드 핸들러는 별도 Isolate에서 실행되므로, Firebase를 다시 초기화해야 합니다.

인앱 알림: Firestore 기반 알림 이력 관리

FCM 푸시 알림과 별도로, Firestore에 알림 이력을 저장하여 앱 내에서 알림 목록을 확인하고 읽음/삭제 처리를 할 수 있습니다.

NotificationModel: 5가지 알림 타입

class NotificationModel {
  final String id;
  final String userId;
  final String title;
  final String message;
  final String type;     // 'event', 'checkin', 'qna', 'announcement', 'lottery'
  final String? eventId;
  final bool isRead;
  final DateTime createdAt;
}

NotificationProvider: 실시간 + Batch 처리

class NotificationProvider with ChangeNotifier {
  List<NotificationModel> _notifications = [];
  StreamSubscription<QuerySnapshot>? _notificationsSubscription;

  int get unreadCount => _notifications.where((n) => !n.isRead).length;

  // 실시간 알림 리스너
  void startListening(String userId) {
    _notificationsSubscription?.cancel();
    _notificationsSubscription = _firestore
        .collection('notifications')
        .where('userId', isEqualTo: userId)
        .orderBy('createdAt', descending: true)
        .limit(50)
        .snapshots()
        .listen((snapshot) {
      _notifications = snapshot.docs
          .map((doc) => NotificationModel.fromFirestore(doc))
          .toList();
      notifyListeners();
    });
  }

  // 모든 알림 읽음 처리 (Batch Write)
  Future<void> markAllAsRead() async {
    final batch = _firestore.batch();
    final unreadNotifications = _notifications.where((n) => !n.isRead);

    for (final notification in unreadNotifications) {
      batch.update(
        _firestore.collection('notifications').doc(notification.id),
        {'isRead': true},
      );
    }
    await batch.commit();

    // 로컬 상태 즉시 업데이트 (Firestore 응답 대기하지 않음)
    _notifications = _notifications
        .map((n) => n.copyWith(isRead: true))
        .toList();
    notifyListeners();
  }
}

Optimistic Update 패턴: markAllAsRead()에서 Firestore batch.commit() 후 로컬 상태를 즉시 업데이트합니다. Stream 리스너가 Firestore 변경을 감지하기 전에 UI가 먼저 반영되어 사용자 경험이 향상됩니다.

토픽 구독: 이벤트별 알림 분리

// 특정 이벤트 토픽 구독
Future<void> subscribeToTopic(String topic) async {
  await _messaging.subscribeToTopic(topic);
  // 예: subscribeToTopic('event_abc123')
}

// 토픽 구독 해제
Future<void> unsubscribeFromTopic(String topic) async {
  await _messaging.unsubscribeFromTopic(topic);
}

참가자가 이벤트에 참여하면 해당 이벤트 토픽을 구독하고, 이벤트 종료 시 구독을 해제합니다. 이를 통해 관련 이벤트의 공지, 퀴즈, 추첨 알림만 선별적으로 수신합니다.

Stream 구독 관리와 메모리 안전

FCMProvider는 3개의 StreamSubscription을 관리합니다:

StreamSubscription<String>? _tokenRefreshSubscription;
StreamSubscription<RemoteMessage>? _foregroundMessageSubscription;
StreamSubscription<RemoteMessage>? _messageOpenedAppSubscription;

@override
Future<void> dispose() async {
  _tokenRefreshSubscription?.cancel();
  _foregroundMessageSubscription?.cancel();
  _messageOpenedAppSubscription?.cancel();
  _fcmToken = null;
  _isInitialized = false;
  super.dispose();
}

dispose()에서 모든 구독을 해제하여 메모리 누수를 방지합니다. 새 구독 시작 전에 기존 구독을 cancel()하는 방어적 패턴을 적용합니다.

마치며

MICEMore의 알림 시스템은 FCM + 로컬 알림 + Firestore 인앱 알림의 3계층 구조로 모든 앱 상태에서 알림을 안정적으로 전달합니다. 딥링크를 통해 알림에서 직접 관련 화면으로 이동하고, Batch Write로 대량 읽음 처리를 효율적으로 수행합니다. 다음 포스트에서는 성능 최적화 기법을 분석하겠습니다.


초보자를 위한 상세 가이드: 알림 시스템 완전 정복

알림 시스템이란? - 일상 속 비유로 이해하기

알림 시스템은 카카오톡 메시지와 비슷합니다. 누군가 메시지를 보내면 핸드폰에 "띵!" 소리와 함께 알림이 뜨죠? 앱에서도 마찬가지입니다. 사용자에게 중요한 정보를 전달하기 위해 알림을 보내는 것입니다.

일상 속 비유 앱 알림 시스템 기술 용어
카카오톡 메시지 받기 서버에서 앱으로 메시지 전송 FCM Push Notification
알람 시계 설정 특정 시간에 알림 표시 Local Notification (Scheduled)
메시지 클릭해서 대화방 들어가기 알림 클릭해서 특정 화면 이동 Deep Linking
카톡 단체방에 공지 보내기 여러 사용자에게 동시 알림 Batch Notification

MICEMore의 3계층 알림 아키텍처

MICEMore는 알림을 3개의 계층으로 나누어 관리합니다. 이것은 마치 우편 시스템과 같습니다:

계층 역할 비유 파일
1. FCM Push (원격 알림) 서버 → 앱으로 메시지 전송 우체국에서 편지 배달 fcm_provider.dart
2. Local Notification (로컬 알림) 앱 내에서 직접 알림 생성 집안 알람시계 local_notification_service.dart
3. In-App Notification (앱 내 알림) 알림 목록 화면에서 읽기/관리 우편함에 보관된 편지들 notification_provider.dart

1계층: FCM(Firebase Cloud Messaging) 푸시 알림 상세 분석

FCM이란 무엇인가?

FCM은 Google이 제공하는 무료 메시지 전송 서비스입니다. 쉽게 말해 "앱 전용 카카오톡 서버"라고 생각하면 됩니다.

FCM 동작 과정 (택배 배송 비유):

1. 앱 설치 → FCM 토큰 발급 (= 집 주소 등록)
2. 서버에서 메시지 생성 (= 택배 발송)
3. FCM 서버가 중간 배달 (= 택배 허브)
4. 사용자 기기에 알림 표시 (= 택배 수령)
5. 알림 클릭으로 앱 내 이동 (= 택배 개봉)

FCM Provider 코드 완전 해부

FCMProvider는 ChangeNotifier를 상속받아 상태 변화를 UI에 자동으로 알려줍니다.

class FCMProvider with ChangeNotifier {
  // 1. Firebase 서비스 인스턴스
  final FirebaseMessaging _messaging = FirebaseMessaging.instance;
  final FirebaseFirestore _firestore = FirebaseFirestore.instance;
  final FlutterLocalNotificationsPlugin _localNotifications = 
      FlutterLocalNotificationsPlugin();

  // 2. 상태 변수들
  String? _fcmToken;        // FCM 토큰 (= 기기 고유 주소)
  bool _isInitialized = false;  // 초기화 완료 여부

  // 3. Stream 구독 관리 (Cancel-Before-Subscribe 패턴)
  StreamSubscription<String>? _tokenRefreshSubscription;
  StreamSubscription<RemoteMessage>? _foregroundMessageSubscription;
  StreamSubscription<RemoteMessage>? _messageOpenedAppSubscription;

  // 4. 딥 링킹 데이터
  Map<String, dynamic>? _pendingDeepLink;
}

Cancel-Before-Subscribe 패턴이란?

이 패턴은 "물 틀기 전에 수도꼭지 잠그기"와 같습니다. Stream을 새로 구독하기 전에 반드시 이전 구독을 취소하는 것입니다.

// 왜 이렇게 하나요?
// 만약 cancel 없이 listen을 계속 하면...
// → 구독이 쌓여서 알림이 2번, 3번 중복으로 옵니다!

// Cancel-Before-Subscribe 패턴
_tokenRefreshSubscription?.cancel();  // 1. 기존 구독 해제 (없으면 skip)
_tokenRefreshSubscription = _messaging
    .onTokenRefresh
    .listen((newToken) {              // 2. 새 구독 시작
      _fcmToken = newToken;
      _saveFCMToken(userId, newToken);
      notifyListeners();
    });
패턴 하지 않으면? 비유
Cancel-Before-Subscribe 알림 중복 수신, 메모리 누수 TV 채널 안 끄고 새 채널 틀면 소리 겹침
dispose()에서 cancel 화면 벗어나도 계속 동작 방 나갔는데 불 안 끔

FCM 초기화 과정 단계별 설명

Future<void> initialize(String userId) async {
  // Step 1: 로컬 알림 초기화 (알림 표시 도구 준비)
  await _initializeLocalNotifications();

  // Step 2: 알림 권한 요청 (사용자에게 "알림 보내도 될까요?" 물어봄)
  NotificationSettings settings = await _messaging.requestPermission(
    alert: true,   // 팝업 알림
    badge: true,   // 앱 아이콘 숫자 표시
    sound: true,   // 알림 소리
  );

  // Step 3: 권한 승인되었을 때만 진행
  if (settings.authorizationStatus == AuthorizationStatus.authorized) {
    // Step 4: FCM 토큰 발급 (= 이 기기의 고유 주소 받기)
    _fcmToken = await _messaging.getToken();

    // Step 5: Firestore에 토큰 저장 (서버가 알 수 있게)
    if (_fcmToken != null) {
      await _saveFCMToken(userId, _fcmToken!);
    }

    // Step 6: 3개의 리스너 등록
    // 6-1. 토큰 갱신 리스너 (주소 바뀌면 서버에 알리기)
    // 6-2. 포그라운드 메시지 리스너 (앱 사용 중 알림 받기)
    // 6-3. 백그라운드 앱 열기 리스너 (알림 클릭으로 앱 열기)
  }
}

2계층: Local Notification (로컬 알림) 상세 분석

로컬 알림이란?

로컬 알림은 서버 없이 앱 자체에서 생성하는 알림입니다. 인터넷 연결 없이도 동작합니다. 마치 핸드폰에 설정한 알람처럼, 앱이 직접 알림을 띄워줍니다.

구분 FCM 푸시 알림 로컬 알림
알림 생성 위치 서버(Cloud Functions) 앱 내부
인터넷 필요 여부 필요 불필요
사용 시나리오 다른 사용자 행동 알림 부스 근접, 일정 리마인더
비유 택배 배달 집안 알람시계

Singleton 패턴으로 구현된 LocalNotificationService

Singleton(싱글톤)이란 "앱 전체에서 딱 하나만 존재하는 객체"를 만드는 패턴입니다. 알림 서비스가 여러 개 생기면 알림이 중복되거나 충돌할 수 있기 때문입니다.

class LocalNotificationService {
  // 1. 유일한 인스턴스를 저장할 변수 (static final)
  static final LocalNotificationService _instance =
      LocalNotificationService._internal();

  // 2. factory 생성자: new로 만들어도 항상 같은 인스턴스 반환
  factory LocalNotificationService() {
    return _instance;  // 항상 동일한 객체!
  }

  // 3. private 생성자: 외부에서 직접 생성 불가
  LocalNotificationService._internal();
}

// 사용 예시:
final service1 = LocalNotificationService();
final service2 = LocalNotificationService();
// service1 == service2  → true! (같은 객체)

Android 알림 채널(Channel) 시스템

Android 8.0(Oreo)부터 알림을 채널별로 분류해야 합니다. 이것은 마치 TV 채널처럼, 사용자가 채널별로 알림을 끄거나 켤 수 있습니다.

채널 ID 이름 용도 중요도
booth_proximity Booth Proximity 부스 근접 알림 Default
token_earned Tokens Earned 토큰 획득 알림 High
congestion_alert Congestion Alerts 혼잡도 알림 Default
game_available Game Available 미니게임 알림 Default
event_reminder Event Reminders 행사 리마인더 High
// 알림 채널 생성 코드 분석
await _flutterLocalNotificationsPlugin
    .resolvePlatformSpecificImplementation<
        AndroidFlutterLocalNotificationsPlugin>()
    ?.createNotificationChannel(
      const AndroidNotificationChannel(
        id: 'token_earned',          // 채널 고유 ID
        name: 'Tokens Earned',       // 사용자에게 보이는 이름
        description: '토큰 획득 알림',  // 채널 설명
        importance: Importance.high,  // 중요도 (소리+진동+팝업)
        enableVibration: true,       // 진동 활성화
        enableLights: true,          // LED 불빛
        showBadge: true,             // 앱 아이콘 배지 표시
      ),
    );

중요도(Importance) 레벨 설명:

레벨 동작 사용 예
Importance.max 소리 + 진동 + 팝업 + 전체화면 긴급 알림
Importance.high 소리 + 진동 + 팝업 토큰 획득, 리마인더
Importance.defaultImportance 소리 + 상태바 부스 근접, 혼잡도
Importance.low 상태바만 (소리 없음) 일반 정보
Importance.min 접힌 상태로만 표시 백그라운드 정보

3계층: In-App Notification (앱 내 알림 관리)

NotificationProvider - 알림 목록 관리자

NotificationProvider는 앱 내 알림함입니다. 카카오톡의 "알림" 탭처럼, 지금까지 받은 모든 알림을 목록으로 보여주고 읽음/삭제 관리를 합니다.

class NotificationProvider with ChangeNotifier {
  List<NotificationModel> _notifications = [];  // 알림 목록
  bool _isLoading = false;                       // 로딩 중인지
  String? _currentUserId;                        // 현재 사용자

  // Stream 구독 (실시간 알림 수신용)
  StreamSubscription<QuerySnapshot>? _notificationsSubscription;

  // 읽지 않은 알림 개수 (앱 아이콘 배지에 표시)
  int get unreadCount => _notifications.where((n) => !n.isRead).length;
}

실시간 알림 수신: Firestore snapshots()

Firestore의 snapshots()데이터가 변경될 때마다 자동으로 알려주는 기능입니다. 마치 CCTV 실시간 모니터링처럼, 새 알림이 추가되면 즉시 감지합니다.

void startListening(String userId) {
  // Cancel-Before-Subscribe 패턴 적용
  _notificationsSubscription?.cancel();  // 기존 구독 해제

  // Firestore 실시간 구독 시작
  _notificationsSubscription = _firestore
      .collection('notifications')       // notifications 컬렉션에서
      .where('userId', isEqualTo: userId) // 내 알림만 필터링
      .orderBy('createdAt', descending: true) // 최신순 정렬
      .limit(50)                          // 최대 50개만
      .snapshots()                        // 실시간 구독!
      .listen((snapshot) {                // 변경될 때마다 실행
    _notifications = snapshot.docs
        .map((doc) => NotificationModel.fromFirestore(doc))
        .toList();
    notifyListeners();  // UI 자동 업데이트
  });
}

Batch Write로 전체 읽음 처리

알림이 30개 있는데 하나씩 "읽음"으로 바꾸면 Firestore에 30번 요청해야 합니다. Batch Write를 사용하면 1번의 요청으로 30개를 한꺼번에 처리할 수 있습니다.

Future<void> markAllAsRead() async {
  final batch = _firestore.batch();  // 배치 시작
  
  // 읽지 않은 알림만 필터링
  final unreadNotifications = _notifications.where((n) => !n.isRead);

  for (final notification in unreadNotifications) {
    final docRef = _firestore
        .collection('notifications')
        .doc(notification.id);
    batch.update(docRef, {'isRead': true});  // 배치에 작업 추가
  }

  await batch.commit();  // 한 번에 실행! (네트워크 요청 1번)

  // 로컬 상태도 업데이트 (UI 즉시 반영)
  _notifications = _notifications
      .map((n) => n.copyWith(isRead: true))
      .toList();
  notifyListeners();
}

딥 링킹(Deep Linking): 알림에서 특정 화면으로 이동

딥 링킹이란?

딥 링킹은 "알림을 클릭하면 앱의 특정 화면으로 바로 이동"하는 기능입니다. 카카오톡에서 메시지 알림을 누르면 해당 채팅방으로 바로 들어가는 것과 같습니다.

// 알림 클릭 시 딥 링크 데이터 설정
void _handleMessageOpenedApp(RemoteMessage message) {
  final data = message.data;  // 알림에 포함된 데이터
  
  if (data.containsKey('eventId')) {
    // 딥 링크 정보를 저장해둠
    _pendingDeepLink = {
      'type': data['type'] ?? 'event',    // 알림 타입
      'eventId': data['eventId'],          // 행사 ID
      'screen': data['screen'],            // 이동할 화면
    };
    notifyListeners();  // UI에 알림 (GoRouter가 감지)
  }
}

// GoRouter에서 딥 링크를 감지하여 화면 이동
// app.dart에서 FCMProvider를 watch하다가
// pendingDeepLink가 생기면 해당 화면으로 navigate

딥 링킹 동작 흐름:

알림 수신 → 사용자가 알림 클릭
  ↓
_handleMessageOpenedApp() 호출
  ↓
_pendingDeepLink 에 데이터 저장
  ↓
notifyListeners() 호출
  ↓
app.dart의 Consumer<FCMProvider>가 감지
  ↓
GoRouter.go('/events/행사ID/detail') 실행
  ↓
해당 행사 상세 화면으로 이동!
  ↓
clearPendingDeepLink() 호출 (처리 완료 표시)

FCMNotificationService: 서버 측 알림 전송 로직

알림 전송 방식 3가지

FCMNotificationService는 알림을 Firestore에 저장하는 역할을 합니다. 실제 FCM 메시지 전송은 Cloud Functions가 Firestore 변경을 감지하여 자동으로 처리합니다.

메서드 대상 사용 예시
sendNotificationToUser() 특정 1명 체크인 완료, 추첨 당첨
sendNotificationToUsers() 여러 명 (Batch) 행사 공지
sendNotificationToEventParticipants() 행사 참가자 전체 행사 시작/종료
// 1명에게 알림 보내기
Future<void> sendNotificationToUser({
  required String userId,    // 받을 사람
  required String title,     // 알림 제목
  required String message,   // 알림 내용
  required String type,      // 알림 종류
  String? eventId,           // 관련 행사 (선택)
}) async {
  // Firestore에 알림 데이터 저장
  // → Cloud Functions가 이걸 감지해서 실제 FCM 전송
  await _firestore.collection('notifications').add({
    'userId': userId,
    'title': title,
    'message': message,
    'type': type,
    'eventId': eventId,
    'isRead': false,          // 처음엔 읽지 않은 상태
    'createdAt': Timestamp.now(),  // 생성 시간
  });
}

Batch 알림: 여러 명에게 한번에 보내기

100명에게 알림을 보낼 때, 하나씩 보내면 100번의 네트워크 요청이 필요합니다. Batch를 사용하면 1번의 요청으로 처리됩니다.

Future<void> sendNotificationToUsers({
  required List<String> userIds,  // 받을 사람들 목록
  required String title,
  required String message,
  required String type,
}) async {
  final batch = _firestore.batch();  // 배치 시작
  final now = Timestamp.now();

  for (final userId in userIds) {
    // 각 사용자별 알림 문서 생성
    final docRef = _firestore.collection('notifications').doc();
    batch.set(docRef, {
      'userId': userId,
      'title': title,
      'message': message,
      'type': type,
      'isRead': false,
      'createdAt': now,  // 모든 알림 동일한 시간
    });
  }

  await batch.commit();  // 한 번에 전송!
  // 주의: Firestore Batch는 최대 500개까지 가능
}

NotificationModel: 알림 데이터 구조

NotificationModel은 알림 한 건의 데이터를 담는 그릇입니다.

class NotificationModel {
  final String id;        // 알림 고유 ID
  final String userId;    // 수신자 ID
  final String title;     // 제목 (예: "체크인 완료")
  final String message;   // 내용 (예: "행사 체크인이 완료되었습니다!")
  final String type;      // 종류
  final String? eventId;  // 관련 행사 ID
  final bool isRead;      // 읽었는지 여부
  final DateTime createdAt; // 생성 시간
}

알림 타입(type) 분류:

타입 설명 발생 시점
event 행사 관련 행사 생성/수정/시작/종료
checkin 체크인 QR 체크인 완료
qna Q&A 질문에 답변 등록
announcement 공지사항 새 공지 등록
lottery 추첨 추첨 당첨

copyWith 패턴 상세 설명

copyWith"원본은 그대로 두고, 일부만 바꾼 복사본을 만드는" 패턴입니다. Dart에서 불변(immutable) 객체를 다룰 때 필수적인 패턴입니다.

// copyWith 사용 예시
final notification = NotificationModel(
  id: 'abc123',
  userId: 'user1',
  title: '체크인 완료',
  message: '행사 체크인이 완료되었습니다!',
  type: 'checkin',
  isRead: false,      // 아직 안 읽음
  createdAt: DateTime.now(),
);

// isRead만 true로 바꾼 새 객체 생성
final readNotification = notification.copyWith(isRead: true);

// 원본은 그대로! (isRead: false)
// 복사본만 변경됨! (isRead: true)

앱 상태별 알림 처리 정리

앱 상태 알림 수신 방법 처리 코드
Foreground (앱 사용 중) onMessage 리스너 _handleForegroundMessage()
Background (앱 최소화) 시스템 알림 자동 표시 firebaseMessagingBackgroundHandler()
Terminated (앱 종료) 시스템 알림 자동 표시 getInitialMessage()
알림 클릭 (Background) onMessageOpenedApp _handleMessageOpenedApp()
알림 클릭 (Terminated) getInitialMessage _handleMessageOpenedApp()

백그라운드 메시지 핸들러: 최상위 함수의 비밀

// @pragma는 Dart 컴파일러에게 특별한 지시를 하는 주석입니다
// 'vm:entry-point'는 "이 함수는 직접 호출되지 않아도 제거하지 마세요"라는 의미
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(
    RemoteMessage message) async {
  // 이 함수는 반드시 최상위 함수여야 합니다!
  // 클래스 안에 넣으면 동작하지 않습니다.
  // 이유: 백그라운드에서는 별도의 Isolate에서 실행되기 때문
  AppLogger.d('백그라운드 메시지 수신');
}

전체 알림 시스템 데이터 흐름도

[Cloud Functions에서 FCM 전송]
        |
        v
[FCM 서버] ----> [사용자 기기]
                     |
        +-----------+-----------+
        |           |           |
   [Foreground] [Background] [Terminated]
        |           |           |
   onMessage    시스템알림    시스템알림
        |           |           |
   로컬알림표시  클릭시       클릭시
        |      onMessageOpened getInitialMsg
        |           |           |
        +-----+-----+-----+----+
              |           |
         [딥 링크]   [알림함 저장]
              |           |
         GoRouter    NotificationProvider
              |           |
         화면 이동    목록 표시/관리

핵심 정리

개념 핵심 포인트
FCM Google 무료 푸시 알림 서비스, 토큰 기반 전송
Local Notification 서버 없이 앱 자체에서 생성, Singleton 패턴
알림 채널 Android 8.0+ 필수, 사용자가 채널별 on/off 가능
Cancel-Before-Subscribe Stream 중복 구독 방지 필수 패턴
딥 링킹 알림 클릭 → 특정 화면 이동, pendingDeepLink로 관리
Batch Write 여러 문서 동시 쓰기 (최대 500개), 네트워크 절약
copyWith 불변 객체의 일부만 변경한 복사본 생성
@pragma('vm:entry-point') 백그라운드 핸들러 최상위 함수 필수 지정
728x90
반응형
이 페이지는 리디주식회사에서 제공한 리디바탕 글꼴이 사용되어 있습니다.