Showing posts with label fcm. Show all posts
Showing posts with label fcm. Show all posts

Wednesday, June 10, 2020

Flutter 백그라운드 푸시 알림의 숨은 복병, MissingPluginException 완벽 해부

Flutter로 앱을 개발하면서 Firebase Cloud Messaging(FCM)을 연동하여 푸시 알림 기능을 구현하는 것은 이제 매우 보편적인 작업이 되었습니다. 사용자의 재방문을 유도하고 중요한 정보를 전달하는 데 푸시 알림만큼 효과적인 수단도 드물기 때문입니다. 대부분의 개발자는 firebase_messaging 패키지를 사용하여 FCM 연동을 시작하고, 더 나아가 사용자 경험을 향상시키기 위해 flutter_local_notifications 패키지를 함께 사용하여 커스텀 알림을 구현하곤 합니다.

앱이 포그라운드(Foreground) 상태일 때는 모든 것이 순조롭게 진행됩니다. FCM 메시지를 수신하고, flutter_local_notifications를 이용해 아름답게 디자인된 알림을 띄우는 것은 비교적 간단합니다. 하지만 진짜 문제는 앱이 백그라운드(Background)에 있거나 완전히 종료(Terminated)되었을 때 발생합니다. 바로 이때, 많은 개발자들이 MissingPluginException이라는 예외를 마주하며 깊은 좌절에 빠지게 됩니다.

분명히 포그라운드에서는 잘 동작하던 코드가 왜 백그라운드에서는 '플러그인을 찾을 수 없다'는 오류를 뿜어내는 것일까요? 이 글에서는 단순히 문제 해결 코드 몇 줄을 제시하는 것을 넘어, MissingPluginException이 발생하는 근본적인 원인을 Flutter와 Android의 동작 원리 관점에서 깊이 파헤쳐 보고, 가장 확실하고 안정적인 해결책을 단계별로 상세히 안내합니다. 이 글을 끝까지 읽으신다면, 더 이상 백그라운드 알림 오류에 시간을 낭비하지 않게 될 것입니다.

1. 근본 원인: Isolate와 분리된 실행 컨텍스트

문제를 해결하기 위해선 먼저 왜 이런 문제가 발생하는지 이해해야 합니다. MissingPluginException의 원흉은 Flutter의 동시성 모델인 'Isolate'와 안드로이드의 백그라운드 서비스 실행 방식에 있습니다.

Flutter의 Isolate란?

Flutter(엄밀히 말하면 Dart)는 단일 스레드(Single-threaded) 언어입니다. 하지만 Isolate라는 개념을 통해 멀티 스레딩과 유사한 동시성 프로그래밍을 지원합니다. 각 Isolate는 자신만의 메모리 공간과 이벤트 루프를 가지며, 다른 Isolate와 메모리를 공유하지 않습니다. 우리가 일반적으로 작성하는 Flutter 앱의 UI와 로직은 모두 '메인 Isolate'에서 실행됩니다.

이러한 구조는 메모리 공유로 인한 복잡한 동시성 문제를 피할 수 있게 해주지만, 바로 이 '격리'라는 특징이 백그라운드 작업에서 문제를 일으키는 원인이 됩니다.

백그라운드 메시지 처리 과정의 비밀

앱이 백그라운드 상태이거나 종료된 상태에서 FCM 푸시 메시지가 도착하면 어떤 일이 벌어질까요?

  1. Android 시스템이 FCM 메시지를 수신합니다.
  2. firebase_messaging 플러그인의 네이티브(Android) 코드가 이 메시지를 가로챕니다.
  3. 플러그인은 Dart 코드로 작성된 백그라운드 메시지 핸들러(FirebaseMessaging.onBackgroundMessage에 등록한 함수)를 실행하기 위해 새로운 Dart Isolate를 생성합니다.

여기서 가장 중요한 점은 3번입니다. UI가 실행되는 '메인 Isolate'가 아닌, 완전히 새로운 '백그라운드 Isolate'가 조용히 생성되어 실행되는 것입니다. 이 Isolate는 UI가 없기 때문에 '헤드리스(Headless) Isolate'라고도 불립니다.

'MissingPluginException' 발생 시나리오

새롭게 태어난 '백그라운드 Isolate'는 메인 Isolate와는 완전히 분리된, 깨끗한 상태입니다. 이는 메인 Isolate가 시작될 때(MainActivity가 실행될 때) 등록되었던 수많은 플러그인들의 정보(예: 'flutter_local_notifications'라는 이름의 채널은 안드로이드의 어떤 네이티브 코드와 연결되어 있다'는 정보)를 전혀 알지 못합니다.

따라서, 우리가 백그라운드 메시지 핸들러 안에서 flutter_local_notificationsshow() 메소드를 호출하면 다음과 같은 일이 벌어집니다.

  1. 백그라운드 Isolate의 Dart 코드가 "flutter_local_notifications 플러그인의 show 메소드를 호출해줘!" 라는 메시지를 Flutter 엔진에 보냅니다.
  2. Flutter 엔진은 이 요청을 받고 네이티브 안드로이드 코드와 연결을 시도하지만, 현재 실행 컨텍스트(백그라운드 Isolate)에는 flutter_local_notifications 플러그인이 등록된 적이 없다는 것을 발견합니다.
  3. 결국 엔진은 "요청하신 플러그인을 찾을 수 없습니다(Missing Plugin)"라는 의미의 MissingPluginException 예외를 발생시킵니다.

이제 원인을 명확히 알았습니다. 해결책은 간단합니다. 새롭게 생성되는 백그라운드 Isolate에도 flutter_local_notifications 플러그인을 수동으로 등록해주면 됩니다.

2. 단계별 해결 과정: 네이티브 코드 직접 연동하기

이 문제를 해결하기 위해 Flutter 프로젝트의 android 폴더 내에서 몇 가지 네이티브 코드를 직접 수정해야 합니다. 겁먹지 마세요. Kotlin/Java에 익숙하지 않더라도 아래 단계를 그대로 따라오시면 쉽게 해결할 수 있습니다.

Step 1: 사전 준비 (필수 패키지 확인)

먼저 pubspec.yaml 파일에 다음 패키지들이 정상적으로 추가되어 있는지 확인합니다. 이 글은 해당 패키지들의 기본적인 Dart 측 설정(초기화, 권한 요청 등)은 이미 완료되었다고 가정합니다.


dependencies:
  flutter:
    sdk: flutter
  
  # Firebase 관련 패키지
  firebase_core: ^2.27.0
  firebase_messaging: ^14.7.19
  
  # 로컬 알림 패키지
  flutter_local_notifications: ^17.0.0

(버전은 이 글을 작성하는 시점의 최신 버전이며, 본인의 프로젝트에 맞는 버전을 사용하시면 됩니다.)

Step 2: 수동 플러그인 등록자(Registrant) 생성

가장 핵심적인 단계입니다. 백그라운드 Isolate에 flutter_local_notifications 플러그인을 등록해 줄 별도의 Kotlin(또는 Java) 파일을 생성합니다.

  1. Android Studio나 VS Code에서 Flutter 프로젝트의 다음 경로로 이동합니다: android/app/src/main/kotlin/<your_package_name>/
  2. 해당 경로에 MainActivity.kt 파일이 있을 것입니다. 같은 위치에 FlutterLocalNotificationsPluginRegistrant.kt 라는 이름으로 새로운 Kotlin 파일을 생성합니다.
  3. 생성된 파일에 아래의 코드를 그대로 복사하여 붙여넣습니다.

package <프로젝트의 패키지 이름> // 예: com.example.my_app

import io.flutter.plugin.common.PluginRegistry
import com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin

// 이 클래스는 백그라운드 Isolate에서 flutter_local_notifications 플러그인을 수동으로 등록하는 역할을 합니다.
class FlutterLocalNotificationPluginRegistrant {
    companion object {
        fun registerWith(registry: PluginRegistry) {
            // 플러그인이 이미 등록되었는지 확인하여 중복 등록을 방지합니다.
            if (alreadyRegisteredWith(registry)) {
                return
            }
            // registry에서 플러그인 이름으로 registrar를 찾아 플러그인을 등록합니다.
            // "com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin"는
            // flutter_local_notifications 플러그인의 고유 식별자입니다.
            FlutterLocalNotificationsPlugin.registerWith(registry.registrarFor("com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin"))
        }

        private fun alreadyRegisteredWith(registry: PluginRegistry): Boolean {
            // 이 클래스의 canonicalName을 키로 사용하여 플러그인이 등록되었는지 확인합니다.
            // 한번 등록되면, 다시 등록하지 않도록 하여 효율성을 높입니다.
            val key = FlutterLocalNotificationPluginRegistrant::class.java.canonicalName
            if (registry.hasPlugin(key)) {
                return true
            }
            // 플러그인을 등록하고, 등록되었다는 사실을 기록합니다.
            registry.registrarFor(key)
            return false
        }
    }
}

주의: 코드 상단의 package <프로젝트의 패키지 이름> 부분은 반드시 본인 프로젝트의 패키지 이름으로 수정해야 합니다. (예: package com.example.my_app)

Step 3: 커스텀 Application 클래스 생성 또는 수정

이제 방금 만든 등록자(Registrant)를 호출할 장소가 필요합니다. Android 앱이 시작될 때 가장 먼저 실행되는 Application 클래스를 커스터마이징하여 이 역할을 맡깁니다.

firebase_messaging의 백그라운드 핸들러를 사용하려면 보통 커스텀 Application 클래스가 이미 필요합니다. 만약 아직 없다면 새로 만들고, 이미 있다면 수정하면 됩니다.

  1. 위의 FlutterLocalNotificationsPluginRegistrant.kt 파일과 동일한 경로에 Application.kt 라는 이름으로 새로운 Kotlin 파일을 생성합니다. (만약 이미 있다면 해당 파일을 엽니다.)
  2. 아래 코드를 참고하여 파일을 작성하거나 수정합니다.

package <프로젝트의 패키지 이름>

import io.flutter.app.FlutterApplication
import io.flutter.plugin.common.PluginRegistry
import io.flutter.plugin.common.PluginRegistry.PluginRegistrantCallback
import io.flutter.plugins.GeneratedPluginRegistrant
import io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingBackgroundService

// FlutterApplication을 상속하고 PluginRegistrantCallback을 구현합니다.
class Application : FlutterApplication(), PluginRegistrantCallback {

    override fun onCreate() {
        super.onCreate()
        // 앱이 시작될 때 백그라운드 메시징 서비스를 위한 콜백을 설정합니다.
        FlutterFirebaseMessagingBackgroundService.setPluginRegistrant(this)
    }

    // 이 메소드는 백그라운드 Isolate가 생성될 때 호출됩니다.
    override fun registerWith(registry: PluginRegistry?) {
        // [중요] 기존에 자동 생성되던 플러그인 등록 (firebase_messaging 등)
        // 이 부분을 주석 처리하거나 삭제하면 다른 백그라운드 플러그인이 동작하지 않을 수 있습니다.
        // `firebase_messaging`의 문서를 확인하여 최신 권장 방법을 따르세요.
        // 현재 firebase_messaging은 직접 GeneratedPluginRegistrant를 호출하지 않고
        // 내부적으로 처리하므로 아래 라인은 필요 없을 수 있습니다. 하지만 명시적으로 추가해두는 것이 안전합니다.
        // FirebaseMessagingPlugin.registerWith(registry?.registrarFor("io.flutter.plugins.firebase.messaging.FirebaseMessagingPlugin"))
        
        // [핵심] 우리가 직접 만든 로컬 알림 플러그인 등록자를 호출합니다.
        // 이 코드를 통해 백그라운드 Isolate에 flutter_local_notifications가 등록됩니다.
        if (registry != null) {
            FlutterLocalNotificationPluginRegistrant.registerWith(registry)
        }
    }
}

핵심 포인트: registerWith 메소드 안에 FlutterLocalNotificationPluginRegistrant.registerWith(registry)를 추가하는 것입니다. 이 한 줄이 백그라운드 Isolate와 flutter_local_notifications 플러그인을 연결해주는 다리 역할을 합니다.

참고: 과거에는 GeneratedPluginRegistrant.registerWith(registry)를 직접 호출하기도 했지만, 최신 firebase_messaging 버전에서는 내부적으로 필요한 플러그인을 처리하는 방식으로 변경되었습니다. 하지만 다른 플러그인을 백그라운드에서 함께 사용한다면 GeneratedPluginRegistrant 호출이 필요할 수도 있습니다. 지금 우리의 목표인 flutter_local_notifications 등록을 위해서는 위 코드와 같이 우리가 만든 Registrant만 확실히 호출해주면 됩니다.

Step 4: AndroidManifest.xml 파일 수정

마지막으로, 우리가 만든 커스텀 Application 클래스를 Android 시스템이 인식하고 사용하도록 AndroidManifest.xml 파일에 알려줘야 합니다.

  1. android/app/src/main/AndroidManifest.xml 파일을 엽니다.
  2. <application> 태그를 찾습니다.
  3. 해당 태그에 android:name 속성을 추가하고, 값으로는 방금 만든 커스텀 Application 클래스의 전체 경로를 .으로 시작하여 적어줍니다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.my_app">
    <!-- application 태그에 android:name 속성을 추가합니다. -->
    <application
        android:name=".Application" <!-- 이 부분을 추가! -->
        android:label="my_app"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            ...
        >
            ...
        </activity>
        ...
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
</manifest>

이 설정을 통해 안드로이드 앱이 실행될 때 기본 Application 클래스 대신 우리가 만든 .Application 클래스가 사용되어, 플러그인 등록 로직이 정상적으로 포함되게 됩니다.

3. 전체 코드 예시 및 최종 검증

이제 네이티브 코드 설정이 모두 끝났습니다. 마지막으로 Dart 코드와 함께 전체적인 구조를 확인하고 어떻게 테스트하는지 알아봅시다.

Dart 코드 예시 (`main.dart`)

백그라운드 메시지를 처리하는 Dart 코드는 다음과 같은 형태가 될 것입니다.


import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

// [중요] 백그라운드/종료 상태에서 메시지를 처리하기 위한 최상위 함수
// 이 어노테이션은 트리 쉐이킹(tree shaking) 중에 이 함수가 제거되지 않도록 보장합니다.
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // 백그라운드에서 실행되기 때문에 여기서 Firebase를 다시 초기화해야 합니다.
  await Firebase.initializeApp();

  // [핵심] 여기서 Local Notification을 호출해도 이제 MissingPluginException이 발생하지 않습니다.
  await showLocalNotification(message);
}

