티스토리 뷰

최근 이틀 동안 iOS, 안드로이드 환경에서 동일하게 푸시 알림 설정을 보내려고 삽질을 했다...
그러다 마주친 4주 전에 올라온 이 강의를 보고 해결되었다...ㅠㅠ

 

 

 

같은 어려움을 겪는 사람들을 위해서 간단히 기록을 남겨본다...🫠
(참고로 APNs 설정은 절차가 복잡해서 직접 강의를 보는 걸 추천)

반말체로 갑니다~~~~!

📌 준비

아래의 파일 4가지를 수정하거나 추가할 예정임.

  • iOS 폴더 내
    • AppDelegate.swift
  • lib 폴더 내

🔸 AppDelegate.swift

iOS의 푸시 알림 권한 및 delegate 설정을 여기서 하게 됨
느낌적으로 AppDelegate의 호출순서와 import 구문이 중요

// AppDelegate.swift
import UIKit
import Flutter

import flutter_local_notifications

@main
class AppDelegate: FlutterAppDelegate {
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // 1
        FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in
            GeneratedPluginRegistrant.register(with: registry)
        }
        // 2
        if #available(iOS 10.0, *) {
            UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
        }
        // 3
        GeneratedPluginRegistrant.register(with: self)

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

🔸 firebase_messaging_service.dart

싱글톤 패턴으로 작성됨.
Firebase 푸시 메시지를 수신하면 이를 로컬 알림 서비스로 넘겨서 사용자에게 즉시 표시하는 역할을 수행.

  • init(): Firebase 메시지 수신을 위한 기본 설정 (알림 권한 요청, 메시지 리스너 설정 등)
  • onMessage(): Foreground 상태에서 메시지를 수신했을 때 로컬 알림으로 변환하여 즉시 표시
  • onMessageOpenedApp(): 사용자가 푸시 알림을 눌러 앱을 열었을 때 실행되는 로직 처리

미래의 제가 볼까봐 주석이 좀 많아요...😂

// services/firebase_messaging_service.dart
class FirebaseMessagingService {
  // 싱글 톤 패턴 - private 생성자
  FirebaseMessagingService._internal();

  // 싱글톤 인스턴스
  static final FirebaseMessagingService _instance =
      FirebaseMessagingService._internal();

  // 싱글톤 인스턴스를 제공하는 팩토리 생성자
  factory FirebaseMessagingService.instance() => _instance;

  // 알림 표시를위한 로컬 알림 서비스에 대한 참조
  LocalNotificationsService? _localNotificationsService;

  /// Firebase 메시징을 초기화하고 모든 메시지 리스너를 설정합니다
  Future<void> init(
      {required LocalNotificationsService localNotificationsService}) async {
    _localNotificationsService = localNotificationsService;

    // FCM 토큰 처리
    _handlePushNotificationsToken();

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

    // 백그라운드 메시지 처리 등록 (앱 종료)
    FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

    // 앱이 포어그라운드에 있을 때 메시지 수신
    FirebaseMessaging.onMessage.listen(_onForegroundMessage);

    // 앱이 백그라운드에 있지만 종료되지 않을 때 알림 탭 수신
    FirebaseMessaging.onMessageOpenedApp.listen(_onMessageOpenedApp);

    // 종료된 상태에서 앱을 열었을 때 초기 메시지 확인
    final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
    if (initialMessage != null) {
      _onMessageOpenedApp(initialMessage);
    }
  }

  /// 푸시 알림을 위해 FCM 토큰을 검색하고 관리합니다
  Future<void> _handlePushNotificationsToken() async {
    // 푸시 알림을 위한 FCM 토큰 검색
    final token = await FirebaseMessaging.instance.getToken();
    debugPrint(' ~~~ 🔑 ~~~ FCM 토큰 : $token');

    FirebaseMessaging.instance.onTokenRefresh.listen((token) {
      debugPrint(' ~~~ 🔑 ~~~ 토큰 갱신 : $token');
      // 타겟팅을 위해 토큰을 서버에 보내는 로직 추가해야함
    }).onError((error) {
      debugPrint(' ~~~ 🐛 ~~~ 토큰 갱신 오류 : $error');
    });
  }

  /// 사용자에게 알림 권한 요청
  Future<void> _requestPermission() async {
    // 경고, 배지, 소리에 대한 권한 요청
    final result = await FirebaseMessaging.instance.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );

    // 사용자의 권한 결정 로그
    debugPrint(' ~~~ 📣 ~~~ 알림 권한 요청 : ${result.authorizationStatus}');
  }

  /// 앱이 포어그라운드에 있을 때 수신된 메시지 처리
  void _onForegroundMessage(RemoteMessage message) {
    debugPrint(' ~~~ 📣 ~~~ 포그라운드 알림 수신 : ${message.data.toString()}');
    final notificationData = message.notification;
    if (notificationData != null) {
      // 서비스를 사용하여 로컬 알림 표시
      _localNotificationsService?.showNotification(
        notificationData.title,
        notificationData.body,
        message.data.toString(),
      );
    }
  }

  /// 앱이 백그라운드 또는 종료된 상태에서 열리면 알림 탭 처리
  void _onMessageOpenedApp(RemoteMessage message) {
    debugPrint(
        ' ~~~ 📣 ~~~ 백그라운드 알림 수신 : ${message.data.toString()}');
    // 메시지 데이터에 따라 탐색 또는 특정 처리 추가해야함
  }
}

