Push 알림 — FCM과 flutter_local_notifications

푸시 알림은 사용자 재방문(retention)을 높이는 핵심 기능입니다. Firebase Cloud Messaging(FCM)으로 서버에서 알림을 보내고, flutter_local_notifications로 로컬 알림을 표시하는 방법을 정리해보겠습니다.


설치

YAML
dependencies:
  firebase_core: ^3.0.0
  firebase_messaging: ^15.0.0
  flutter_local_notifications: ^18.0.0

FCM 초기화

DART
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

class NotificationService {
  final FirebaseMessaging _fcm = FirebaseMessaging.instance;
  final FlutterLocalNotificationsPlugin _localNotifications =
      FlutterLocalNotificationsPlugin();

  Future<void> initialize() async {
    // 1. 권한 요청
    await _requestPermission();

    // 2. 로컬 알림 초기화
    await _initLocalNotifications();

    // 3. FCM 토큰 가져오기
    final token = await _fcm.getToken();
    print('FCM Token: $token');
    // 서버에 토큰 전송

    // 4. 토큰 갱신 감시
    _fcm.onTokenRefresh.listen(_sendTokenToServer);

    // 5. 포그라운드 메시지 처리
    FirebaseMessaging.onMessage.listen(_handleForegroundMessage);

    // 6. 백그라운드에서 알림 클릭 시
    FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageOpenedApp);

    // 7. 앱이 종료된 상태에서 알림 클릭으로 열린 경우
    final initialMessage = await _fcm.getInitialMessage();
    if (initialMessage != null) {
      _handleMessageOpenedApp(initialMessage);
    }
  }

  Future<void> _requestPermission() async {
    final settings = await _fcm.requestPermission(
      alert: true,
      badge: true,
      sound: true,
      provisional: false,
    );

    if (settings.authorizationStatus == AuthorizationStatus.authorized) {
      print('알림 권한 허용됨');
    } else {
      print('알림 권한 거부됨');
    }
  }

  Future<void> _initLocalNotifications() async {
    const androidSettings = AndroidInitializationSettings(
      '@mipmap/ic_launcher',
    );
    const iosSettings = DarwinInitializationSettings(
      requestAlertPermission: false,
      requestBadgePermission: false,
      requestSoundPermission: false,
    );

    await _localNotifications.initialize(
      const InitializationSettings(
        android: androidSettings,
        iOS: iosSettings,
      ),
      onDidReceiveNotificationResponse: (response) {
        // 로컬 알림 클릭 시 처리
        _handleNotificationTap(response.payload);
      },
    );
  }

  void _handleForegroundMessage(RemoteMessage message) {
    // 포그라운드에서는 FCM이 자동으로 알림을 표시하지 않음
    // 직접 로컬 알림으로 표시
    _showLocalNotification(
      title: message.notification?.title ?? '',
      body: message.notification?.body ?? '',
      payload: message.data.toString(),
    );
  }

  void _handleMessageOpenedApp(RemoteMessage message) {
    // 알림 클릭으로 앱이 열린 경우
    final data = message.data;
    if (data.containsKey('screen')) {
      // 해당 화면으로 이동
      navigatorKey.currentState?.pushNamed(data['screen']!);
    }
  }

  Future<void> _showLocalNotification({
    required String title,
    required String body,
    String? payload,
  }) async {
    const androidDetails = AndroidNotificationDetails(
      'default_channel',
      '기본 알림',
      channelDescription: '기본 알림 채널',
      importance: Importance.high,
      priority: Priority.high,
    );

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

    await _localNotifications.show(
      DateTime.now().millisecondsSinceEpoch ~/ 1000,
      title,
      body,
      const NotificationDetails(
        android: androidDetails,
        iOS: iosDetails,
      ),
      payload: payload,
    );
  }

  void _sendTokenToServer(String token) {
    // API 호출로 서버에 토큰 전송
    print('새 토큰: $token');
  }

  void _handleNotificationTap(String? payload) {
    if (payload != null) {
      // 페이로드에 따라 화면 이동
      print('알림 탭: $payload');
    }
  }
}

백그라운드 핸들러

DART
// main.dart — 최상위 함수로 선언
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(
  RemoteMessage message,
) async {
  await Firebase.initializeApp();
  print('백그라운드 메시지: ${message.messageId}');
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

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

  runApp(const MyApp());
}

알림 채널 (Android)

Android 8.0+에서는 알림 채널이 필수입니다.

DART
// 채널 생성
const channel = AndroidNotificationChannel(
  'high_importance',
  '중요 알림',
  description: '중요한 알림을 위한 채널',
  importance: Importance.high,
);

await _localNotifications
    .resolvePlatformSpecificImplementation<
        AndroidFlutterLocalNotificationsPlugin>()
    ?.createNotificationChannel(channel);

토픽 구독

DART
// 특정 토픽 구독
await FirebaseMessaging.instance.subscribeToTopic('news');

// 구독 해제
await FirebaseMessaging.instance.unsubscribeFromTopic('news');

예약 알림 (로컬)

DART
import 'package:timezone/timezone.dart' as tz;

Future<void> scheduleNotification({
  required DateTime scheduledTime,
  required String title,
  required String body,
}) async {
  await _localNotifications.zonedSchedule(
    0,
    title,
    body,
    tz.TZDateTime.from(scheduledTime, tz.local),
    const NotificationDetails(
      android: AndroidNotificationDetails(
        'scheduled_channel',
        '예약 알림',
      ),
    ),
    androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
    matchDateTimeComponents: DateTimeComponents.time, // 매일 반복
  );
}

알림 상태별 동작

앱 상태FCM 알림 표시데이터 메시지
포그라운드자동 표시 안 됨onMessage
백그라운드시스템이 표시onBackgroundMessage
종료됨시스템이 표시getInitialMessage

면접 포인트: 포그라운드에서는 FCM이 알림을 자동으로 표시하지 않습니다. flutter_local_notifications를 사용해 직접 표시해야 합니다.


정리

  • FCM으로 서버 푸시 알림, flutter_local_notifications로 로컬 알림을 표시합니다
  • 포그라운드에서는 FCM이 알림을 자동 표시하지 않으므로 로컬 알림을 직접 표시해야 합니다
  • 백그라운드 핸들러는 최상위 함수로 선언해야 합니다
  • Android 8.0+에서는 알림 채널 생성이 필수입니다
  • FCM 토큰을 서버에 저장하고, 갱신 시에도 업데이트해야 합니다
댓글 로딩 중...