// 로컬 알림을 표시하는 유틸리티 함수
Future<void> showLocalNotification(RemoteMessage message) async {
  final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();

  // Android 알림 채널 설정
  const AndroidNotificationChannel channel = AndroidNotificationChannel(
    'high_importance_channel', // id
    'High Importance Notifications', // title
    description: 'This channel is used for important notifications.', // description
    importance: Importance.max,
  );

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

  // 알림 상세 정보 설정
  final NotificationDetails notificationDetails = NotificationDetails(
    android: AndroidNotificationDetails(
      channel.id,
      channel.name,
      channelDescription: channel.description,
      icon: '@mipmap/ic_launcher', // 알림 아이콘
      importance: Importance.max,
      priority: Priority.high,
    ),
    iOS: const DarwinNotificationDetails(),
  );

  // 알림 표시
  await flutterLocalNotificationsPlugin.show(
    message.hashCode, // 알림 ID
    message.notification?.title ?? 'Title',
    message.notification?.body ?? 'Body',
    notificationDetails,
  );
}


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

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

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('FCM Background Test'),
        ),
        body: const Center(
          child: Text('앱이 백그라운드에 있을 때 FCM을 테스트하세요.'),
        ),
      ),
    );
  }
}

위 코드에서 @pragma('vm:entry-point') 어노테이션은 매우 중요합니다. 컴파일러에게 이 함수가 앱의 main 진입점이 아니더라도 코드에서 제거하지 말라고 알려주는 역할을 합니다. 이 어노테이션이 없으면 릴리즈 빌드에서 백그라운드 핸들러가 동작하지 않을 수 있습니다.

최종 검증 방법

  1. 모든 코드 수정을 완료한 후, 프로젝트를 완전히 중지했다가 다시 빌드하여 실제 Android 기기나 에뮬레이터에 설치합니다. (flutter cleanflutter run을 권장합니다.)
  2. 앱이 실행되면 필요한 알림 권한을 허용합니다.
  3. 앱을 백그라운드로 보냅니다. (홈 버튼을 누름) 또는 앱을 완전히 종료합니다. (최근 앱 목록에서 스와이프하여 제거)
  4. Firebase 콘솔의 Messaging 탭으로 이동하여 '새 캠페인' > '알림'을 선택합니다.
  5. 알림 제목과 내용을 입력하고, 테스트 기기를 선택하여 테스트 메시지를 보냅니다.
  6. 기기에서 기본 FCM 알림이 아닌, flutter_local_notifications로 설정한 커스텀 알림(아이콘, 채널명 등이 적용된)이 정상적으로 수신되는지 확인합니다.

만약 커스텀 알림이 성공적으로 나타난다면, 당신은 마침내 MissingPluginException의 늪에서 벗어난 것입니다!

4. 심화 학습 및 추가 정보

다른 플러그인에서도 발생할 수 있나요?

네, 그렇습니다. 이 문제는 flutter_local_notifications에만 국한되지 않습니다. 백그라운드 Isolate에서 파일 시스템 접근을 위해 path_provider를 사용하거나, 간단한 데이터 저장을 위해 shared_preferences를 호출하는 등, 네이티브 기능과 연동된 모든 플러그인은 동일한 MissingPluginException을 발생시킬 수 있습니다.

해결 원리는 동일합니다. 해당 플러그인의 소스 코드를 살펴보면 registerWith라는 정적 메소드를 가진 `*Plugin.java` 또는 `*Plugin.kt` 파일을 찾을 수 있습니다. 그 플러그인의 등록 로직을 우리가 만든 Application.ktregisterWith 메소드에 추가해주면 됩니다. 예를 들어 shared_preferences를 추가한다면 다음과 같은 형태가 될 것입니다.


// Application.kt
import io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin

override fun registerWith(registry: PluginRegistry?) {
    if (registry == null) return

    // 로컬 알림 등록
    FlutterLocalNotificationPluginRegistrant.registerWith(registry)
    
    // SharedPreferences 등록
    // 플러그인의 registrarFor 키는 보통 플러그인의 메인 클래스 경로입니다.
    SharedPreferencesPlugin.registerWith(registry.registrarFor("io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin"))
}

iOS에서는 왜 이런 문제가 없을까요?

iOS는 Android와 근본적으로 다른 백그라운드 처리 모델을 가지고 있습니다. Android처럼 앱의 코드를 직접 실행하기 위해 별도의 Isolate를 띄우는 방식이 아닙니다. 대신 iOS에서는 'Notification Service Extension'이라는 별도의 앱 타겟을 사용하여 푸시 알림이 도착했을 때 내용을 수정하거나 커스텀 로직을 실행할 기회를 줍니다. 이는 Flutter의 Dart 세상과는 분리된 네이티브(Swift/Objective-C) 환경에서 동작하므로, Flutter 플러그인 등록과 관련된 MissingPluginException이 원천적으로 발생하지 않습니다.

결론: 원리를 이해하면 두렵지 않다

Flutter에서 백그라운드 푸시 알림 처리 중 발생하는 MissingPluginException은 단순히 코드를 잘못 작성해서가 아니라, Flutter의 Isolate 구조와 Android의 백그라운드 실행 컨텍스트의 차이에서 비롯되는 필연적인 문제였습니다. 이 문제를 해결하는 과정은 단순히 코드를 복사-붙여넣기 하는 것을 넘어, Flutter가 네이티브 플랫폼과 어떻게 상호작용하는지에 대한 깊은 이해를 제공합니다.

핵심은 '백그라운드에서 실행되는 Isolate는 메인 Isolate와 분리되어 있으므로, 필요한 플러그인을 수동으로 다시 등록해주어야 한다'는 것입니다. 이 원리만 기억한다면, 앞으로 flutter_local_notifications 뿐만 아니라 다른 어떤 플러그인을 백그라운드에서 사용하게 되더라도 당황하지 않고 문제를 해결해 나갈 수 있을 것입니다. 복잡해 보이는 크로스플랫폼의 이면을 이해하고 문제를 해결하는 것, 이것이야말로 개발의 진정한 묘미가 아닐까요?

Sunday, May 3, 2020

Flutter 푸시 알림, FCM과 Local Notifications 완벽 연동 가이드

사용자 참여를 유도하고 중요한 정보를 적시에 전달하는 푸시 알림은 현대 모바일 앱의 필수 기능입니다. Flutter 생태계에서는 Google의 강력한 백엔드 서비스인 Firebase Cloud Messaging(FCM)과, 기기 자체에서 알림을 유연하게 제어할 수 있는 flutter_local_notifications 패키지의 조합이 가장 이상적인 해결책으로 꼽힙니다. 하지만 이 두 가지 강력한 도구를 매끄럽게 연동하는 과정은 생각보다 많은 함정을 내포하고 있습니다. 특히 플랫폼별 설정의 미묘한 차이와 버전 업데이트에 따른 변경 사항들은 개발자들을 혼란에 빠뜨리곤 합니다.

이 글에서는 단순히 '따라하기' 식의 가이드를 넘어, 왜 FCM과 로컬 알림을 함께 사용해야 하는지에 대한 근본적인 이유부터 시작하여, 최신 Flutter 및 Firebase 버전에 맞는 가장 안정적이고 권장되는 설정 방법을 상세히 다룹니다. Firebase 프로젝트 설정부터 안드로이드와 iOS의 플랫폼별 네이티브 설정, 다양한 앱 상태(포그라운드, 백그라운드, 종료 상태)에 따른 메시지 처리, 그리고 개발자들이 가장 많이 겪는 문제들에 대한 해결책까지, 푸시 알림 기능 구현에 필요한 모든 것을 총망라하여 제공합니다. 이 가이드를 끝까지 따라오신다면, 여러분의 Flutter 앱에 안정적이고 사용자 친화적인 푸시 알림 시스템을 자신 있게 구축할 수 있을 것입니다.

1. 왜 FCM과 Flutter Local Notifications를 함께 사용해야 할까?

푸시 알림 구현을 시작하기 전에, 왜 firebase_messaging만으로는 부족하고 flutter_local_notifications를 추가로 사용해야 하는지 이해하는 것이 중요합니다. 각 패키지의 역할과 시너지를 알면 전체 구조를 설계하는 데 큰 도움이 됩니다.

  • Firebase Cloud Messaging (FCM) - firebase_messaging: 서버에서 클라이언트(앱)로 푸시 메시지를 보내는 파이프라인 역할을 합니다. Google의 인프라를 통해 안정적으로 메시지를 전달받는 핵심 통로입니다. 앱이 백그라운드나 종료 상태일 때 수신된 'notification' 페이로드를 통해 기본적인 시스템 알림을 자동으로 표시해 줄 수 있습니다. 하지만 앱이 활성화된 상태(포그라운드)에서는 알림이 자동으로 표시되지 않으며, 표시되는 알림의 디자인이나 동작을 세밀하게 커스터마이징하는 데 한계가 있습니다.
  • 로컬 알림 - flutter_local_notifications: 서버 연동 없이 앱 코드 내에서 직접 알림을 생성하고 관리하는 역할을 합니다. 즉, 기기 자체에서 알림을 만드는 도구입니다. 이를 통해 알림 채널(Android), 커스텀 사운드, 중요도, 진행률 표시줄, 액션 버튼 등 매우 풍부하고 사용자 정의된 알림을 만들 수 있습니다.

이 둘의 시너지는 다음과 같은 시나리오에서 극대화됩니다.

  1. 포그라운드(Foreground) 상태에서의 일관된 경험 제공: 사용자가 앱을 활발하게 사용 중일 때 FCM 메시지가 도착하면, 이는 데이터 형태로만 전달됩니다. 이때 firebase_messagingonMessage 스트림에서 이 데이터를 받아, flutter_local_notifications를 사용하여 사용자에게 시각적인 알림을 즉시 보여줄 수 있습니다. 이를 통해 사용자는 어떤 상태에서든 일관된 형태의 알림을 경험하게 됩니다.
  2. 고도로 커스터마이징된 알림: FCM은 메시지 '전달'에 집중하고, '표시'는 flutter_local_notifications에 위임하는 구조입니다. 서버에서 보낸 데이터(예: 이미지 URL, 특정 페이지로 이동하기 위한 정보)를 기반으로 로컬 알림 패키지를 사용해 사진이 포함된 알림, '답장'이나 '자세히 보기' 같은 버튼이 있는 알림 등 풍부한 형태의 알림을 구현할 수 있습니다.
  3. 플랫폼별 정책 대응: 특히 Android에서는 알림 채널(Notification Channel) 설정이 필수적입니다. flutter_local_notifications를 사용하면 이러한 채널을 손쉽게 생성하고 관리하여 사용자가 알림 종류별로 수신 여부를 선택할 수 있게 하는 등 최신 OS 정책에 유연하게 대응할 수 있습니다.

결론적으로, FCM을 '우편 배달부'로, 로컬 알림을 '예쁜 편지지를 만드는 디자이너'로 비유할 수 있습니다. 배달부가 가져온 소식(데이터)을 디자이너가 멋지게 꾸며 사용자에게 최종적으로 보여주는, 역할 분담을 통한 완벽한 시스템을 구축하는 것이 우리의 목표입니다.

2. 1단계: Firebase 프로젝트 설정 및 Flutter 연동

본격적인 코드 작성에 앞서, 우리의 Flutter 프로젝트와 Firebase를 연결하는 준비 작업이 필요합니다. 최신 개발 환경에서는 Firebase CLI(Command Line Interface)를 사용하는 것이 가장 효율적이고 실수를 줄이는 방법입니다.

2.1. Firebase 프로젝트 생성하기

가장 먼저, 알림을 보내고 관리할 Firebase 프로젝트가 필요합니다.

  1. Firebase 콘솔로 이동하여 Google 계정으로 로그인합니다.
  2. '프로젝트 추가' 버튼을 클릭합니다.
  3. 프로젝트 이름을 입력합니다. (예: 'My Awesome App')
  4. (선택 사항) 이 프로젝트에 Google 애널리틱스를 사용할지 결정합니다. 일반적으로 사용하는 것이 데이터 분석에 유리합니다. '계속'을 클릭하고 애널리틱스 계정을 선택하거나 새로 만듭니다.
  5. '프로젝트 만들기'를 클릭하고 잠시 기다리면 새로운 Firebase 프로젝트가 생성됩니다.

이제 이 Firebase 프로젝트에 우리의 Flutter 앱을 등록할 차례입니다.

2.2. FlutterFire CLI 설치 및 Flutter 프로젝트 연결

