[TIL] Day 70 Flutter에서 iOS 알림 구현

현서·2026년 3월 9일

[TIL] Flutter 9기

목록 보기
82/102

Flutter 로컬 알림 & FCM 구현

개요

2가지 알림 방식을 구현한다:

  • 로컬 알림: 기기 내에서 예약 (flutter_local_notifications)
  • FCM 알림: 서버에서 전송 (firebase_messaging)

1. 패키지 설치

flutter pub add flutter_local_notifications
flutter pub add firebase_messaging
flutter pub add firebase_core
flutter pub add timezone
flutter pub add shared_preferences

2. iOS 설정

Xcode에서 Capability 추가

  1. Xcode에서 ios/Runner.xcworkspace 열기
  2. Signing & Capabilities 탭에서 + Capability 클릭
  3. Push Notifications 추가

3. 로컬 알림 - NotificationService

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

class NotificationService {
  static final NotificationService _instance = NotificationService._internal();
  
  factory NotificationService() => _instance;
  NotificationService._internal();
  
  final plugin = FlutterLocalNotificationsPlugin();

  // 초기화 (권한 요청 안함)
  Future<void> init() async {
    const iOS = DarwinInitializationSettings(
      requestAlertPermission: false,
      requestBadgePermission: false,
      requestSoundPermission: false,
    );

    final settings = InitializationSettings(iOS: iOS);
    await plugin.initialize(settings);
  }

  // 권한 요청 (지연 요청)
  Future<void> requestPermission() async {
    await plugin
        .resolvePlatformSpecificImplementation<
            DarwinFlutterLocalNotificationsPlugin>()
        ?.requestPermissions(alert: true, badge: true, sound: true);
  }

  // 예약 알림
  Future<void> scheduleNotification({
    required int id,
    required String title,
    required String body,
    required DateTime scheduledTime,
    String? payload,
  }) async {
    if (scheduledTime.isBefore(DateTime.now())) {
      return;
    }

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

    const details = NotificationDetails(iOS: iOS);

    await plugin.zonedSchedule(
      id,
      title,
      body,
      tz.TZDateTime.from(scheduledTime, tz.local),
      details,
      uiLocalNotificationDateInterpretation:
          UILocalNotificationDateInterpretation.absoluteTime,
      payload: payload,
    );
  }

  // 즉시 알림
  Future<void> showNotification({
    required int id,
    required String title,
    required String body,
    String? payload,
  }) async {
    const iOS = DarwinNotificationDetails(
      presentAlert: true,
      presentBadge: true,
      presentSound: true,
    );

    const details = NotificationDetails(iOS: iOS);
    await plugin.show(id, title, body, details, payload: payload);
  }

  // 알림 취소
  Future<void> cancelNotification(int id) async {
    await plugin.cancel(id);
  }

  // 모든 알림 취소
  Future<void> cancelAll() async {
    await plugin.cancelAll();
  }
}

4. FCM 알림 - FirebaseNotificationController

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:riverpod/riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';

class FirebaseNotificationController extends StateNotifier<Set<String>> {
  FirebaseNotificationController() : super({});

  // 토픽 구독
  Future<void> subscribeToTopic(String topicName) async {
    try {
      await FirebaseMessaging.instance.subscribeToTopic(topicName);
      
      final prefs = await SharedPreferences.getInstance();
      final subscribedTopics = prefs.getStringList('subscribed_topics') ?? [];
      
      if (!subscribedTopics.contains(topicName)) {
        subscribedTopics.add(topicName);
        await prefs.setStringList('subscribed_topics', subscribedTopics);
      }
      
      state = {...state, topicName};
      print('토픽 구독 성공: $topicName');
    } catch (e) {
      print('토픽 구독 실패: $e');
    }
  }

  // 토픽 구독 해제
  Future<void> unsubscribeFromTopic(String topicName) async {
    try {
      await FirebaseMessaging.instance.unsubscribeFromTopic(topicName);
      
      final prefs = await SharedPreferences.getInstance();
      final subscribedTopics = prefs.getStringList('subscribed_topics') ?? [];
      subscribedTopics.remove(topicName);
      await prefs.setStringList('subscribed_topics', subscribedTopics);
      
      state = state.where((topic) => topic != topicName).toSet();
      print('토픽 구독 해제: $topicName');
    } catch (e) {
      print('토픽 구독 해제 실패: $e');
    }
  }

  // 저장된 구독 목록 로드
  Future<void> loadSubscribedTopics() async {
    final prefs = await SharedPreferences.getInstance();
    final subscribedTopics = prefs.getStringList('subscribed_topics') ?? [];
    state = subscribedTopics.toSet();
  }
}

final firebaseNotificationProvider = StateNotifierProvider<
    FirebaseNotificationController,
    Set<String>>((ref) => FirebaseNotificationController());

5. FCM 백그라운드 핸들러

('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  print('백그라운드 메시지 수신');
  print('제목: ${message.notification?.title}');
  print('내용: ${message.notification?.body}');
}

6. main.dart 초기화

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Firebase 초기화
  await Firebase.initializeApp();
  
  // NotificationService 초기화
  await NotificationService().init();
  
  // FCM 백그라운드 핸들러 등록
  FirebaseMessaging.onBackgroundMessage(
    firebaseMessagingBackgroundHandler,
  );
  
  // FCM 포그라운드 리스너
  FirebaseMessaging.onMessage.listen((RemoteMessage message) {
    print('포그라운드 메시지 수신');
    print('제목: ${message.notification?.title}');
    print('내용: ${message.notification?.body}');
  });
  
  runApp(const MyApp());
}

7. 로컬 알림 사용 예제

즉시 알림

await NotificationService().showNotification(
  id: 1,
  title: '알림 제목',
  body: '알림 본문',
);

예약 알림

final notificationTime = DateTime.now().add(const Duration(minutes: 30));

await NotificationService().scheduleNotification(
  id: 2,
  title: '예약 알림',
  body: '30분 후 나타납니다',
  scheduledTime: notificationTime,
);

8. FCM 알림 사용 예제

토픽 구독

await ref
    .read(firebaseNotificationProvider.notifier)
    .subscribeToTopic('topic_name');

토픽 구독 해제

await ref
    .read(firebaseNotificationProvider.notifier)
    .unsubscribeFromTopic('topic_name');

토픽 구독 상태 확인

class TopicSubscribeButton extends ConsumerWidget {
  final String topicName;

  const TopicSubscribeButton({required this.topicName});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final subscribedTopics = ref.watch(firebaseNotificationProvider);
    final isSubscribed = subscribedTopics.contains(topicName);

    return ElevatedButton(
      onPressed: () async {
        if (isSubscribed) {
          await ref
              .read(firebaseNotificationProvider.notifier)
              .unsubscribeFromTopic(topicName);
        } else {
          await ref
              .read(firebaseNotificationProvider.notifier)
              .subscribeToTopic(topicName);
        }
      },
      child: Text(isSubscribed ? '구독 중' : '구독'),
    );
  }
}

9. 알림 비교

항목로컬 알림FCM 알림
서버 필요불필요필요
예약가능불가능
권한 요청필요필요
포그라운드/백그라운드모두 지원모두 지원

결론

로컬 알림은 기기 내에서 독립적으로 동작하고,
FCM은 서버에서 메시지를 전송할 때 사용한다.
두 방식을 함께 사용하면 효과적인 알림 시스템을 구축할 수 있다.

0개의 댓글