푸시 노티피케이션의 이해
푸시 노티피케이션은 모바일 애플리케이션에서 사용자 참여도를 높이는 핵심 기능입니다. Flutter에서는 주로 Firebase Cloud Messaging(FCM)을 통해 구현하며, 로컬 노티피케이션과 함께 사용하여 완전한 노티피케이션 시스템을 구축할 수 있습니다.
푸시 노티피케이션의 종류:
- 원격 푸시 노티피케이션: 서버에서 전송되는 메시지 (FCM 사용)
- 로컬 노티피케이션: 앱 내에서 예약되는 메시지 (flutter_local_notifications 사용)
주요 구성 요소:
- Firebase Console: 메시지 전송 및 관리
- FCM SDK: 클라이언트 측 메시지 수신
- 디바이스 토큰: 개별 기기 식별자
- 토픽/그룹: 대상 사용자 그룹화
푸시 노티피케이션의 종류:
- 원격 푸시 노티피케이션: 서버에서 전송되는 메시지 (FCM 사용)
- 로컬 노티피케이션: 앱 내에서 예약되는 메시지 (flutter_local_notifications 사용)
주요 구성 요소:
- Firebase Console: 메시지 전송 및 관리
- FCM SDK: 클라이언트 측 메시지 수신
- 디바이스 토큰: 개별 기기 식별자
- 토픽/그룹: 대상 사용자 그룹화
Firebase 프로젝트 설정
Flutter 앱에서 푸시 노티피케이션을 사용하기 위해서는 먼저 Firebase 프로젝트를 설정해야 합니다.
Firebase Console 설정 단계:
1. Firebase Console에서 새 프로젝트 생성
2. Android/iOS 앱 추가
3. 설정 파일 다운로드 (google-services.json, GoogleService-Info.plist)
4. Cloud Messaging API 활성화
Flutter CLI를 통한 자동 설정:
Firebase Console 설정 단계:
1. Firebase Console에서 새 프로젝트 생성
2. Android/iOS 앱 추가
3. 설정 파일 다운로드 (google-services.json, GoogleService-Info.plist)
4. Cloud Messaging API 활성화
Flutter CLI를 통한 자동 설정:
bash
# Firebase CLI 설치
npm install -g firebase-tools
# Firebase 로그인
firebase login
# Flutter 프로젝트에서 Firebase 초기화
flutterfire configure
# 프로젝트 선택 및 플랫폼 설정 진행
필수 의존성 추가
pubspec.yaml 파일에 푸시 노티피케이션에 필요한 패키지들을 추가합니다:
yaml
dependencies:
flutter:
sdk: flutter
# Firebase 관련
firebase_core: ^2.24.2
firebase_messaging: ^14.7.10
# 로컬 노티피케이션
flutter_local_notifications: ^16.3.2
# 상태 관리 (선택사항)
riverpod: ^2.4.9
flutter_riverpod: ^2.4.9
# 권한 처리
permission_handler: ^11.1.0
Android 설정
Android에서 푸시 노티피케이션을 받기 위한 설정을 진행합니다.
android/app/build.gradle 설정:
android/app/build.gradle 설정:
gradle
android {
compileSdkVersion 34
defaultConfig {
minSdkVersion 21 // FCM 최소 요구사항
targetSdkVersion 34
}
}
dependencies {
implementation platform('com.google.firebase:firebase-bom:32.7.0')
implementation 'com.google.firebase:firebase-messaging'
}
// 파일 맨 아래에 추가
apply plugin: 'com.google.gms.google-services'
android/app/src/main/AndroidManifest.xml 설정:
xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 권한 추가 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<!-- Firebase Messaging Service -->
<service
android:name=".MyFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- 기본 채널 설정 -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="default_notification_channel" />
<!-- 알림 아이콘 설정 -->
<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/notification_color" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme">
<!-- 딥링크 처리를 위한 intent-filter -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" />
</intent-filter>
</activity>
</application>
</manifest>
iOS 설정
iOS에서 푸시 노티피케이션을 받기 위한 설정을 진행합니다.
ios/Runner/Info.plist 설정:
ios/Runner/Info.plist 설정:
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 기존 설정들... -->
<!-- 백그라운드 모드 활성화 -->
<key>UIBackgroundModes</key>
<array>
<string>background-processing</string>
<string>remote-notification</string>
</array>
<!-- URL 스킴 설정 -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>myapp.deeplink</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
</dict>
</plist>
ios/Runner/AppDelegate.swift 설정:
swift
import UIKit
import Flutter
import Firebase
import UserNotifications
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Firebase 초기화
FirebaseApp.configure()
// 노티피케이션 권한 요청
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
UNUserNotificationCenter.current().requestAuthorization(
options: authOptions,
completionHandler: { _, _ in }
)
} else {
let settings: UIUserNotificationSettings =
UIUserNotificationSettings(types: [.alert, .badge, .sound], categories: nil)
application.registerUserNotificationSettings(settings)
}
application.registerForRemoteNotifications()
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// APNs 토큰 등록 성공
override func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
}
}
노티피케이션 서비스 구현
이제 Flutter 앱에서 노티피케이션을 처리할 서비스 클래스를 구현해보겠습니다. 체계적인 관리를 위해 별도의 서비스 클래스로 분리합니다:
Dart
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
// 초기화 완료 여부
bool _initialized = false;
/// 노티피케이션 서비스 초기화
Future<void> initialize() async {
if (_initialized) return;
await _initializeFirebaseMessaging();
await _initializeLocalNotifications();
await _setupMessageHandlers();
_initialized = true;
}
/// Firebase Messaging 초기화
Future<void> _initializeFirebaseMessaging() async {
// 권한 요청
NotificationSettings settings = await _firebaseMessaging.requestPermission(
alert: true,
badge: true,
sound: true,
carPlay: false,
criticalAlert: false,
provisional: false,
announcement: false,
);
print('노티피케이션 권한: ${settings.authorizationStatus}');
// 토큰 가져오기
String? token = await _firebaseMessaging.getToken();
print('FCM 토큰: $token');
// 토큰 갱신 리스너
_firebaseMessaging.onTokenRefresh.listen((newToken) {
print('새로운 FCM 토큰: $newToken');
// 서버에 새 토큰 전송
_sendTokenToServer(newToken);
});
}
/// 로컬 노티피케이션 초기화
Future<void> _initializeLocalNotifications() async {
const AndroidInitializationSettings androidSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings iosSettings =
DarwinInitializationSettings(
requestSoundPermission: true,
requestBadgePermission: true,
requestAlertPermission: true,
);
const InitializationSettings initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
await _localNotifications.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationTapped,
);
// Android 노티피케이션 채널 생성
await _createNotificationChannel();
}
/// Android 노티피케이션 채널 생성
Future<void> _createNotificationChannel() async {
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'default_notification_channel', // 채널 ID
'기본 알림', // 채널 이름
description: '앱의 기본 알림 채널입니다.',
importance: Importance.high,
);
await _localNotifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
}
/// 메시지 핸들러 설정
Future<void> _setupMessageHandlers() async {
// 포그라운드 메시지 처리
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
// 백그라운드에서 앱 열림 (노티피케이션 탭)
FirebaseMessaging.onMessageOpenedApp.listen(_handleBackgroundMessage);
// 앱이 종료된 상태에서 노티피케이션으로 앱 실행
RemoteMessage? initialMessage =
await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) {
_handleBackgroundMessage(initialMessage);
}
}
/// 포그라운드에서 메시지 수신 처리
void _handleForegroundMessage(RemoteMessage message) {
print('포그라운드 메시지 수신: ${message.messageId}');
// 포그라운드에서는 로컬 노티피케이션으로 표시
_showLocalNotification(message);
}
/// 백그라운드/종료 상태에서 메시지 처리
void _handleBackgroundMessage(RemoteMessage message) {
print('백그라운드 메시지 처리: ${message.messageId}');
// 데이터에 따른 라우팅 처리 (2편에서 자세히 다룰 예정)
_handleNotificationRouting(message.data);
}
/// 로컬 노티피케이션 표시
Future<void> _showLocalNotification(RemoteMessage message) async {
const AndroidNotificationDetails androidDetails =
AndroidNotificationDetails(
'default_notification_channel',
'기본 알림',
channelDescription: '앱의 기본 알림 채널입니다.',
importance: Importance.high,
priority: Priority.high,
);
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails();
const NotificationDetails notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _localNotifications.show(
message.hashCode,
message.notification?.title ?? '알림',
message.notification?.body ?? '새로운 메시지가 있습니다.',
notificationDetails,
payload: message.data.toString(),
);
}
/// 노티피케이션 탭 처리
void _onNotificationTapped(NotificationResponse response) {
print('노티피케이션 탭: ${response.payload}');
// 라우팅 처리 (2편에서 자세히 다룰 예정)
}
/// 노티피케이션 라우팅 처리
void _handleNotificationRouting(Map<String, dynamic> data) {
// 라우팅 로직 (2편에서 구현)
print('라우팅 데이터: $data');
}
/// 서버에 토큰 전송
Future<void> _sendTokenToServer(String token) async {
// 서버 API 호출하여 토큰 저장
print('서버에 토큰 전송: $token');
}
/// 토픽 구독
Future<void> subscribeToTopic(String topic) async {
await _firebaseMessaging.subscribeToTopic(topic);
print('토픽 구독: $topic');
}
/// 토픽 구독 해제
Future<void> unsubscribeFromTopic(String topic) async {
await _firebaseMessaging.unsubscribeFromTopic(topic);
print('토픽 구독 해제: $topic');
}
}
앱에서 노티피케이션 서비스 사용
main.dart 파일에서 노티피케이션 서비스를 초기화하고 사용하는 방법을 알아보겠습니다:
Dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'firebase_options.dart';
import 'services/notification_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Firebase 초기화
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// 노티피케이션 서비스 초기화
await NotificationService().initialize();
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '푸시 노티피케이션 앱',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const HomeScreen(),
);
}
}
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('푸시 노티피케이션 테스트'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
// 토픽 구독
NotificationService().subscribeToTopic('news');
},
child: const Text('뉴스 토픽 구독'),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// 토픽 구독 해제
NotificationService().unsubscribeFromTopic('news');
},
child: const Text('뉴스 토픽 구독 해제'),
),
],
),
),
);
}
}
백그라운드 메시지 핸들러
앱이 백그라운드나 종료 상태일 때의 메시지를 처리하기 위한 전역 핸들러를 설정해야 합니다. main.dart 파일 상단에 추가합니다:
Dart
import 'package:firebase_messaging/firebase_messaging.dart';
/// 백그라운드 메시지 핸들러 (전역 함수)
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Firebase 초기화 (백그라운드에서 필요)
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
print('백그라운드 메시지 처리: ${message.messageId}');
// 필요한 경우 로컬 스토리지에 저장하거나
// 다른 백그라운드 작업 수행
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Firebase 초기화
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// 백그라운드 메시지 핸들러 등록
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
// 노티피케이션 서비스 초기화
await NotificationService().initialize();
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
테스트 및 디버깅
구현이 완료되었다면 Firebase Console에서 테스트 메시지를 전송하여 정상 작동을 확인해보겠습니다.
Firebase Console에서 테스트 메시지 전송:
1. Firebase Console → Cloud Messaging
2. "첫 번째 캠페인 만들기" 클릭
3. "Firebase 알림 메시지" 선택
4. 제목과 텍스트 입력
5. "테스트 메시지 전송" 클릭
6. FCM 등록 토큰 입력 (콘솔 로그에서 확인)
일반적인 문제 해결:
1. Android에서 노티피케이션이 표시되지 않는 경우
- minSdkVersion이 21 이상인지 확인
- google-services.json 파일이 올바른 위치에 있는지 확인
- 권한이 제대로 설정되어 있는지 확인
2. iOS에서 노티피케이션이 표시되지 않는 경우
- Apple Developer Console에서 Push Notification 기능 활성화
- Provisioning Profile 재생성
- APNs 인증서 설정 확인
3. 토큰을 받지 못하는 경우
- 네트워크 연결 상태 확인
- Firebase 프로젝트 설정 확인
- 앱 권한 상태 확인
Firebase Console에서 테스트 메시지 전송:
1. Firebase Console → Cloud Messaging
2. "첫 번째 캠페인 만들기" 클릭
3. "Firebase 알림 메시지" 선택
4. 제목과 텍스트 입력
5. "테스트 메시지 전송" 클릭
6. FCM 등록 토큰 입력 (콘솔 로그에서 확인)
일반적인 문제 해결:
1. Android에서 노티피케이션이 표시되지 않는 경우
- minSdkVersion이 21 이상인지 확인
- google-services.json 파일이 올바른 위치에 있는지 확인
- 권한이 제대로 설정되어 있는지 확인
2. iOS에서 노티피케이션이 표시되지 않는 경우
- Apple Developer Console에서 Push Notification 기능 활성화
- Provisioning Profile 재생성
- APNs 인증서 설정 확인
3. 토큰을 받지 못하는 경우
- 네트워크 연결 상태 확인
- Firebase 프로젝트 설정 확인
- 앱 권한 상태 확인
다음 편 예고
1편에서는 Flutter 푸시 노티피케이션의 기초 설정과 기본적인 메시지 수신 구현을 다뤘습니다.
2편에서 다룰 내용:
- 노티피케이션 선택 시 특정 화면으로 라우팅하는 방법
- 딥링크와 노티피케이션 데이터 활용
- GoRouter와 함께 사용하는 방법
- 복잡한 라우팅 시나리오 처리
3편에서 다룰 내용:
- 앱 생명주기에 따른 노티피케이션 상태 관리
- 포그라운드/백그라운드/종료 상태별 처리 방법
- 노티피케이션 큐잉과 배치 처리
- 사용자 설정 및 권한 관리
이 시리즈를 통해 완전한 푸시 노티피케이션 시스템을 구축할 수 있을 것입니다.
2편에서 다룰 내용:
- 노티피케이션 선택 시 특정 화면으로 라우팅하는 방법
- 딥링크와 노티피케이션 데이터 활용
- GoRouter와 함께 사용하는 방법
- 복잡한 라우팅 시나리오 처리
3편에서 다룰 내용:
- 앱 생명주기에 따른 노티피케이션 상태 관리
- 포그라운드/백그라운드/종료 상태별 처리 방법
- 노티피케이션 큐잉과 배치 처리
- 사용자 설정 및 권한 관리
이 시리즈를 통해 완전한 푸시 노티피케이션 시스템을 구축할 수 있을 것입니다.