과거에는 플랫폼별(Android, iOS)로 설정 파일을 직접 다운로드하여 프로젝트에 추가하는 번거로운 과정을 거쳐야 했습니다. 하지만 이제는 FlutterFire CLI를 통해 이 모든 과정을 자동화할 수 있습니다.

  1. Firebase CLI 설치: 터미널을 열고 아래 명령어를 실행하여 Firebase CLI를 설치합니다. (이미 설치되어 있다면 이 단계를 건너뛰세요.)
    
    npm install -g firebase-tools
    
  2. Firebase 로그인: 다음 명령어로 Firebase 계정에 로그인합니다. 브라우저 창이 열리면 로그인할 계정을 선택하세요.
    
    firebase login
    
  3. FlutterFire CLI 활성화: Dart의 전역 패키지로 flutterfire_cli를 활성화합니다.
    
    dart pub global activate flutterfire_cli
    
  4. Flutter 프로젝트 연결: 이제 여러분의 Flutter 프로젝트 최상위 디렉토리에서 아래 명령어를 실행합니다.
    
    flutterfire configure
    
    이 명령어를 실행하면, CLI가 여러분의 Firebase 계정에 있는 프로젝트 목록을 보여줍니다. 위에서 생성한 프로젝트를 선택하세요. 그 후, 어떤 플랫폼(android, ios, macos, web)에 대해 설정을 진행할지 묻습니다. 필요한 플랫폼을 선택(스페이스바로 선택, 엔터로 확인)하면 CLI가 자동으로 각 플랫폼에 맞는 설정 파일(google-services.json for Android, GoogleService-Info.plist for iOS)을 다운로드하고, 프로젝트에 필요한 네이티브 코드 수정을 일부 진행해줍니다.

이 과정이 성공적으로 완료되면, lib/ 디렉토리에 firebase_options.dart 파일이 생성된 것을 확인할 수 있습니다. 이 파일은 각 플랫폼에 맞는 Firebase 프로젝트 식별 정보를 담고 있으며, 앱 초기화 시 사용됩니다. 절대 이 파일을 수동으로 수정하지 마세요.

2.3. 필요한 Flutter 패키지 설치하기

이제 pubspec.yaml 파일에 푸시 알림 연동에 필요한 패키지들을 추가할 차례입니다. 또는 터미널에서 다음 명령어를 실행해도 됩니다.


flutter pub add firebase_core
flutter pub add firebase_messaging
flutter pub add flutter_local_notifications
  • firebase_core: 모든 Firebase 플러그인의 기반이 되는 핵심 패키지입니다.
  • firebase_messaging: FCM 메시지를 수신하고 상호작용하기 위한 메인 패키지입니다.
  • flutter_local_notifications: 수신한 FCM 데이터를 바탕으로 사용자 정의 알림을 표시하기 위한 패키지입니다.

패키지 추가 후에는 IDE의 팝업을 통해 또는 터미널에서 flutter pub get 명령어를 실행하여 의존성을 설치해주세요.

2.4. 앱의 진입점(main.dart)에서 Firebase 초기화하기

앱이 시작될 때 Firebase 서비스가 Flutter 엔진보다 먼저 초기화되도록 설정해야 합니다. main() 함수를 다음과 같이 수정합니다.


import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart'; // flutterfire configure로 생성된 파일

void main() async {
  // Flutter 엔진과 위젯 바인딩이 초기화되었는지 확인
  // Firebase.initializeApp()을 호출하기 전에 반드시 필요합니다.
  WidgetsFlutterBinding.ensureInitialized();
  
  // 플랫폼별 Firebase 설정을 로드하여 Firebase 앱을 초기화
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'FCM Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter FCM & Local Notification'),
      ),
      body: const Center(
        child: Text('푸시 알림 테스트 준비 완료!'),
      ),
    );
  }
}

WidgetsFlutterBinding.ensureInitialized();runApp()이 호출되기 전에 Flutter 프레임워크의 여러 기능을 사용해야 할 때(플랫폼 채널 통신 등) 반드시 먼저 호출되어야 합니다. Firebase.initializeApp()이 내부적으로 플랫폼 채널을 사용하므로 이 라인은 필수입니다.

3. 2단계: 플랫폼별 상세 설정 (Android & iOS)

기본적인 Flutter 프로젝트 설정이 끝났습니다. 이제 각 모바일 운영체제의 특성에 맞는 네이티브 설정을 진행해야 합니다. 이 단계에서의 작은 실수가 알림이 동작하지 않는 주된 원인이 되므로 주의 깊게 따라와 주세요.

3.1. Android 플랫폼 설정

안드로이드는 비교적 설정이 간단한 편입니다. flutterfire configure가 대부분의 작업을 처리해주지만, 몇 가지 확인하고 추가해야 할 부분이 있습니다.

  1. Google Services 플러그인 확인:
    • android/build.gradle 파일에 google-services 클래스패스가 추가되었는지 확인합니다.
      
      // android/build.gradle
      buildscript {
          // ...
          dependencies {
              // ...
              classpath 'com.google.gms:google-services:4.4.1' // 버전은 다를 수 있음
          }
      }
      
    • android/app/build.gradle 파일 최하단에 google-services 플러그인이 적용되었는지 확인합니다.
      
      // android/app/build.gradle
      // ...
      apply plugin: 'com.google.gms.google-services'
      
  2. AndroidManifest.xml 설정:

    android/app/src/main/AndroidManifest.xml 파일은 안드로이드 앱의 모든 구성요소를 정의하는 중요한 파일입니다. 여기에 몇 가지 설정을 추가해야 합니다.

    • 인터넷 권한: FCM이 서버와 통신하려면 인터넷 권한이 필요합니다. <manifest> 태그 바로 아래에 추가합니다.
      
      <uses-permission android:name="android.permission.INTERNET"/>
      
    • 기본 알림 채널 ID (중요): Android 8.0(Oreo) 이상에서는 모든 알림이 '채널'에 속해야 합니다. FCM 메시지에 별도의 채널 정보가 없을 때 사용할 기본 채널 ID를 지정해주는 것이 좋습니다. 이 ID는 나중에 flutter_local_notifications에서 채널을 생성할 때 사용할 ID와 일치시켜야 합니다. <application> 태그 내부에 추가합니다.
      
      <meta-data
          android:name="com.google.firebase.messaging.default_notification_channel_id"
          android:value="high_importance_channel" /> 
      
    • 기본 알림 아이콘 및 색상 (브랜딩에 필수): FCM이 백그라운드에서 자동으로 알림을 표시할 때 사용할 아이콘과 색상을 지정합니다. 여기서 아이콘을 지정하지 않으면 회색 사각형으로 표시될 수 있습니다.
      
      <meta-data
          android:name="com.google.firebase.messaging.default_notification_icon"
          android:resource="@drawable/ic_notification" />
      <meta-data
          android:name="com.google.firebase.messaging.default_notification_color"
          android:resource="@color/ic_notification_color" />
      

      위 설정에 사용된 리소스를 생성해야 합니다.

      • 아이콘(ic_notification.xml): android/app/src/main/res/drawable/ 디렉토리에 생성합니다. 안드로이드 상태 표시줄 아이콘은 반드시 흰색 또는 단색이어야 하며, 배경은 투명해야 합니다. 컬러 아이콘은 시스템에 의해 자동으로 단색 처리됩니다. Android Studio의 'Vector Asset' 생성 도구를 사용하면 편리합니다.
      • 색상(colors.xml): android/app/src/main/res/values/ 디렉토리에 colors.xml 파일을 만들고 색상을 정의합니다. 이 색상은 주로 아이콘의 배경 틴트나 앱 이름 텍스트에 적용됩니다.
        
        <?xml version="1.0" encoding="utf-8"?>
        <resources>
            <color name="ic_notification_color">#FFFFFF</color>
        </resources>
        
  3. Android 13 (API 33) 이상 알림 권한 요청:

    Android 13부터는 앱이 알림을 보내기 위해 사용자에게 명시적으로 런타임 권한을 요청해야 합니다. 이 과정 없이는 어떤 알림도 표시되지 않습니다. 이 권한은 보통 앱이 시작될 때 요청하며, permission_handler 같은 패키지를 사용하면 쉽게 구현할 수 있습니다.

    먼저 패키지를 추가합니다: flutter pub add permission_handler

    그리고 AndroidManifest.xml에 권한을 선언합니다.

    
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
    

    이제 Dart 코드에서 권한을 요청하는 로직을 추가합니다. 이 로직은 나중에 만들 알림 서비스 클래스 내에 위치시키는 것이 좋습니다.

3.2. iOS 플랫폼 설정

iOS 설정은 Android에 비해 조금 더 복잡하며, 많은 개발자들이 어려움을 겪는 구간입니다. Apple의 정책과 시스템 구조에 대한 이해가 필요합니다.

  1. Xcode Capability 설정:
    • Flutter 프로젝트의 ios 폴더를 Xcode로 엽니다. (Runner.xcworkspace 파일을 여는 것을 권장합니다.)
    • 왼쪽 네비게이터에서 'Runner' 프로젝트를 선택한 후, 중앙 에디터에서 'Runner' 타겟을 선택합니다.
    • 상단의 'Signing & Capabilities' 탭으로 이동합니다.
    • '+ Capability' 버튼을 클릭하여 다음 두 가지 기능을 추가합니다.
      • Push Notifications: 앱이 원격 알림을 수신할 수 있도록 허용하는 가장 기본적인 설정입니다.
      • Background Modes: 앱이 백그라운드 상태에서 특정 작업을 수행할 수 있도록 합니다. 추가 후 'Remote notifications' 항목을 체크합니다. 이것이 있어야 백그라운드 상태에서 수신된 푸시를 처리할 수 있습니다.
  2. Apple Push Notification service (APNs) 인증 키 설정:

    Firebase가 Apple의 푸시 알림 서버(APNs)를 통해 iOS 기기로 알림을 보내려면, 인증을 위한 키가 필요합니다. 인증서(.p12) 방식도 있지만, 만료 기간이 없는 인증 키(.p8) 방식이 훨씬 편리하고 권장됩니다.

    • Apple 개발자 계정에 로그인합니다.
    • 'Certificates, Identifiers & Profiles' 메뉴로 이동합니다.
    • 왼쪽 메뉴에서 'Keys'를 선택하고 '+' 버튼을 눌러 새 키를 생성합니다.
    • Key Name을 입력하고(예: 'My App APNs Key'), 'Apple Push Notifications service (APNs)' 서비스에 체크합니다.
    • 'Continue'와 'Register'를 차례로 클릭합니다.
    • 매우 중요: 키 생성 완료 페이지에서 .p8 파일을 딱 한 번만 다운로드할 수 있습니다. 즉시 다운로드하여 안전한 곳에 보관하세요. 또한, 페이지에 표시된 **Key ID**와 페이지 우상단의 계정 이름 아래에 있는 **Team ID**를 복사해둡니다.
    • 이제 Firebase 콘솔로 돌아가 프로젝트 설정 > 클라우드 메시징 탭으로 이동합니다.
    • 'Apple 앱 구성' 섹션에서 'APN 인증 키'를 업로드하는 곳을 찾아, 방금 다운로드한 .p8 파일과 복사해둔 Key ID, Team ID를 입력하고 '업로드' 버튼을 클릭합니다.
  3. Info.plist 설정 및 핵심 문제 해결:

    ios/Runner/Info.plist 파일에 키-값 쌍을 추가하여 앱의 동작을 설정합니다. 여기서 많은 개발자들이 겪는 문제가 발생합니다.

    배경 지식: 메소드 스위즐링(Method Swizzling)
    Firebase iOS SDK는 개발자의 편의를 위해 '메소드 스위즐링'이라는 기술을 사용합니다. 이는 앱의 델리게이트 메소드(예: 알림 수신 관련 메소드)를 Firebase의 자체 메소드로 자동으로 바꿔치기하여 알림 관련 작업을 쉽게 처리하도록 돕는 기능입니다.

    문제점과 해결책
    하지만 우리가 flutter_local_notifications와 같은 다른 알림 처리 라이브러리를 함께 사용할 때, 이 자동 바꿔치기 기능은 오히려 충돌을 일으키거나 우리가 원하는 대로 알림을 제어할 수 없게 만드는 원인이 됩니다. 따라서 우리는 Firebase의 이 스위즐링 기능을 끄고, 모든 알림 흐름을 수동으로 직접 제어하는 것이 더 안정적입니다.

    Info.plist 파일을 열고 다음 키-값 쌍을 추가하여 스위즐링을 비활성화합니다.

    
    <key>FirebaseAppDelegateProxyEnabled</key>
    <string>NO</string>
    

    ‼️ 극히 중요한 함정: 많은 공식 문서나 오래된 자료에서 이 값을 불리언(Boolean) 타입인 <false/>로 설정하라고 안내합니다. 하지만 수많은 실제 사례에서 <false/>는 제대로 동작하지 않는 경우가 빈번하게 보고되었습니다. 가장 확실하고 안정적인 방법은 타입(Type)을 문자열(String)로 설정하고 값(Value)을 NO로 지정하는 것입니다. 이 작은 차이가 몇 시간의 디버깅을 아껴줄 수 있습니다.

  4. AppDelegate.swift 수정:

    메소드 스위즐링을 비활성화했기 때문에, APNs로부터 받은 기기 토큰(device token)을 Firebase SDK에 수동으로 전달해주는 코드를 추가해야 합니다. 이 과정이 없으면 Firebase가 어떤 기기에 알림을 보내야 할지 알 수 없습니다.

    ios/Runner/AppDelegate.swift 파일을 열고 다음과 같이 코드를 추가하거나 수정합니다.

    
    import UIKit
    import Flutter
    import Firebase // Firebase 임포트 추가
    
    @UIApplicationMain
    @objc class AppDelegate: FlutterAppDelegate {
      override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
      ) -> Bool {
        // Firebase.configure()는 flutterfire configure를 통해 자동으로 처리될 수 있으나,
        // 명시적으로 호출해주는 것이 안전할 수 있습니다.
        FirebaseApp.configure()
    
        GeneratedPluginRegistrant.register(with: self)
        
        // 포그라운드 알림 설정 (iOS 10 이상)
        if #available(iOS 10.0, *) {
          UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
        }
    
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
      }
    
      // APNs 토큰을 Firebase에 전달하기 위한 코드
      override func application(_ application: UIApplication,
                                didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        Messaging.messaging().apnsToken = deviceToken
        super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
      }
    }
    

