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 뿐만 아니라 다른 어떤 플러그인을 백그라운드에서 사용하게 되더라도 당황하지 않고 문제를 해결해 나갈 수 있을 것입니다. 복잡해 보이는 크로스플랫폼의 이면을 이해하고 문제를 해결하는 것, 이것이야말로 개발의 진정한 묘미가 아닐까요?


1 comment:

  1. 앱이 종료 됐을 때 푸시알람이 왔을 때 콜백함수로 Sqlite 데이터 베이스에 데이터 insert 할 때도 SQLite 관련 패키지를 로딩하기 위한 코틀린 코드를 추가 해야 할까요?

    ReplyDelete