/// 백그라운드 메시지 처리기 (반드시 최상위 함수 또는 정적 함수여야 함)
/// 앱이 완전히 종료된 상태에서 메시지 처리
/// @pragma('vm:entry-point') 란? 
/// 안드로이드 네이티브 코드에서 다트 함수를 실행할 수 있게 해주는 구문
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  debugPrint(' ~~~ 📣 ~~~ 백그라운드 알림 수신 : ${message.data.toString()}');
}

🔸 local_notifications_service.dart

싱글톤 패턴으로 작성됨.
앱이 Foreground 상태일 때 로컬 알림을 실제로 표시하는 로직을 처리함.

  • init(): 로컬 알림 채널 및 기본 알림 설정 초기화
  • showNotification(): 전달받은 메시지 데이터를 실제 로컬 알림으로 사용자에게 보여줌
// services/local_notifications_service.dart
class LocalNotificationsService {
  // 싱글톤 패턴을 위한 private 생성자
  LocalNotificationsService._internal();

  //싱글톤 인스턴스
  static final LocalNotificationsService _instance =
      LocalNotificationsService._internal();

  //싱글톤 인스턴스를 반환하는 팩토리 생성자
  factory LocalNotificationsService.instance() => _instance;

  // 알림 처리를위한 메인 플러그인 인스턴스
  late FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin;

  // 앱 런처 아이콘을 사용하는 Android-specific 초기화 설정
  final _androidInitializationSettings =
      const AndroidInitializationSettings('@mipmap/ic_launcher');

  // 권한 요청이있는 iOS 별 초기화 설정
  final _iosInitializationSettings = const DarwinInitializationSettings();

  // Android 알림 채널 구성
  final _androidChannel = const AndroidNotificationChannel(
    'channel_id',
    'Channel name',
    description: 'Android push notification channel',
    importance: Importance.max,
  );

  // 초기화 상태 추적을 위한 플래그
  bool _isFlutterLocalNotificationInitialized = false;

  // 고유 알림 ID 생성을 위한 카운터
  int _notificationIdCounter = 0;

  /// Android 및 iOS에 대한 로컬 알림 플러그인 초기화
  Future<void> init() async {
    // 이미 초기화되었는지 확인하여 중복 설정 방지
    if (_isFlutterLocalNotificationInitialized) {
      return;
    }

    // 플러그인 인스턴스 생성
    _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();

    // 플랫폼별 설정 결합
    final initializationSettings = InitializationSettings(
      android: _androidInitializationSettings,
      iOS: _iosInitializationSettings,
    );

    // 설정 및 알림 탭 수신 콜백을 사용하여 플러그인 초기화
    await _flutterLocalNotificationsPlugin.initialize(
      initializationSettings,
      onDidReceiveNotificationResponse: (NotificationResponse response) {
        debugPrint('~~~ ✅ ~~~ 포그라운드 알림 탭: ${response.payload}');
      },
    );

    // Android 알림 채널 생성
    await _flutterLocalNotificationsPlugin
        .resolvePlatformSpecificImplementation<
            AndroidFlutterLocalNotificationsPlugin>()
        ?.createNotificationChannel(_androidChannel);

    _isFlutterLocalNotificationInitialized = true;
    debugPrint('~~~ ✅ ~~~ 로컬 알림 서비스가 초기화');
  }

  /// 로컬 알림 표시
  Future<void> showNotification(
    String? title,
    String? body,
    String? payload,
  ) async {
    AndroidNotificationDetails androidDetails = AndroidNotificationDetails(
      _androidChannel.id,
      _androidChannel.name,
      channelDescription: _androidChannel.description,
      importance: Importance.max,
      priority: Priority.high,
    );

    const iosDetails = DarwinNotificationDetails();

    final notificationDetails = NotificationDetails(
      android: androidDetails,
      iOS: iosDetails,
    );

    await _flutterLocalNotificationsPlugin.show(
      _notificationIdCounter++,
      title,
      body,
      notificationDetails,
      payload: payload,
    );
  }
}

🔸 main.dart

main.dart의 초기화 순서가 가장 중요함. 아래와 같이 초기화 로직을 짜면 문제없이 동작한다.

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await DotEnvService.load();

  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  final localNotificationsService = LocalNotificationsService.instance();
  await localNotificationsService.init();

  final firebaseMessagingService = FirebaseMessagingService.instance();
  await firebaseMessagingService.init(
    localNotificationsService: localNotificationsService,
  );

  runApp(const App());
}

📌 마무리

확실히 푸시 알림 설정(APNs 관련 포함)은 막막하고 어려웠지만, 정확한 호출 순서와 각 서비스의 역할을 이해하니 해결이 가능했음.
비슷한 문제로 힘들어하는 사람이 있다면 위의 코드를 꼭 참고하면 좋겠다.

이 글이 삽질 시간을 단축하는 데 조금이라도 도움이 되기를 바라며! 😊