iOS 설정이 완료되면, ios 디렉토리에서 pod install --repo-update 명령어를 실행하여 새로 추가된 네이티브 의존성들을 설치해주는 것이 좋습니다.

4. 3단계: Dart 코드 구현 - 로직 통합

모든 플랫폼별 설정이 끝났습니다. 이제 Flutter(Dart) 코드를 작성하여 이 모든 것을 하나로 묶고, 실제로 알림을 수신하고 처리하는 로직을 구현할 차례입니다. 재사용성과 유지보수성을 위해 모든 알림 관련 로직을 별도의 서비스 클래스로 분리하는 것을 강력히 권장합니다.

4.1. 알림 처리 로직 설계

우리가 구현해야 할 핵심 기능들은 다음과 같습니다.

  1. 사용자에게 알림 수신 권한 요청하기
  2. FCM 기기 토큰 가져오기 (서버 전송용)
  3. 앱 상태별 FCM 메시지 처리
    • 포그라운드(Foreground): 앱이 화면에 떠 있을 때. (커스텀 알림 표시 필요)
    • 백그라운드(Background): 앱이 화면에 없지만 메모리에 살아있을 때.
    • 종료(Terminated): 사용자가 앱을 완전히 종료했을 때.
  4. 사용자가 알림을 탭했을 때 특정 화면으로 이동시키기

이 모든 로직을 관리할 `notification_service.dart` 파일을 생성하고 클래스를 만들어 보겠습니다.


// lib/services/notification_service.dart

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

// 백그라운드/종료 상태에서 메시지를 처리하기 위한 최상위 함수
// 이 함수는 반드시 클래스 외부에, 최상위 레벨에 위치해야 합니다.
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // 백그라운드에서 어떤 작업을 수행해야 한다면 여기에 작성합니다.
  // 예를 들어, 수신된 데이터를 로컬 DB에 저장하는 등의 작업을 할 수 있습니다.
  // 이 핸들러는 UI 업데이트와 관련된 작업을 직접 수행할 수 없습니다.
  print("백그라운드 메시지 수신: ${message.messageId}");
  print("데이터: ${message.data}");
}


class NotificationService {
  // 싱글톤 패턴으로 인스턴스 관리
  NotificationService._privateConstructor();
  static final NotificationService _instance = NotificationService._privateConstructor();
  factory NotificationService() {
    return _instance;
  }

  final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
  final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();

  // 여기에 초기화 및 기타 메소드들을 추가할 것입니다.
}

4.2. 초기화 메소드 구현

앱이 시작될 때 단 한 번 호출될 `init()` 메소드를 만들어, 모든 초기 설정을 이곳에서 처리합니다.


// NotificationService 클래스 내부에 추가

Future<void> init() async {
  // 1. FCM 권한 요청 (iOS & Android 13+)
  await _requestPermission();

  // 2. FCM 기본 설정
  await _setupFCM();

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

  // 4. FCM 토큰 가져오기
  final fcmToken = await getFcmToken();
  print("FCM Token: $fcmToken");
  // 이 토큰을 서버로 전송하여 사용자별 푸시를 보낼 수 있습니다.
}

Future<void> _requestPermission() async {
  // FCM 권한 요청
  await _firebaseMessaging.requestPermission(
    alert: true,
    announcement: false,
    badge: true,
    carPlay: false,
    criticalAlert: false,
    provisional: false,
    sound: true,
  );

  // Android 13+ 알림 권한 요청 (permission_handler 사용 시)
  // final status = await Permission.notification.request();
  // if (status.isDenied) {
  //   // 권한 거부 시 처리
  // }
}

Future<String?> getFcmToken() async {
  return await _firebaseMessaging.getToken();
}

이제 `main.dart`에서 이 초기화 메소드를 호출해줍니다.


// main.dart

// ... (import 문들)
import 'package:my_awesome_app/services/notification_service.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  
  // 백그라운드 핸들러 설정
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  // 알림 서비스 초기화
  await NotificationService().init();

  runApp(const MyApp());
}

4.3. 로컬 알림 및 FCM 메시지 핸들러 설정

이제 `NotificationService` 클래스에 가장 중요한 부분인 로컬 알림 설정과 FCM 메시지 리스너를 추가합니다.


// NotificationService 클래스 내부

// 안드로이드 알림 채널 정의
final AndroidNotificationChannel _channel = const AndroidNotificationChannel(
  'high_importance_channel', // id
  'High Importance Notifications', // title
  description: 'This channel is used for important notifications.', // description
  importance: Importance.max,
);

Future<void> _initLocalNotifications() async {
  // 안드로이드 초기화 설정
  const AndroidInitializationSettings initializationSettingsAndroid =
      AndroidInitializationSettings('@drawable/ic_notification'); // AndroidManifest.xml에 설정한 아이콘 이름

  // iOS 초기화 설정
  const DarwinInitializationSettings initializationSettingsIOS = DarwinInitializationSettings(
    requestAlertPermission: false,
    requestBadgePermission: false,
    requestSoundPermission: false,
  );

  final InitializationSettings initializationSettings = InitializationSettings(
    android: initializationSettingsAndroid,
    iOS: initializationSettingsIOS,
  );

  // 로컬 알림 플러그인 초기화 및 알림 탭 콜백 설정
  await _localNotifications.initialize(
    initializationSettings,
    onDidReceiveNotificationResponse: (NotificationResponse response) async {
      // 알림을 탭했을 때의 로직
      print('로컬 알림 탭! 페이로드: ${response.payload}');
      // payload를 기반으로 특정 페이지로 네비게이션
    },
  );

  // 안드로이드 포그라운드 알림을 위해 채널 생성
  await _localNotifications
      .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
      ?.createNotificationChannel(_channel);
}

Future<void> _setupFCM() async {
  // iOS 포그라운드 알림 표시 설정
  await _firebaseMessaging.setForegroundNotificationPresentationOptions(
    alert: true,
    badge: true,
    sound: true,
  );

  // 1. 포그라운드 상태에서 메시지 수신 리스너
  FirebaseMessaging.onMessage.listen((RemoteMessage message) {
    print('포그라운드에서 메시지 수신!');
    if (message.notification != null) {
      print('메시지 포함 알림: ${message.notification!.title}');
      
      // 포그라운드 상태에서는 FCM이 자동으로 알림을 띄우지 않으므로,
      // 로컬 알림을 사용해 직접 띄워줍니다.
      showLocalNotification(message);
    }
  });

  // 2. 백그라운드/종료 상태에서 알림을 탭하여 앱이 열렸을 때 처리
  FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
    print('알림 탭으로 앱 열림 (백그라운드)');
    // message.data를 기반으로 특정 페이지로 네비게이션
  });
}

// 로컬 알림을 표시하는 메소드
void showLocalNotification(RemoteMessage message) {
  final notification = message.notification;
  final android = message.notification?.android;

  if (notification != null) {
    _localNotifications.show(
      notification.hashCode,
      notification.title,
      notification.body,
      NotificationDetails(
        android: AndroidNotificationDetails(
          _channel.id, // 필수: 채널 ID
          _channel.name, // 필수: 채널 이름
          channelDescription: _channel.description,
          icon: android?.smallIcon, // 아이콘
          importance: Importance.max,
          priority: Priority.high,
        ),
        iOS: const DarwinNotificationDetails(
          presentAlert: true,
          presentBadge: true,
          presentSound: true,
        ),
      ),
      payload: message.data['route'], // 알림 탭 시 전달할 데이터
    );
  }
}

이제 `init()` 메소드에서 `_setupFCM()`과 `_initLocalNotifications()`를 순서대로 호출하도록 구성하면, 포그라운드에서 FCM 메시지를 수신했을 때 `flutter_local_notifications`를 통해 커스텀 알림이 표시됩니다.

4.4. 알림 탭(클릭) 시 화면 이동 처리

사용자가 알림을 탭했을 때 앱의 특정 페이지로 이동시키는 것은 매우 일반적인 요구사항입니다. 이는 앱의 상태에 따라 처리 방식이 나뉩니다.

  1. 앱이 종료(Terminated)된 상태에서 알림 탭:

    이 경우, `main` 함수가 처음부터 실행됩니다. `FirebaseMessaging.instance.getInitialMessage()`를 사용하여 앱을 실행시킨 초기 메시지를 가져올 수 있습니다.

    
    // main.dart 또는 스플래시 화면의 initState
    
    void handleInitialMessage() async {
        RemoteMessage? initialMessage = await FirebaseMessaging.instance.getInitialMessage();
        if (initialMessage != null) {
            print("앱 종료 상태에서 알림 탭으로 실행됨");
            // initialMessage.data['route'] 등을 사용하여 네비게이션
            // 예: navigatorKey.currentState.pushNamed(initialMessage.data['route']);
        }
    }
    
  2. 앱이 백그라운드(Background) 상태에서 알림 탭:

    이때는 `FirebaseMessaging.onMessageOpenedApp` 스트림이 이벤트를 발생시킵니다. 이 리스너는 `_setupFCM` 메소드 안에 이미 설정되어 있습니다.

  3. 앱이 포그라운드(Foreground) 상태에서 로컬 알림 탭:

    포그라운드에서는 우리가 직접 띄운 로컬 알림을 탭하게 됩니다. 이 이벤트는 `_localNotifications.initialize`의 `onDidReceiveNotificationResponse` 콜백에서 처리됩니다.

5. 4단계: 고급 활용 및 트러블슈팅

기본 연동이 완료되었습니다. 이제 몇 가지 고급 기능과 자주 발생하는 문제들에 대해 알아보겠습니다.

5.1. Firebase 콘솔에서 테스트 메시지 보내기

구현한 기능이 잘 동작하는지 테스트해볼 시간입니다.

  1. Firebase 콘솔로 이동하여 왼쪽 메뉴의 '참여' 섹션에서 'Cloud Messaging'을 선택합니다.
  2. 'Send your first message' 또는 '새 캠페인' > '알림'을 클릭합니다.
  3. 알림 제목과 텍스트를 입력합니다.
  4. '테스트 메시지 보내기' 버튼을 클릭합니다.
  5. 앱을 실행하여 콘솔에 출력된 FCM 등록 토큰을 복사하여 입력 필드에 붙여넣고 '+' 버튼을 눌러 추가합니다.
  6. '테스트' 버튼을 클릭합니다.

앱이 포그라운드, 백그라운드, 종료 상태일 때 각각 테스트하여 모든 시나리오에서 알림이 정상적으로 수신되고 표시되는지 확인합니다.

팁: 데이터 페이로드 보내기
알림에 추가 데이터를 담아 보내려면, 알림 작성 화면의 '추가 옵션' 섹션에서 '맞춤 데이터' 키-값 쌍을 추가하면 됩니다. 예를 들어 `key: route`, `value: /detail/123` 과 같이 보내면, `RemoteMessage` 객체의 `data` 맵을 통해 이 값을 수신하여 화면 이동에 사용할 수 있습니다.

5.2. 자주 묻는 질문 및 트러블슈팅

  • Q: 알림이 전혀 오지 않아요.
    • (공통) FCM 토큰이 정상적으로 생성되는지 확인하세요.
    • (iOS) APNs 인증 키(.p8)가 Firebase 콘솔에 정확히 등록되었는지, Xcode에서 'Push Notifications'와 'Background Modes' Capability가 활성화되었는지 다시 확인하세요.
    • (iOS) 시뮬레이터는 원격 푸시 알림을 지원하지 않습니다. 반드시 실제 기기로 테스트해야 합니다.
    • (Android) google-services.json 파일이 android/app 디렉토리에 올바르게 위치해 있는지 확인하세요.
    • (Android 13+) `POST_NOTIFICATIONS` 권한을 요청하고 사용자가 허용했는지 확인하세요.
  • Q: Android에서 알림 아이콘이 회색 네모/원으로 나와요.
    • 상태 표시줄 아이콘은 알파 채널이 있는(배경이 투명한) 단색(주로 흰색)이어야 합니다. android/app/src/main/res/drawable에 있는 아이콘 파일을 확인하세요. 컬러풀한 앱 런처 아이콘을 사용하면 안 됩니다.
  • Q: iOS에서 포그라운드 알림이 안 떠요.
    • await _firebaseMessaging.setForegroundNotificationPresentationOptions(...) 코드가 호출되었는지 확인하세요. 또는, `onMessage` 리스너에서 `flutter_local_notifications`를 사용하여 직접 알림을 `show()` 하고 있는지 확인하세요. 둘 중 하나의 방법은 반드시 구현되어야 합니다.
  • Q: iOS에서 FirebaseAppDelegateProxyEnabledNO로 설정했는데도 문제가 계속돼요.
    • Info.plist에서 키의 타입이 정말 'String'으로 되어 있는지 다시 한번 확인하세요 ('Boolean'이 아닙니다).
    • `AppDelegate.swift`에 `Messaging.messaging().apnsToken = deviceToken` 코드가 제대로 추가되었는지 확인하세요.
    • 변경 후 ios 폴더에서 pod deintegratepod install을 다시 실행하여 Pod을 깨끗하게 재설치해보세요.
    • 그래도 문제가 해결되지 않는다면 Xcode에서 프로젝트 클린(Clean Build Folder) 후 다시 빌드해보세요.

