알림 시스템 아키텍처
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') | 백그라운드 핸들러 최상위 함수 필수 지정 |