마치며: 안정적인 푸시 알림 시스템을 향한 여정

지금까지 Flutter에서 Firebase Cloud Messaging과 Flutter Local Notifications를 연동하여 강력하고 유연한 푸시 알림 시스템을 구축하는 전 과정을 상세히 살펴보았습니다. 처음에는 복잡해 보일 수 있지만, 각 단계의 '왜'를 이해하고 나면 전체 구조가 명확해집니다.

핵심은 FCM은 메시지 전달자, 로컬 알림은 메시지 표현자라는 역할 분담을 명확히 하는 것입니다. 그리고 iOS의 스위즐링 비활성화와 수동 설정, Android의 알림 채널과 아이콘/권한 설정이라는 플랫폼별 특성을 꼼꼼하게 챙기는 것이 안정적인 구현의 열쇠입니다.

이 가이드를 통해 여러분은 단순한 알림 수신을 넘어, 사용자의 앱 사용 경험을 한 단계 끌어올릴 수 있는 상호작용 가능한 알림 시스템의 기반을 다졌습니다. 이제 여기서 더 나아가 이미지나 버튼이 포함된 리치 알림, 주제(Topic) 구독을 통한 그룹 메시징, 데이터 메시지를 활용한 사일런트 푸시 등 다양한 고급 기능을 탐색하며 여러분의 앱을 더욱 풍성하게 만들어 보시길 바랍니다.

Thursday, January 30, 2020

FCM 푸시 알림, '한글 토픽'으로 사용자 경험을 혁신하는 비결

오늘날 모바일 애플리케이션 환경에서 사용자에게 시의적절한 정보를 전달하는 푸시 알림은 필수적인 기능으로 자리 잡았습니다. 신규 콘텐츠 업데이트, 중요한 공지사항, 개인화된 메시지 등 푸시 알림은 사용자의 재방문을 유도하고 앱의 가치를 높이는 핵심적인 소통 채널입니다. 구글의 Firebase Cloud Messaging(FCM)은 이러한 푸시 알림을 안정적이고 효율적으로 구현할 수 있도록 돕는 강력한 크로스플랫폼 메시징 솔루션입니다.

FCM이 제공하는 여러 기능 중 '토픽 메시징(Topic Messaging)'은 특정 주제에 관심 있는 다수의 사용자 그룹에게 한 번에 메시지를 보낼 수 있는 매우 유용한 기능입니다. 예를 들어, '스포츠' 뉴스에 관심 있는 모든 사용자에게 새로운 경기 소식을 보내거나, '할인 이벤트'에 동의한 사용자들에게 프로모션 정보를 전송하는 시나리오를 손쉽게 구현할 수 있습니다.

하지만 많은 국내 개발자들이 FCM 토픽 기능을 활용하면서 한 가지 예상치 못한 장벽에 부딪히게 됩니다. 바로 토픽 이름에 한글을 직접 사용할 수 없다는 점입니다. FCM의 공식 문서에 따르면 토픽 이름은 [a-zA-Z0-9-_.~%] 정규 표현식에 맞는 문자열만 허용합니다. 이 규칙 때문에 '스포츠'나 '오늘의 핫딜'과 같이 직관적인 한글 단어를 토픽으로 사용하려는 시도는 실패로 돌아가기 일쑤입니다. 이는 사용자 그룹을 관리하고 코드를 작성하는 개발자에게 상당한 불편함을 초래합니다. 관리의 편의성을 위해 'sports'나 'hotdeal_today'와 같은 영어 또는 로마자 표기를 사용해야만 했을까요?

결론부터 말하자면, 그렇지 않습니다. 이 제약은 간단하고 영리한 방법으로 우회할 수 있습니다. 이 글에서는 FCM의 문자열 제약이라는 기술적 한계를 넘어, 어떻게 한글 토픽을 자유자재로 사용하고 이를 통해 사용자 경험을 한 단계 끌어올릴 수 있는지, 그 원리와 구체적인 구현 방법, 그리고 실제 서비스에 적용할 수 있는 다양한 활용 사례까지 심도 있게 다뤄보겠습니다.

1. FCM과 토픽 메시징의 기본 원리 다시보기

본격적인 해결책을 논하기에 앞서, 왜 이 문제가 발생하는지 근본적으로 이해하기 위해 FCM 토픽 메시징의 작동 방식을 간단히 짚고 넘어갈 필요가 있습니다.

FCM 토픽이란 무엇인가? (Publish/Subscribe 모델)

FCM 토픽 메시징은 '게시/구독(Publish/Subscribe, 이하 Pub/Sub)' 모델을 기반으로 동작합니다. 이 모델은 메시지를 보내는 '게시자(Publisher)'와 메시지를 받는 '구독자(Subscriber)' 사이의 관계를 느슨하게 연결(loosely coupled)하는 강력한 아키텍처 패턴입니다.

  • 토픽(Topic): 메시지를 분류하는 가상의 채널 또는 이름입니다. '날씨', '정치', '연예' 등이 토픽이 될 수 있습니다.
  • 게시자(Publisher): 보통 앱 서버를 의미합니다. 특정 토픽에 메시지를 보냅니다. 게시자는 어떤 기기들이 이 토픽을 구독하고 있는지 알 필요가 없습니다. 그저 '날씨' 토픽에 "오늘 오후 소나기 예보"라는 메시지를 던져주기만 하면 됩니다.
  • 구독자(Subscriber): 사용자의 앱(클라이언트)을 의미합니다. 사용자가 앱 내에서 특정 주제에 대한 알림을 받기로 선택하면, 앱은 해당 토픽을 '구독(subscribe)'합니다. 예를 들어, 사용자가 날씨 알림을 켜면 클라이언트 앱은 FCM에 '날씨' 토픽을 구독하겠다고 요청합니다.

이러한 구조 덕분에 서버는 수백만 명의 사용자 개개인의 토큰(FCM Token)을 일일이 관리할 필요 없이, 단 한 번의 API 호출로 '날씨' 토픽을 구독한 모든 기기에 메시지를 전송할 수 있습니다. 이는 서버의 부하를 줄이고 메시징 로직을 매우 단순하게 만들어 줍니다.

FCM 토픽 메시징 동작 원리 도식도

FCM 토픽 메시징의 Pub/Sub 모델: 서버는 토픽에 메시지를 게시하고, FCM은 해당 토픽을 구독한 모든 클라이언트 앱에 메시지를 전달합니다.

왜 FCM은 토픽 이름에 제약을 둘까?

그렇다면 왜 FCM은 이토록 편리한 토픽 기능의 이름에 [a-zA-Z0-9-_.~%]라는 까다로운 제약을 두었을까요? 몇 가지 기술적인 이유를 추론해 볼 수 있습니다.

  1. URL 및 시스템 호환성: 토픽 이름은 내부적으로 시스템의 식별자(Identifier)나 리소스 경로의 일부로 사용될 가능성이 높습니다. 다양한 운영체제와 파일 시스템, 네트워크 프로토콜에서 문제를 일으키지 않는 안전한 문자(URL-safe characters) 집합으로 제한하는 것은 시스템의 안정성과 예측 가능성을 높이는 일반적인 설계 방식입니다. 한글이나 특수문자, 공백 등은 특정 환경에서 예기치 않은 오류를 발생시킬 수 있습니다.
  2. 크로스플랫폼 일관성: FCM은 Android, iOS, Web 등 다양한 플랫폼을 지원합니다. 모든 플랫폼에서 동일하게 해석되고 처리될 수 있는 표준화된 문자 집합을 사용하는 것이 구현의 복잡성을 줄이고 일관성을 유지하는 데 유리합니다.
  3. 단순성과 성능: 단순한 문자열은 파싱(parsing)하고 인덱싱(indexing)하기에 더 효율적입니다. 수억, 수십억 개의 구독 관계를 관리해야 하는 FCM의 거대한 인프라에서 식별자를 단순하게 유지하는 것은 성능 최적화의 한 부분이 될 수 있습니다.

이러한 제약은 타당한 이유를 가지고 있지만, 개발자, 특히 비영어권 개발자에게는 분명한 한계로 다가옵니다. '부동산'이라는 키워드를 'budongsan'으로 변환하여 관리하는 것은 직관적이지 않으며, 시간이 지나면 어떤 키워드가 어떻게 매핑되었는지 추적하기 어려워지는 관리상의 문제를 낳습니다. 이제 이 문제를 해결할 열쇠를 살펴볼 시간입니다.

2. 마법의 열쇠, 'URI 인코딩'으로 한글 토픽 잠금 해제

문제의 해결책은 의외로 간단하며, 웹 개발자라면 매우 친숙할 'URI 인코딩(URI Encoding, 또는 퍼센트 인코딩)' 기술에 있습니다.

URI 인코딩이란?

URI(Uniform Resource Identifier)는 인터넷상의 리소스를 고유하게 식별하기 위한 주소 체계입니다. 우리가 흔히 사용하는 URL이 URI의 한 종류입니다. URI에는 알파벳, 숫자, 그리고 일부 특수문자(-, _, ., ~)만 직접 사용할 수 있도록 약속되어 있습니다.

만약 한글, 공백, 또는 다른 특수문자를 URI에 포함시켜야 할 경우, 이 문자들을 직접 사용하는 대신 정해진 규칙에 따라 안전한 문자들의 조합으로 변환하는 과정이 필요한데, 이것이 바로 URI 인코딩입니다.

변환 규칙은 다음과 같습니다.

  1. 인코딩할 문자를 특정 문자 인코딩 방식(일반적으로 UTF-8)에 따라 1개 이상의 바이트(byte) 시퀀스로 변환합니다.
  2. 변환된 각 바이트를 % 기호 뒤에 두 자리 16진수(Hexadecimal) 값으로 표기합니다.

예를 들어, '테스트'라는 한글 문자열을 UTF-8 기반으로 URI 인코딩하면 다음과 같은 과정으로 변환됩니다.

  • '테' (U+D14C) → UTF-8 바이트: ED 85 8C → 인코딩된 문자열: %ED%85%8C
  • '스' (U+C2A4) → UTF-8 바이트: EC 8A A4 → 인코딩된 문자열: %EC%8A%A4
  • '트' (U+D2B8) → UTF-8 바이트: ED 8A B8 → 인코딩된 문자열: %ED%8A%B8

따라서 '테스트'라는 문자열은 최종적으로 '%ED%85%8C%EC%8A%A4%ED%8A%B8'라는 문자열로 변환됩니다.

FCM 토픽에 URI 인코딩 적용하기

여기서 중요한 포인트가 나옵니다. FCM 토픽이 허용하는 문자 패턴 [a-zA-Z0-9-_.~%]을 다시 한번 자세히 살펴보세요. 놀랍게도 이 패턴에는 퍼센트 기호(%)가 포함되어 있습니다. 이는 FCM이 애초에 URI 인코딩된 문자열을 토픽 이름으로 사용하는 것을 염두에 두고 설계되었음을 강력하게 시사합니다.

따라서 우리는 한글 토픽을 FCM에 전달하기 전에 간단히 URI 인코딩 처리만 해주면 됩니다. FCM 시스템은 이 인코딩된 문자열을 유효한 토픽 이름으로 인식하고 정상적으로 처리합니다.

원본 한글 토픽: 오늘의 뉴스
URI 인코딩된 토픽: %EC%98%A4%EB%8A%98%EC%9D%98%20%EB%89%B4%EC%8A%A4 (공백은 %20으로 인코딩됩니다)

클라이언트 앱은 '%EC%98%A4%EB%8A%98%EC%9D%98%20%EB%89%B4%EC%8A%A4'라는 이름의 토픽을 구독하고, 서버 역시 동일한 이름의 토픽으로 메시지를 보내면, '오늘의 뉴스' 알림을 받고 싶어하는 사용자에게 정확하게 푸시가 전달됩니다. 개발자는 데이터베이스나 관리자 페이지에서 '오늘의 뉴스'라는 직관적인 한글 이름을 그대로 관리하고, FCM과 통신하는 시점에만 인코딩/디코딩을 적용하면 됩니다.

3. 실전! 플랫폼별 한글 토픽 구독/발송 코드 구현

원리를 이해했으니 이제 실제 코드에 적용해 보겠습니다. 다행히 대부분의 최신 프로그래밍 언어와 플랫폼은 URI 인코딩/디코딩을 위한 표준 라이브러리나 함수를 기본적으로 제공하므로 구현은 매우 간단합니다.

클라이언트 측 구현 (토픽 구독)

클라이언트(사용자 앱)는 사용자의 요청에 따라 특정 한글 토픽을 구독해야 합니다.

Android (Kotlin)

Android에서는 java.net.URLEncoder를 사용하여 손쉽게 문자열을 인코딩할 수 있습니다.


import com.google.firebase.ktx.Firebase
import com.google.firebase.messaging.ktx.messaging
import java.net.URLEncoder
import java.nio.charset.StandardCharsets

// ...

fun subscribeToKoreanTopic(koreanTopic: String) {
    // 1. 한글 토픽을 UTF-8 기반으로 URI 인코딩합니다.
    val encodedTopic = URLEncoder.encode(koreanTopic, StandardCharsets.UTF_8.toString())

    // 2. 인코딩된 토픽으로 구독을 요청합니다.
    Firebase.messaging.subscribeToTopic(encodedTopic)
        .addOnCompleteListener { task ->
            var msg = "구독 성공: $koreanTopic (encoded: $encodedTopic)"
            if (!task.isSuccessful) {
                msg = "구독 실패: $koreanTopic"
            }
            Log.d("FCM_TOPIC", msg)
            // 사용자에게 UI 피드백 제공 (예: Toast 메시지)
        }
}

// 사용 예시
subscribeToKoreanTopic("오늘의 핫딜") // 내부적으로 "%EC%98%A4%EB%8A%98%EC%9D%98%20%ED%95%AB%EB%94%9C" 토픽을 구독

토픽 구독을 취소(unsubscribe)할 때도 마찬가지로 동일한 방식으로 인코딩된 토픽 이름을 사용해야 합니다.

iOS (Swift)

Swift에서는 StringaddingPercentEncoding(withAllowedCharacters:) 메서드를 사용하여 인코딩할 수 있습니다. .urlQueryAllowed는 일반적으로 사용되는 안전한 문자 집합입니다.


import FirebaseMessaging

// ...

func subscribeToKoreanTopic(koreanTopic: String) {
    // 1. 한글 토픽을 퍼센트 인코딩합니다.
    // .urlQueryAllowed는 공백 등 일부 문자를 인코딩하지 않으므로, 더 엄격한 문자 집합을 정의하거나
    // 필요에 따라 추가적인 처리를 할 수 있습니다. 일반적으로는 잘 동작합니다.
    guard let encodedTopic = koreanTopic.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
        print("Error: Failed to encode topic string.")
        return
    }

    // 2. 인코딩된 토픽으로 구독을 요청합니다.
    Messaging.messaging().subscribe(toTopic: encodedTopic) { error in
        if let error = error {
            print("Error subscribing to topic: \(koreanTopic), error: \(error.localizedDescription)")
        } else {
            print("Successfully subscribed to topic: \(koreanTopic) (encoded: \(encodedTopic))")
        }
    }
}

// 사용 예시
subscribeToKoreanTopic("주간 웹툰 업데이트")

서버 측 구현 (토픽 메시지 발송)

서버에서는 발송하려는 한글 토픽 이름을 클라이언트와 동일한 방식으로 인코딩하여 FCM API를 호출해야 합니다.

Node.js (Firebase Admin SDK)

Node.js 환경에서는 내장된 encodeURIComponent() 함수를 사용하는 것이 가장 편리합니다.


const admin = require("firebase-admin");

// ... Firebase Admin SDK 초기화 코드 ...

async function sendToKoreanTopic(koreanTopic, messagePayload) {
  // 1. 한글 토픽을 URI 인코딩합니다.
  const encodedTopic = encodeURIComponent(koreanTopic);

  const message = {
    ...messagePayload, // { notification: { title: '...', body: '...' }, data: { ... } }
    topic: encodedTopic
  };

  try {
    // 2. 인코딩된 토픽으로 메시지를 발송합니다.
    const response = await admin.messaging().send(message);
    console.log(`Successfully sent message to topic: ${koreanTopic} (encoded: ${encodedTopic})`, response);
    return response;
  } catch (error) {
    console.error(`Error sending message to topic: ${koreanTopic}`, error);
    throw error;
  }
}

// 사용 예시
const payload = {
  notification: {
    title: "부동산 속보!",
    body: "서울 지역 아파트 실거래가 업데이트 알림"
  }
};
sendToKoreanTopic("부동산", payload); // 내부적으로 "부동산"을 "%EB%B6%80%EB%8F%99%EC%82%B0"으로 변환하여 발송

Python (Firebase Admin SDK)

Python에서는 urllib.parse.quote() 함수를 사용합니다.


import firebase_admin
from firebase_admin import credentials, messaging
from urllib.parse import quote

# ... Firebase Admin SDK 초기화 코드 ...

def send_to_korean_topic(korean_topic: str, message_payload: dict):
    # 1. 한글 토픽을 UTF-8 기반으로 URI 인코딩합니다.
    # safe='' 인자는 '/'를 포함한 모든 특수문자를 인코딩하도록 합니다.
    encoded_topic = quote(korean_topic.encode('utf-8'), safe='')

    message = messaging.Message(
        notification=messaging.Notification(
            title=message_payload.get('title'),
            body=message_payload.get('body'),
        ),
        topic=encoded_topic,
    )

    try:
        # 2. 인코딩된 토픽으로 메시지를 발송합니다.
        response = messaging.send(message)
        print(f"Successfully sent message to topic: {korean_topic} (encoded: {encoded_topic}), response: {response}")
        return response
    except Exception as e:
        print(f"Error sending message to topic: {korean_topic}, error: {e}")
        raise e

# 사용 예시
payload = {
    "title": "IT 기술 세미나 안내",
    "body": "6월 개발자 밋업 신청이 시작되었습니다."
}
send_to_korean_topic("IT 행사", payload) # 내부적으로 "%IT%20%ED%96%89%EC%82%AC"로 변환하여 발송

보시다시피, 어떤 플랫폼을 사용하든 핵심은 'FCM과 통신하기 직전에 한글 토픽을 URI 인코딩한다'는 단 하나의 규칙입니다. 이 규칙만 지키면 클라이언트와 서버는 완벽하게 동일한 토픽 채널을 바라보게 되어 한글 토픽 메시징이 원활하게 이루어집니다.

4. 상상력을 더하다: 한글 토픽 실전 활용 사례

한글 토픽을 사용할 수 있게 되었다는 것은 단순히 관리의 편의성을 넘어, 서비스의 기능을 더욱 풍부하고 직관적으로 만들 수 있는 새로운 가능성을 열어줍니다. 몇 가지 구체적인 활용 시나리오를 통해 그 잠재력을 살펴보겠습니다.

사례 1: 초개인화 '키워드 알림' 시스템 구축

가장 강력하고 대표적인 활용 사례입니다. 뉴스, 커뮤니티, 중고거래, 쇼핑몰 등 다양한 앱에서 사용자가 특정 키워드에 대한 새 글이나 상품이 등록되었을 때 알림을 받도록 설정하는 기능입니다.

  • 사용자 경험: 사용자가 앱 설정 화면에서 '알림 받고 싶은 키워드' 입력란에 '삼성전자 주가', '제주도 2인 항공권', '나이키 270 사이즈'와 같은 구체적인 문구를 직접 입력하고 저장합니다.
  • 기술 구현 흐름:
    1. 사용자가 '삼성전자 주가'를 입력하고 저장 버튼을 누릅니다.
    2. 클라이언트 앱은 '삼성전자 주가' 문자열을 URI 인코딩하여 (예: %EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90%20%EC%A3%BC%EA%B0%80) 해당 토픽을 구독합니다. 동시에 서버 DB에 이 키워드를 저장하여 관리합니다.
    3. 서버의 콘텐츠 수집 시스템(크롤러, 파서 등)이 '삼성전자 주가'와 관련된 새로운 뉴스를 발견합니다.
    4. 서버는 해당 키워드인 '삼성전자 주가'를 즉시 URI 인코딩하여 FCM 토픽 메시징 API를 호출합니다.
    5. FCM은 이 토픽을 구독한 모든 사용자에게 '삼성전자 주가 관련 새 소식!'이라는 푸시 알림을 발송합니다.
  • 장점: 개발자는 미리 정해진 카테고리(스포츠, 연예 등)에 사용자를 가두는 것이 아니라, 사용자의 능동적인 관심사를 기반으로 무한히 확장 가능한 개인화된 알림 채널을 제공할 수 있습니다. 이는 사용자의 서비스 충성도를 크게 높이는 요인이 됩니다.

사례 2: 지역 기반 하이퍼로컬(Hyperlocal) 서비스

당근마켓과 같은 지역 커뮤니티나 배달 앱에서 특정 동네, 아파트 단지, 지하철역 등을 기반으로 한 타겟팅 메시지를 보낼 때 매우 유용합니다.

  • 사용자 경험: 사용자가 자신의 '동네 인증'을 하면 앱은 자동으로 해당 동네 이름(예: '역삼동', '판교테크노밸리')을 토픽으로 구독합니다.
  • 기술 구현 흐름:
    1. 신규 사용자가 가입 후 주소 인증을 통해 '역삼동' 주민임이 확인됩니다.
    2. 클라이언트 앱은 '역삼동'을 URI 인코딩하여 토픽을 구독합니다.
    3. '역삼동'에 위치한 한 카페 사장님이 앱의 광고 시스템을 통해 "오늘 오후 2시~4시, 역삼동 주민 커피 50% 할인!" 이라는 이벤트를 등록합니다.
    4. 서버는 타겟 지역인 '역삼동'을 URI 인코딩하여 해당 토픽에 프로모션 메시지를 발송합니다.
  • 장점: 관리자는 'yeoksam-dong'과 같은 불분명한 식별자 대신 '역삼동'이라는 명확한 이름으로 타겟 그룹을 관리할 수 있습니다. 이는 운영 실수를 줄이고 마케팅 효율을 극대화합니다.

사례 3: 사용자 생성 콘텐츠(UGC) 태그 구독

블로그, 인스타그램, 브런치와 같은 콘텐츠 플랫폼에서 사용자가 특정 해시태그나 관심 작가를 팔로우(구독)하는 기능을 구현할 수 있습니다.

  • 사용자 경험: 사용자가 '#반려동물일상' 이라는 태그가 붙은 게시물을 보고, 해당 태그 옆의 '구독하기' 버튼을 누릅니다. 또는 '김작가'라는 필명의 작가 페이지에서 '새 글 알림받기'를 선택합니다.
  • 기술 구현 흐름:
    1. 사용자가 '#반려동물일상' 태그 구독을 요청합니다.
    2. 클라이언트 앱은 '#반려동물일상' 또는 작가 필명 '김작가'를 URI 인코딩하여 토픽을 구독합니다. (특수문자 #도 인코딩됩니다.)
    3. 다른 사용자가 '#반려동물일상' 태그를 포함한 새 글을 작성하거나, '김작가'가 새 글을 발행합니다.
    4. 서버는 해당 태그나 작가 이름을 즉시 토픽으로 사용하여 새 글 알림을 발송합니다.
  • 장점: 정적인 카테고리를 넘어, 사용자들이 동적으로 생성하는 관심의 흐름(태그)을 실시간 알림 시스템과 직접 연결할 수 있어 매우 유연하고 확장성 있는 서비스 아키텍처를 구축할 수 있습니다.

5. 반드시 알아야 할 주의사항과 고급 팁

한글 토픽을 사용하는 것은 매우 강력하지만, 실제 운영 환경에 적용하기 전에 몇 가지 주의해야 할 점이 있습니다.

1. 토픽 문자열 길이 제한

FCM 토픽 이름에는 길이 제한이 있습니다. 공식적으로 명시된 바는 없지만, 실제 테스트 결과에 따르면 인코딩된 후의 문자열 길이가 900자를 넘지 않아야 합니다.

한글 한 글자는 보통 UTF-8에서 3바이트를 차지하고, 각 바이트는 %XX 형태로 3개의 문자로 인코딩됩니다. 따라서 한글 한 글자는 인코딩 후 9개의 문자가 됩니다 (예: '한' → %ED%95%9C).

이를 계산해보면, 순수 한글로만 이루어진 토픽의 경우 약 100자 (900 / 9 = 100) 까지 사용 가능하다고 볼 수 있습니다. 일반적인 키워드나 문장으로는 충분히 긴 길이지만, 사용자가 임의로 매우 긴 문자열을 토픽으로 구독하려는 경우를 대비하여 클라이언트 단에서 입력 길이를 제한하는 방어 코드를 추가하는 것이 안전합니다.


// Android (Kotlin) 예시 - 길이 제한 추가
fun subscribeToKoreanTopic(koreanTopic: String) {
    if (koreanTopic.length > 100) { // 원본 한글 문자열 길이로 체크
        Log.w("FCM_TOPIC", "토픽 길이가 너무 깁니다: ${koreanTopic.length}자")
        // 사용자에게 길이 제한에 대한 피드백을 주고 구독을 막음
        return
    }
    // ... 이후 구독 로직은 동일
}

2. 인코딩/디코딩 로직의 일관성 유지

매우 중요합니다. 클라이언트(Android, iOS, Web)와 서버(Node.js, Python, Java 등)에서 사용하는 URI 인코딩 방식과 문자셋(Character Set)이 반드시 동일해야 합니다. 모든 플랫폼에서 UTF-8을 표준으로 사용하는 것이 가장 안전하고 일반적인 방법입니다.

만약 한쪽은 UTF-8로, 다른 한쪽은 EUC-KR로 인코딩한다면 완전히 다른 토픽 문자열이 생성되어 메시징이 실패하게 됩니다. 이는 디버깅하기 매우 어려운 '조용한 오류(silent error)'로 이어질 수 있으므로, 프로젝트 시작 단계에서 인코딩 표준을 명확히 정의하고 모든 팀원이 공유해야 합니다.

3. 동적 토픽 생성에 따른 관리 전략

사용자가 직접 토픽(키워드, 태그 등)을 생성하는 기능을 제공하면, 시간이 지남에 따라 수백만, 수천만 개의 고유한 토픽이 생성될 수 있습니다. FCM 자체는 토픽의 개수에 대한 명시적인 제한은 없다고 알려져 있지만, 앱 서버 입장에서 이 토픽들을 관리하는 것은 또 다른 문제입니다.

  • 토픽 정리(Cleanup): 아무도 구독하지 않는 토픽은 사실상 의미가 없습니다. 정기적으로 각 토픽의 구독자 수를 확인하고, 구독자가 0명인 토픽을 DB에서 삭제하는 등의 정리 로직을 구현하는 것을 고려해볼 수 있습니다. (참고: FCM은 특정 토픽의 구독자 목록을 직접 조회하는 API를 제공하지 않으므로, 앱 서버에서 각 사용자가 어떤 토픽을 구독하는지 별도로 추적/관리해야 합니다.)
  • 입력 값 검증(Input Sanitization): 사용자의 입력을 그대로 토픽으로 사용하는 만큼, 악의적인 사용자가 비정상적으로 길거나 의미 없는 문자열을 대량으로 생성하여 시스템에 부하를 주는 것을 방지하기 위한 최소한의 검증 로직(길이 제한, 금칙어 필터링 등)이 필요합니다.

결론: 제약을 넘어 창의적인 사용자 경험으로

FCM 토픽의 [a-zA-Z0-9-_.~%] 문자열 제약은 처음 마주했을 때 넘기 힘든 벽처럼 느껴질 수 있습니다. 많은 개발자들이 이로 인해 한글 토픽 사용을 포기하고 영어 약어나 숫자 조합으로 우회하는 불편함을 감수해왔습니다.

하지만 이 글에서 살펴보았듯이, 'URI 인코딩'이라는 표준 웹 기술을 활용하는 것만으로 이 제약은 아무런 문제가 되지 않습니다. 오히려 FCM이 퍼센트 기호(%)를 허용함으로써 이러한 확장성을 공식적으로 지원하고 있음을 알 수 있습니다.

이제 우리는 기술적 한계에 갇혀 'hotdeal_1225'와 같은 기계적인 토픽을 만드는 대신, '크리스마스 특별 할인'이라는 사람 친화적이고 직관적인 토픽을 자유롭게 설계하고 구현할 수 있습니다. 이는 단순히 개발자의 편의성을 높이는 것을 넘어, 사용자의 숨겨진 니즈를 정확히 파고드는 '키워드 알림', 우리 동네 소식을 전하는 '하이퍼로컬 메시징', 관심사를 따라 흐르는 '태그 구독' 등 과거에는 구현하기 까다로웠던 창의적인 기능들을 현실로 만들 수 있는 강력한 무기가 됩니다.

FCM의 숨겨진 잠재력을 100% 활용하여, 당신의 애플리케이션에 더욱 섬세하고 개인화된 소통의 다리를 놓아보시기 바랍니다. 작은 아이디어의 전환이 사용자에게는 커다란 가치와 경험의 차이를 만들어낼 것입니다.

Friday, December 13, 2019

플러터(Flutter) FCM 푸시 알림 클릭이 안 먹히는 현상, 10분 만에 원인 파악하고 해결하기

플러터(Flutter)와 Firebase Cloud Messaging(FCM)을 연동하여 푸시 알림 기능을 구현하는 것은 이제 많은 앱의 필수적인 요소가 되었습니다. Firebase의 훌륭한 문서와 Flutter의 편리한 firebase_messaging 패키지 덕분에 기본적인 알림 수신까지는 비교적 순조롭게 진행됩니다. 하지만 많은 개발자들이 거의 예외 없이 부딪히는 거대한 장벽이 하나 있습니다. 바로 "분명히 알림은 오는데, 알림을 탭(클릭)해도 아무런 반응이 없는" 현상입니다.

앱이 실행 중일 때(Foreground)는 onMessage가 잘 호출되지만, 앱이 백그라운드에 있거나 완전히 종료된 상태에서 수신된 알림을 클릭했을 때 약속된 onMessageOpenedApp이나 getInitialMessage가 전혀 동작하지 않는 이 문제는 개발자를 깊은 좌절에 빠뜨립니다. 패키지에 버그가 있는 것은 아닌지, 내 Flutter 코드에 문제가 있는 것인지 의심하며 수많은 GitHub 이슈와 Stack Overflow 질문들을 헤매게 됩니다. 하지만 놀랍게도 이 문제의 99%는 Flutter 코드나 패키지 버그가 아닌, FCM 메시지를 보내는 방식, 즉 서버 측 페이로드(Payload) 구성의 문제입니다.

이 글에서는 바로 이 문제의 근본적인 원인을 명확하게 파헤치고, 다시는 이 문제로 시간을 낭비하지 않도록 확실한 해결책과 단계별 구현 가이드, 그리고 효과적인 테스트 방법까지 총정리하여 제시합니다. 이 글을 끝까지 읽으신다면, 더 이상 동작하지 않는 푸시 알림 때문에 스트레스받는 일은 없을 것입니다.

문제의 핵심: Notification 메시지와 Data 메시지의 결정적 차이

이 모든 혼란의 시작은 FCM이 메시지를 처리하는 두 가지 주요 방식, 'Notification 메시지'와 'Data 메시지'의 차이를 이해하는 것에서부터 출발합니다. 많은 개발자들이 이 둘을 명확히 구분하지 않고 사용하기 때문에 문제가 발생합니다.


1. Notification 메시지 (알림 메시지)

Notification 메시지는 가장 간단하고 직관적인 방식입니다. Firebase 콘솔의 'Cloud Messaging' 섹션에서 '알림 보내기'를 통해 손쉽게 생성할 수 있는 메시지가 바로 이것입니다. 이 메시지는 앱의 상태(포그라운드, 백그라운드, 종료)에 따라 동작 방식이 완전히 달라집니다.

  • 구조: JSON 페이로드에서 notification 키를 가집니다. 이 안에는 title, body, image 등 시각적인 요소를 정의하는 필드들이 포함됩니다.
  • 동작 방식:
    • 앱이 포그라운드(Foreground)일 때: 이 메시지는 Flutter 앱의 FirebaseMessaging.onMessage 스트림으로 전달됩니다. 개발자는 이 데이터를 받아 인앱 팝업을 띄우거나 UI를 업데이트하는 등 자유롭게 처리할 수 있습니다. 이때 시스템 알림 트레이에는 아무것도 표시되지 않습니다.
    • 앱이 백그라운드(Background) 또는 종료(Terminated) 상태일 때: 이 메시지는 Flutter 앱으로 전달되지 않고, 모바일 OS(Android, iOS)의 시스템이 직접 처리합니다. 시스템은 notification 객체의 정보를 바탕으로 표준적인 시스템 알림을 생성하여 상단 바(알림 트레이)에 표시합니다.
  • 치명적인 함정: 앱이 백그라운드/종료 상태일 때 OS가 알림을 직접 처리하기 때문에, 사용자가 이 알림을 탭해도 기본적으로는 그냥 앱을 실행시키는 동작만 할 뿐, Flutter 코드의 onMessageOpenedApp이나 getInitialMessage와 같은 특정 핸들러를 깨우지 않습니다. 이것이 바로 우리가 겪는 문제의 근본 원인입니다. 사용자가 알림을 통해 어떤 특정 페이지로 이동하거나 특정 데이터를 처리하게 만들고 싶은 우리의 의도와는 다르게 동작하는 것입니다.

// Notification 메시지 페이로드 예시
{
  "to": "FCM_DEVICE_TOKEN",
  "notification": {
    "title": "새로운 소식!",
    "body": "앱을 확인하여 최신 업데이트를 보세요.",
    "sound": "default"
  }
}

2. Data 메시지 (데이터 메시지)

Data 메시지는 이름 그대로 UI 요소 없이 데이터만 전달하는 데 사용되는 '조용한' 메시지입니다. 이 메시지의 처리 책임은 전적으로 애플리케이션에 있습니다.

  • 구조: JSON 페이로드에서 data 키를 가집니다. 이 안에는 개발자가 정의한 임의의 Key-Value 쌍 데이터가 포함됩니다. (예: "screen": "profile", "itemId": "12345")
  • 동작 방식:
    • 앱이 포그라운드(Foreground)일 때: Notification 메시지와 동일하게 FirebaseMessaging.onMessage 스트림으로 전달됩니다.
    • 앱이 백그라운드(Background) 또는 종료(Terminated) 상태일 때: 이 메시지는 Flutter 앱의 백그라운드 메시지 핸들러(FirebaseMessaging.onBackgroundMessage)로 전달됩니다. OS가 멋대로 처리하는 것이 아니라, 우리 앱의 코드가 깨어나서 이 데이터를 수신합니다.
  • 중요한 특징: Data 메시지는 그 자체로는 사용자에게 시각적인 알림을 보여주지 않습니다. 따라서 백그라운드/종료 상태에서 사용자에게 알림을 보여주려면, onBackgroundMessage 핸들러 내에서 수신한 데이터를 바탕으로 flutter_local_notifications와 같은 로컬 알림 패키지를 사용하여 직접 알림을 생성하고 표시해야 합니다. 이는 번거롭지만, 알림의 모든 측면(아이콘, 채널, 액션 등)을 완벽하게 제어할 수 있다는 강력한 장점을 가집니다.

// Data 메시지 페이로드 예시
{
  "to": "FCM_DEVICE_TOKEN",
  "data": {
    "type": "chat",
    "senderId": "user_abc",
    "message": "안녕하세요! 답장 기다릴게요.",
    "screen_to_navigate": "/chat/user_abc"
  },
  "priority": "high" // 백그라운드에서 즉시 처리되도록 우선순위를 높여주는 것이 좋습니다.
}

3. 하이브리드 메시지 (Notification + Data) - 최고의 해결책

그렇다면 우리는 항상 로컬 알림을 직접 만들어야 하는 번거로움을 감수해야 할까요? 그렇지 않습니다. FCM은 Notification과 Data를 함께 보내는 하이브리드 방식을 지원하며, 이것이 바로 우리가 겪는 문제를 해결하는 가장 우아하고 권장되는 방법입니다.

  • 구조: JSON 페이로드에 notificationdata 키를 모두 포함합니다.
  • 동작 방식:
    • 앱이 포그라운드(Foreground)일 때: onMessage 핸들러로 notificationdata 객체가 모두 전달됩니다.
    • 앱이 백그라운드(Background) 또는 종료(Terminated) 상태일 때:
      1. OS 시스템이 notification 객체의 내용을 바탕으로 시스템 알림을 자동으로 표시해줍니다. (로컬 알림을 직접 생성할 필요 없음!)
      2. 사용자가 이 알림을 탭하면, OS는 앱을 깨우고 data 객체에 담긴 데이터를 Flutter 앱의 상호작용 핸들러(onMessageOpenedApp 또는 getInitialMessage)로 전달해줍니다.
  • 결론: 이 방식은 Notification 메시지의 편리함(자동 알림 표시)과 Data 메시지의 강력함(탭 했을 때 데이터 전달 및 앱 제어)을 모두 누릴 수 있는 최적의 조합입니다.

// 하이브리드 메시지 페이로드 예시 (강력 추천)
{
  "to": "FCM_DEVICE_TOKEN",
  "notification": {
    "title": "주문이 배송 시작되었습니다!",
    "body": "주문 #12345의 배송 현황을 확인해보세요."
  },
  "data": {
    "order_id": "12345",
    "screen_to_navigate": "/order_details"
  }
}

왜 Firebase 콘솔 테스트 메시지는 앱을 실행시키지 못할까?

이제 원인이 명확해졌습니다. 많은 개발자들이 처음 FCM을 테스트할 때 사용하는 Firebase 콘솔의 '알림 작성' 도구는 기본적으로 Notification 메시지만을 생성하기 때문입니다.

Firebase Console Notification Composer

위 이미지의 '알림 제목'과 '알림 텍스트' 필드를 채우면 notification 객체가 만들어집니다. 만약 여기서 '추가 옵션'을 열어 '맞춤 데이터' 또는 '데이터' 섹션에 Key-Value 쌍을 추가하지 않는다면, 전송되는 페이로드에는 data 객체가 포함되지 않습니다. 그 결과, 백그라운드/종료 상태에서 수신된 알림은 OS가 처리하고, 탭 해도 앱은 그저 실행될 뿐, 우리가 원하는 특정 동작을 수행할 수 없었던 것입니다.

해결의 실마리: Firebase 콘솔에서 테스트할 때도, 반드시 '맞춤 데이터'(Key-Value)를 한 쌍 이상 추가하여 하이브리드 메시지 형태로 보내야만 백그라운드 탭 동작을 정상적으로 테스트할 수 있습니다.

해결책 1 (가장 권장): `data` 페이로드를 함께 전송하기

실제 프로덕션 환경에서는 Firebase 콘솔이 아닌 자체 백엔드 서버에서 API를 통해 FCM 메시지를 전송하게 됩니다. 이때, 반드시 notificationdata를 모두 포함하는 하이브리드 형태로 페이로드를 구성해야 합니다.

최신 FCM v1 API와 레거시 HTTP API의 페이로드 예시는 다음과 같습니다.

FCM v1 API (HTTP v1) 페이로드 예시

현재 Firebase에서 권장하는 최신 방식입니다. 더 안전하고 기능이 많으므로 새로 구현한다면 반드시 v1 API를 사용해야 합니다.


// POST https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send

{
  "message": {
    "token": "FCM_DEVICE_TOKEN",
    "notification": {
      "title": "새로운 댓글이 달렸습니다",
      "body": "'플러터 정말 최고네요' 글에 댓글이 달렸어요."
    },
    "data": {
      "postId": "flutter_is_best_101",
      "type": "new_comment",
      "deep_link": "myapp://posts/flutter_is_best_101"
    },
    "android": { // Android 플랫폼별 세부 설정
      "notification": {
        "channel_id": "new_comment_channel",
        "click_action": "FLUTTER_NOTIFICATION_CLICK" // 하위 호환성을 위해 여전히 유용할 수 있음
      }
    },
    "apns": { // iOS 플랫폼별 세부 설정
      "payload": {
        "aps": {
          "category": "NEW_COMMENT_CATEGORY",
          "sound": "default"
        }
      }
    }
  }
}

위 예시에서 볼 수 있듯이, message 객체 안에 notificationdata를 나란히 배치하는 것이 핵심입니다.

레거시 HTTP API 페이로드 예시

아직 v1으로 마이그레이션하지 않은 기존 시스템에서 사용하는 방식입니다.


// POST https://fcm.googleapis.com/fcm/send

{
  "to": "FCM_DEVICE_TOKEN",
  "priority": "high",
  "notification": {
    "title": "새로운 댓글이 달렸습니다",
    "body": "'플러터 정말 최고네요' 글에 댓글이 달렸어요.",
    "sound": "default",
    "click_action": "FLUTTER_NOTIFICATION_CLICK" // 레거시에서는 이 부분이 중요!
  },
  "data": {
    "postId": "flutter_is_best_101",
    "type": "new_comment",
    "deep_link": "myapp://posts/flutter_is_best_101"
  }
}

레거시 방식에서는 notification 객체 안에 click_action: "FLUTTER_NOTIFICATION_CLICK"을 명시해주는 것이 안드로이드에서 탭 동작을 보장하는 데 중요한 역할을 합니다. (자세한 내용은 해결책 2에서 다룹니다)

해결책 2 (레거시 방식 및 보조 수단): `click_action` 사용하기

만약 어떤 이유로든 data 페이로드를 사용할 수 없고, 오직 Notification 메시지만을 보내야 하는 상황이라면 (예: 일부 마케팅 자동화 툴과의 연동), 안드로이드에 한해 차선책이 존재합니다. 바로 click_action을 이용하는 것입니다.

이 방법은 안드로이드 OS에게 "이 종류의 알림을 탭하면, 기본 앱 실행 동작 대신 특정 인텐트(Intent)를 발생시켜줘!"라고 알려주는 것과 같습니다. firebase_messaging 패키지는 이 특정 인텐트를 감지하여 Flutter 앱의 핸들러를 깨우도록 미리 설정되어 있습니다.

1단계: AndroidManifest.xml 파일 수정

android/app/src/main/AndroidManifest.xml 파일을 열고, MainActivity를 선언하는 <activity> 태그 내부에 새로운 <intent-filter>를 추가합니다.


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.my_app">
    <application ...>
        <activity
            android:name=".MainActivity"
            ...>
            <!-- 일반적인 앱 실행을 위한 인텐트 필터 -->
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>

            <!-- FCM 알림 클릭을 처리하기 위한 인텐트 필터 (이 부분을 추가!) -->
            <intent-filter>
                <action android:name="FLUTTER_NOTIFICATION_CLICK" />
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
            
        </activity>
        ...
    </application>
</manifest>

이 코드는 FLUTTER_NOTIFICATION_CLICK이라는 액션을 가진 인텐트가 발생했을 때, 우리의 MainActivity를 실행하도록 안드로이드 시스템에 등록하는 역할을 합니다.

2단계: FCM 페이로드에 `click_action` 추가

이제 서버에서 Notification 메시지를 보낼 때, notification 객체 내부에 click_action 키와 값으로 "FLUTTER_NOTIFICATION_CLICK"을 포함시켜야 합니다.


// 레거시 HTTP API에서 click_action 사용 예시
{
  "to": "FCM_DEVICE_TOKEN",
  "notification": {
    "title": "이벤트 알림",
    "body": "특별 할인 이벤트가 시작되었습니다! 탭하여 확인하세요.",
    "click_action": "FLUTTER_NOTIFICATION_CLICK" // 바로 이 부분!
  }
  // data 객체가 없어도 동작함
}

이렇게 하면, 백그라운드/종료 상태에서 이 알림을 탭했을 때 안드로이드 시스템은 FLUTTER_NOTIFICATION_CLICK 인텐트를 발생시키고, AndroidManifest.xml 설정에 따라 앱이 실행되면서 firebase_messaging 패키지가 이 이벤트를 감지하여 onMessageOpenedApp 또는 getInitialMessage로 알림 정보를 전달해줍니다.

주의사항: 이 방법은 data 페이로드가 없기 때문에 알림을 탭했을 때 추가적인 데이터를 전달할 수 없다는 명백한 한계가 있습니다. 또한 주로 안드로이드에서 사용되는 방식이며, iOS에서는 다른 메커니즘으로 동작하므로 범용적인 해결책은 아닙니다. 따라서 가급적이면 해결책 1인 하이브리드 메시지 방식을 사용하는 것을 강력히 권장합니다.

Flutter 앱 완벽 구현: 단계별 코드 가이드

이제 원인과 해결책을 모두 알았으니, Flutter 앱에서 FCM 메시지를 완벽하게 처리하는 전체 코드를 단계별로 작성해 보겠습니다.

1. Firebase 설정 및 패키지 추가

먼저, Flutter 프로젝트에 Firebase를 연동하고 필요한 패키지를 추가합니다. (Firebase 공식 문서 참조)

pubspec.yaml 파일에 다음 패키지들을 추가하세요.


dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^2.27.0 # 최신 버전으로 사용
  firebase_messaging: ^14.7.19 # 최신 버전으로 사용
  flutter_local_notifications: ^17.0.0 # 포그라운드 알림 표시에 유용

2. `main.dart` 초기화 및 백그라운드 핸들러 설정

FCM의 가장 중요한 규칙 중 하나는 백그라운드 메시지 핸들러가 반드시 최상위(top-level) 함수여야 한다는 것입니다. 즉, 어떤 클래스의 메서드여서는 안 됩니다. main 함수 바깥에 핸들러를 정의해야 합니다.

main.dart 파일의 상단에 다음 코드를 추가합니다.


import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';

// ✨ 백그라운드 메시지 핸들러는 반드시 최상위 함수여야 합니다.
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // 백그라운드에서 메시지를 처리해야 할 경우, 여기서 Firebase를 초기화해야 할 수도 있습니다.
  await Firebase.initializeApp();

  print("백그라운드에서 메시지를 처리 중입니다: ${message.messageId}");
  print("백그라운드 메시지 데이터: ${message.data}");
  // 여기서 로컬 알림을 띄우거나, 데이터를 로컬 DB에 저장하는 등의 작업을 할 수 있습니다.
}

그리고 main 함수를 수정하여 Firebase를 초기화하고 백그라운드 핸들러를 등록합니다.


Future<void> main() async {
  // Flutter 엔진과 위젯 트리의 바인딩을 보장합니다.
  WidgetsFlutterBinding.ensureInitialized();
  
  // Firebase 앱을 초기화합니다.
  await Firebase.initializeApp();
  
  // 백그라운드 메시지 핸들러를 설정합니다.
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  runApp(const MyApp());
}

3. 알림 권한 요청 및 메시지 스트림 리스닝

앱이 시작될 때 사용자에게 알림 권한을 요청하고, 다양한 상태(포그라운드, 백그라운드 탭, 종료 상태 탭)의 메시지를 처리하는 로직을 작성합니다. 보통 StatefulWidgetinitState에서 이 설정 함수를 호출합니다.


class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String _messageData = "메시지 데이터가 여기에 표시됩니다.";

  @override
  void initState() {
    super.initState();
    _setupInteractedMessage();
  }

  Future<void> _setupInteractedMessage() async {
    // 1. 알림 권한 요청
    FirebaseMessaging messaging = FirebaseMessaging.instance;
    NotificationSettings settings = await messaging.requestPermission(
      alert: true,
      announcement: false,
      badge: true,
      carPlay: false,
      criticalAlert: false,
      provisional: false,
      sound: true,
    );

    print('사용자에게 부여된 권한: ${settings.authorizationStatus}');

    // 2. 포그라운드 메시지 처리
    // 앱이 실행 중일 때 메시지를 수신하면 호출됩니다.
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      print('포그라운드에서 메시지를 받았습니다!');
      print('메시지 데이터: ${message.data}');

      if (message.notification != null) {
        print('메시지는 알림을 포함하고 있습니다: ${message.notification}');
        // 여기서 flutter_local_notifications 등을 사용하여 인앱 알림 UI를 보여줄 수 있습니다.
        setState(() {
          _messageData = "포그라운드 수신: ${message.notification?.title ?? ''}";
        });
      }
    });

    // 3. 백그라운드 상태에서 알림을 탭했을 때 처리
    // 앱이 백그라운드에 있지만 종료되지는 않은 상태에서 사용자가 알림을 탭하면 호출됩니다.
    FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
      print('백그라운드에서 알림이 탭되었습니다.');
      print('메시지 데이터: ${message.data}');
      _handleMessage(message);
    });

    // 4. 앱이 종료된 상태에서 알림을 탭했을 때 처리
    // 앱이 완전히 종료된 상태에서 사용자가 알림을 탭하면, 이 메서드가 Future<RemoteMessage?>를 반환합니다.
    RemoteMessage? initialMessage = await FirebaseMessaging.instance.getInitialMessage();
    if (initialMessage != null) {
      print('종료 상태에서 알림이 탭되었습니다.');
      print('메시지 데이터: ${initialMessage.data}');
      _handleMessage(initialMessage);
    }
  }

  // 메시지 데이터를 기반으로 특정 페이지로 이동하는 등의 로직 처리
  void _handleMessage(RemoteMessage message) {
    setState(() {
      _messageData = "알림 탭! 데이터: ${message.data.toString()}";
    });
    
    if (message.data['screen_to_navigate'] == '/order_details') {
      final orderId = message.data['order_id'];
      // Navigator.push(context, MaterialPageRoute(builder: (context) => OrderDetailsScreen(orderId: orderId)));
      print("주문 상세 페이지로 이동합니다. 주문 ID: $orderId");
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('FCM 완벽 가이드')),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Text('수신된 메시지 정보:', style: TextStyle(fontSize: 18)),
              const SizedBox(height: 10),
              Text(_messageData),
            ],
          ),
        ),
      ),
    );
  }
}

테스트 및 디버깅 전략

이제 코드는 완성되었습니다. 가장 중요한 것은 올바른 페이로드로 테스트하는 것입니다. 앞서 설명했듯이, Firebase 콘솔만으로는 한계가 명확합니다. 따라서 API 클라이언트 도구를 사용하여 직접 페이로드를 구성하고 전송하는 방법을 익혀야 합니다.

cURL을 이용한 직접 테스트

터미널에서 cURL 명령어를 사용하면 가장 빠르고 확실하게 테스트할 수 있습니다.

1. FCM v1 API 테스트용 cURL 명령어

v1 API는 OAuth 2.0 토큰 인증이 필요하여 약간 더 복잡하지만 훨씬 안전합니다.

  1. Google Cloud SDK 설치 후 gcloud auth application-default print-access-token 명령으로 액세스 토큰을 발급받습니다.
  2. Firebase 프로젝트 ID와 테스트할 기기의 FCM 토큰을 준비합니다.

curl -X POST \
  -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
  -H "Content-Type: application/json" \
  "https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send" \
  -d '{
        "message": {
          "token": "YOUR_DEVICE_FCM_TOKEN",
          "notification": {
            "title": "cURL 테스트 (v1)",
            "body": "이 알림을 탭하면 데이터가 전달되어야 합니다."
          },
          "data": {
            "test_id": "curl-v1-1234",
            "screen_to_navigate": "/order_details",
            "order_id": "98765"
          }
        }
      }'

2. 레거시 HTTP API 테스트용 cURL 명령어

Firebase 콘솔의 `프로젝트 설정 > 클라우드 메시징` 탭에서 '서버 키'를 복사하여 사용합니다.


curl -X POST \
  -H "Authorization: key=YOUR_LEGACY_SERVER_KEY" \
  -H "Content-Type: application/json" \
  "https://fcm.googleapis.com/fcm/send" \
  -d '{
        "to": "YOUR_DEVICE_FCM_TOKEN",
        "priority": "high",
        "notification": {
          "title": "cURL 테스트 (Legacy)",
          "body": "이 알림을 탭하면 데이터가 전달되어야 합니다.",
          "click_action": "FLUTTER_NOTIFICATION_CLICK"
        },
        "data": {
          "test_id": "curl-legacy-5678",
          "screen_to_navigate": "/order_details",
          "order_id": "98765"
        }
      }'

이 명령어들을 사용하여 메시지를 보냈을 때, 앱이 백그라운드/종료 상태에서 알림을 탭하면 Flutter 앱의 디버그 콘솔에 "백그라운드에서 알림이 탭되었습니다." 또는 "종료 상태에서 알림이 탭되었습니다."라는 로그와 함께 `data` 객체의 내용이 출력되어야 합니다. 만약 그렇다면, 당신은 성공한 것입니다!

결론: 메시지 페이로드가 모든 것의 열쇠다

플러터에서 FCM 알림 탭이 동작하지 않는 문제는 패키지의 버그나 복잡한 네이티브 코드의 문제일 것이라는 선입견을 갖기 쉽습니다. 하지만 오늘 살펴본 바와 같이, 그 원인은 대부분 서버에서 보내는 메시지 페이로드의 구조에 있었습니다.

이 문제로 고민하고 있다면 다음 세 가지를 기억하세요.

  1. Notification 메시지와 Data 메시지를 명확히 구분하세요. 전자는 OS가, 후자는 내 앱이 처리합니다.
  2. 가장 확실한 해결책은 Notification과 Data를 함께 보내는 '하이브리드 메시지'입니다. 이것이 알림의 편리함과 앱 제어의 유연성을 모두 잡는 방법입니다.
  3. Firebase 콘솔의 기본 알림만으로 테스트하지 마세요. 반드시 '맞춤 데이터'를 추가하거나, cURL, Postman과 같은 API 도구로 정확한 하이브리드 페이로드를 만들어 테스트해야 합니다.

이제 여러분은 FCM 푸시 알림이 클릭되지 않는 문제의 원인과 해결책을 완벽하게 이해했습니다. 이 지식을 바탕으로 사용자에게 풍부한 알림 경험을 제공하고, 알림을 통해 사용자를 앱의 정확한 위치로引导하는 강력한 기능을 자신 있게 구현하시길 바랍니다.