본문으로 건너뛰기

Flutter 푸시 알림 가이드

작성일: 2026-04-06 Last Updated: 2026-04-06 대상: Flutter Mobile App (iOS / Android) 공통 참조: ../common/api-integration.md (API 연동 패턴)


목차

  1. 푸시 알림 아키텍처
  2. FCM 설정 (Firebase Cloud Messaging)
  3. APNs 설정 (Apple Push Notification service)
  4. Flutter 클라이언트 구현
  5. 로컬 알림 (flutter_local_notifications)
  6. 알림 채널 관리 (Android)
  7. 딥 링크 연동
  8. 백그라운드 메시지 처리
  9. 서버 측 구현 (Laravel)
  10. 토픽 구독 및 사용자 타겟팅
  11. 알림 권한 요청 UX
  12. 체크리스트

1. 푸시 알림 아키텍처

전체 흐름

Laravel 서버

├── Firebase Admin SDK ──→ FCM 서버 ──→ Android 디바이스
│ │
│ FCM 서버 ──→ APNs ──→ iOS 디바이스

└── 토큰 DB 저장 (device_tokens 테이블)

Flutter 앱 (FCM 토큰 등록)

메시지 유형

유형설명앱 포그라운드앱 백그라운드앱 종료
Notification시스템 트레이 알림자동 표시 X (핸들러)자동 표시 O자동 표시 O
Data커스텀 데이터핸들러 호출핸들러 호출핸들러 호출
Notification + Data혼합핸들러 호출트레이 표시 + 탭 시 데이터트레이 표시 + 탭 시 데이터

권장: Data 전용 메시지 + flutter_local_notifications로 직접 표시 (모든 상태에서 제어 가능).


2. FCM 설정 (Firebase Cloud Messaging)

Firebase 프로젝트 설정

단계작업
1Firebase Console (https://console.firebase.google.com) 프로젝트 생성
2Android 앱 추가: 패키지명 입력 -> google-services.json 다운로드
3iOS 앱 추가: Bundle ID 입력 -> GoogleService-Info.plist 다운로드
4APNs 키 업로드 (iOS 푸시용)

Flutter 패키지 설정

# pubspec.yaml
dependencies:
firebase_core: ^3.0.0
firebase_messaging: ^15.0.0
flutter_local_notifications: ^18.0.0

Android 설정

// android/build.gradle
buildscript {
dependencies {
classpath 'com.google.gms:google-services:4.4.2'
}
}

// android/app/build.gradle
apply plugin: 'com.google.gms.google-services'
android/app/
├── google-services.json # Firebase 설정 (Git 제외 권장)
└── src/main/AndroidManifest.xml

google-services.json Flavor별 관리

android/app/src/
├── dev/google-services.json # Dev Firebase 프로젝트
├── staging/google-services.json # Staging Firebase 프로젝트
└── prod/google-services.json # Prod Firebase 프로젝트

3. APNs 설정 (Apple Push Notification service)

APNs 인증 키 (권장)

단계작업
1Apple Developer > Keys > 새 키 생성
2"Apple Push Notifications service (APNs)" 체크
3.p8 키 파일 다운로드 (1회만 가능, 안전 보관)
4Firebase Console > Project Settings > Cloud Messaging
5iOS 앱 > APNs Authentication Key 업로드

APNs 키 vs 인증서

항목APNs Key (.p8)APNs Certificate (.p12)
유효기간무기한1년 (갱신 필요)
환경Sandbox + Production 공용환경별 별도
관리간편복잡
권장OX

iOS Capability 설정

Xcode에서:

  1. Signing & Capabilities > + Capability
  2. Push Notifications 추가
  3. Background Modes 추가 > Remote notifications 체크
ios/Runner/Runner.entitlements:
aps-environment: development (또는 production)

4. Flutter 클라이언트 구현

Firebase 초기화 + FCM 토큰 등록

// core/services/push_notification_service.dart

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

class PushNotificationService {
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();

/// 초기화 (앱 시작 시 호출)
Future<void> initialize() async {
await Firebase.initializeApp();

// 1. FCM 토큰 조회 및 서버 등록
final token = await _messaging.getToken();
if (token != null) {
await _registerTokenOnServer(token);
}

// 2. 토큰 갱신 리스너
_messaging.onTokenRefresh.listen(_registerTokenOnServer);

// 3. 포그라운드 메시지 리스너
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);

// 4. 백그라운드 탭 (앱이 백그라운드에 있을 때 알림 탭)
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);

// 5. 앱 종료 상태에서 알림 탭으로 실행
final initialMessage = await _messaging.getInitialMessage();
if (initialMessage != null) {
_handleNotificationTap(initialMessage);
}

// 6. 로컬 알림 초기화
await _initializeLocalNotifications();
}

/// FCM 토큰을 서버에 등록
Future<void> _registerTokenOnServer(String token) async {
// POST /api/v1/device-tokens
// { "token": token, "platform": "ios" | "android" }
await apiClient.post('/device-tokens', data: {
'token': token,
'platform': Platform.isIOS ? 'ios' : 'android',
'device_name': await _getDeviceName(),
});
}

/// 포그라운드 메시지 처리
void _handleForegroundMessage(RemoteMessage message) {
// Data 메시지를 로컬 알림으로 표시
final data = message.data;
_showLocalNotification(
title: data['title'] ?? '',
body: data['body'] ?? '',
payload: jsonEncode(data),
channelId: data['channel'] ?? 'default',
);
}

/// 알림 탭 처리 (딥 링크)
void _handleNotificationTap(RemoteMessage message) {
final route = message.data['route'];
if (route != null) {
// GoRouter로 네비게이션
router.push(route);
}
}
}

FCM 토큰 관리

// features/auth/data/repositories/auth_repository_impl.dart

@override
Future<void> logout() async {
// 로그아웃 시 FCM 토큰 해제
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
await _remoteDataSource.unregisterDeviceToken(token);
}
await FirebaseMessaging.instance.deleteToken();
await _localDataSource.clearTokens();
}

5. 로컬 알림 (flutter_local_notifications)

초기화

Future<void> _initializeLocalNotifications() async {
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: false, // 별도 타이밍에 요청
requestBadgePermission: false,
requestSoundPermission: false,
);

await _localNotifications.initialize(
const InitializationSettings(
android: androidSettings,
iOS: iosSettings,
),
onDidReceiveNotificationResponse: (response) {
// 알림 탭 시 딥 링크 처리
if (response.payload != null) {
final data = jsonDecode(response.payload!);
final route = data['route'];
if (route != null) router.push(route);
}
},
);
}

로컬 알림 표시

Future<void> _showLocalNotification({
required String title,
required String body,
String? payload,
String channelId = 'default',
}) async {
await _localNotifications.show(
DateTime.now().millisecondsSinceEpoch ~/ 1000, // 고유 ID
title,
body,
NotificationDetails(
android: AndroidNotificationDetails(
channelId,
_getChannelName(channelId),
channelDescription: _getChannelDescription(channelId),
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_notification', // 투명 배경 모노크롬 아이콘
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
payload: payload,
);
}

예약 알림

/// 특정 시간에 로컬 알림 예약
Future<void> scheduleNotification({
required int id,
required String title,
required String body,
required DateTime scheduledDate,
}) async {
await _localNotifications.zonedSchedule(
id,
title,
body,
tz.TZDateTime.from(scheduledDate, tz.local),
const NotificationDetails(
android: AndroidNotificationDetails(
'scheduled',
'예약 알림',
channelDescription: '예약된 알림입니다',
),
iOS: DarwinNotificationDetails(),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
}

6. 알림 채널 관리 (Android)

채널 설계

Android 8.0+ (API 26)부터 알림 채널 필수. 사용자가 채널별로 알림 설정 가능.

채널 ID채널 이름중요도용도
default일반 알림HIGH기본 알림
chat메시지MAX채팅 메시지
updates업데이트DEFAULT앱 업데이트, 공지
marketing마케팅LOW프로모션, 이벤트
system시스템MIN백그라운드 작업 상태

채널 생성

// core/services/notification_channels.dart

Future<void> createNotificationChannels() async {
final plugin = FlutterLocalNotificationsPlugin();
final android = plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();

if (android == null) return;

final channels = [
const AndroidNotificationChannel(
'default',
'일반 알림',
description: '기본 알림입니다',
importance: Importance.high,
),
const AndroidNotificationChannel(
'chat',
'메시지',
description: '새 메시지 알림입니다',
importance: Importance.max,
),
const AndroidNotificationChannel(
'updates',
'업데이트',
description: '앱 업데이트 및 공지사항입니다',
importance: Importance.defaultImportance,
),
const AndroidNotificationChannel(
'marketing',
'마케팅',
description: '프로모션 및 이벤트 알림입니다',
importance: Importance.low,
),
];

for (final channel in channels) {
await android.createNotificationChannel(channel);
}
}

서버에서 채널 지정

{
"data": {
"title": "새 메시지",
"body": "홍길동님이 메시지를 보냈습니다",
"channel": "chat",
"route": "/chat/123"
}
}

7. 딥 링크 연동

알림 -> 특정 화면 이동

// 알림 데이터 구조 (서버에서 전송)
{
"data": {
"title": "주문 배송 시작",
"body": "주문 #12345가 배송을 시작했습니다",
"route": "/orders/12345", // GoRouter 경로
"channel": "updates",
"action": "order_shipped", // 분석용 액션 태그
"image_url": "https://..." // 큰 이미지 (선택)
}
}

딥 링크 라우팅 처리

// app/router.dart 에서 알림 딥 링크 처리

class NotificationRouter {
final GoRouter _router;

NotificationRouter(this._router);

/// 알림 데이터에서 라우트 추출 후 네비게이션
void handleNotificationRoute(Map<String, dynamic> data) {
final route = data['route'] as String?;
if (route == null || route.isEmpty) return;

// 인증 상태 확인 후 라우팅
// (미인증 시 로그인 -> 원래 라우트로 리다이렉트)
_router.push(route);
}

/// 앱 종료 상태에서 알림으로 시작 시 초기 라우트 결정
String? getInitialRoute(RemoteMessage? message) {
if (message == null) return null;
return message.data['route'] as String?;
}
}

리치 알림 (이미지 포함)

// Android: BigPicture 스타일
Future<void> _showRichNotification({
required String title,
required String body,
required String imageUrl,
String? payload,
}) async {
// 이미지 다운로드
final response = await http.get(Uri.parse(imageUrl));
final bigPicture = ByteArrayAndroidBitmap(response.bodyBytes);

await _localNotifications.show(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title,
body,
NotificationDetails(
android: AndroidNotificationDetails(
'default',
'일반 알림',
styleInformation: BigPictureStyleInformation(
bigPicture,
contentTitle: title,
summaryText: body,
),
),
iOS: DarwinNotificationDetails(
// iOS: Notification Service Extension으로 이미지 처리
),
),
payload: payload,
);
}

8. 백그라운드 메시지 처리

백그라운드 핸들러 (Top-Level 함수)

// main.dart (또는 별도 파일)

/// 백그라운드 메시지 핸들러 (반드시 top-level 함수)
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Firebase 재초기화 (백그라운드에서는 별도 Isolate)
await Firebase.initializeApp();

// 백그라운드 데이터 처리
final data = message.data;

// 예: 로컬 DB 업데이트, 캐시 갱신
switch (data['action']) {
case 'sync_data':
await _syncLocalData(data);
break;
case 'update_badge':
await _updateBadgeCount(int.parse(data['count'] ?? '0'));
break;
}
}

void main() async {
WidgetsFlutterBinding.ensureInitialized();

// 백그라운드 핸들러 등록 (initialize 전에 등록)
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

await Firebase.initializeApp();
runApp(const MyApp());
}

백그라운드 처리 제약사항

플랫폼제약대응
iOS백그라운드 실행 시간 ~30초무거운 작업 금지, 플래그만 저장
AndroidDoze 모드에서 지연 가능고우선순위 메시지 사용
공통UI 접근 불가로컬 알림으로 사용자에게 알림
공통네트워크 불안정 가능재시도 로직 + 오프라인 큐

iOS 백그라운드 모드

<!-- ios/Runner/Info.plist -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>

9. 서버 측 구현 (Laravel)

패키지 설치

composer require kreait/laravel-firebase

환경 설정

# .env
FIREBASE_CREDENTIALS=storage/app/firebase/service-account.json

디바이스 토큰 관리

// database/migrations/xxxx_create_device_tokens_table.php

Schema::create('device_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('token')->unique();
$table->enum('platform', ['ios', 'android']);
$table->string('device_name')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamps();

$table->index(['user_id', 'platform']);
});
// app/Http/Controllers/Api/DeviceTokenController.php

class DeviceTokenController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'token' => 'required|string|max:500',
'platform' => 'required|in:ios,android',
'device_name' => 'nullable|string|max:255',
]);

$request->user()->deviceTokens()->updateOrCreate(
['token' => $validated['token']],
[
'platform' => $validated['platform'],
'device_name' => $validated['device_name'],
'last_used_at' => now(),
],
);

return response()->json(['message' => 'Token registered']);
}

public function destroy(Request $request): JsonResponse
{
$request->user()->deviceTokens()
->where('token', $request->input('token'))
->delete();

return response()->json(['message' => 'Token removed']);
}
}

푸시 알림 발송 서비스

// app/Services/PushNotificationService.php

use Kreait\Firebase\Messaging;
use Kreait\Firebase\Messaging\CloudMessage;
use Kreait\Firebase\Messaging\Notification;

class PushNotificationService
{
public function __construct(
private readonly Messaging $messaging,
) {}

/**
* 특정 사용자에게 푸시 발송
*/
public function sendToUser(
User $user,
string $title,
string $body,
array $data = [],
): void {
$tokens = $user->deviceTokens()->pluck('token')->toArray();

if (empty($tokens)) {
return;
}

$message = CloudMessage::new()
->withData(array_merge($data, [
'title' => $title,
'body' => $body,
]));

// Data-only 메시지 (클라이언트에서 표시 제어)
$report = $this->messaging->sendMulticast($message, $tokens);

// 실패한 토큰 정리
$this->cleanupFailedTokens($tokens, $report);
}

/**
* 토픽으로 발송
*/
public function sendToTopic(
string $topic,
string $title,
string $body,
array $data = [],
): void {
$message = CloudMessage::withTarget('topic', $topic)
->withData(array_merge($data, [
'title' => $title,
'body' => $body,
]));

$this->messaging->send($message);
}

/**
* 유효하지 않은 토큰 정리
*/
private function cleanupFailedTokens(array $tokens, $report): void
{
foreach ($report->failures()->getItems() as $failure) {
$error = $failure->error();
if ($error && in_array($error->getMessage(), [
'messaging/invalid-registration-token',
'messaging/registration-token-not-registered',
])) {
DeviceToken::where('token', $tokens[$failure->target()])->delete();
}
}
}
}

큐 기반 발송 (Job)

// app/Jobs/SendPushNotification.php

class SendPushNotification implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public function __construct(
private readonly int $userId,
private readonly string $title,
private readonly string $body,
private readonly array $data = [],
) {}

public function handle(PushNotificationService $service): void
{
$user = User::find($this->userId);
if (!$user) return;

$service->sendToUser($user, $this->title, $this->body, $this->data);
}

public int $tries = 3;
public int $backoff = 60;
}

// 사용
SendPushNotification::dispatch(
userId: $order->user_id,
title: '주문 배송 시작',
body: "주문 #{$order->id}가 배송을 시작했습니다",
data: [
'route' => "/orders/{$order->id}",
'channel' => 'updates',
'action' => 'order_shipped',
],
);

10. 토픽 구독 및 사용자 타겟팅

토픽 구독 (클라이언트)

// features/notification/presentation/providers/notification_settings_provider.dart

class NotificationSettingsNotifier extends _$NotificationSettingsNotifier {
@override
Future<NotificationSettings> build() async {
return _loadSettings();
}

/// 토픽 구독
Future<void> subscribeTopic(String topic) async {
await FirebaseMessaging.instance.subscribeToTopic(topic);
// 서버에도 동기화
await ref.read(notificationRepositoryProvider).subscribeTopic(topic);
state = AsyncData(state.value!.copyWith(
topics: {...state.value!.topics, topic},
));
}

/// 토픽 구독 해제
Future<void> unsubscribeTopic(String topic) async {
await FirebaseMessaging.instance.unsubscribeFromTopic(topic);
await ref.read(notificationRepositoryProvider).unsubscribeTopic(topic);
state = AsyncData(state.value!.copyWith(
topics: state.value!.topics.difference({topic}),
));
}
}

토픽 설계 예시

토픽패턴용도
all전체 사용자긴급 공지
tenant_{id}테넌트별조직 공지
workspace_{id}워크스페이스별팀 알림
locale_ko언어별현지화 마케팅
plan_pro구독 플랜별플랜 관련 알림

사용자별 타겟팅 (서버)

// 조건 기반 대상 조회 후 발송
$users = User::query()
->where('tenant_id', $tenantId)
->whereHas('subscription', fn($q) => $q->where('plan', 'pro'))
->get();

foreach ($users as $user) {
SendPushNotification::dispatch(
userId: $user->id,
title: 'Pro 전용 기능 업데이트',
body: '새로운 분석 대시보드가 추가되었습니다',
data: ['route' => '/dashboard/analytics', 'channel' => 'updates'],
);
}

11. 알림 권한 요청 UX

권한 요청 타이밍

시점적합도설명
앱 최초 실행 직후X사용자가 앱 가치를 이해하기 전
온보딩 완료 후O앱 기능 이해 후
관련 기능 사용 시최적메시지 보내기 직전 "알림 받으시겠습니까?"
설정 화면O사용자 주도적 활성화

권한 요청 구현

// core/services/notification_permission_service.dart

class NotificationPermissionService {
/// 알림 권한 요청 (사전 설명 포함)
Future<bool> requestPermission(BuildContext context) async {
// 1. 이미 허용되었는지 확인
final settings = await FirebaseMessaging.instance.getNotificationSettings();
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
return true;
}

// 2. 이미 거부했으면 설정으로 안내 (재요청 불가)
if (settings.authorizationStatus == AuthorizationStatus.denied) {
final goToSettings = await _showSettingsDialog(context);
if (goToSettings) {
await AppSettings.openAppSettings(type: AppSettingsType.notification);
}
return false;
}

// 3. 사전 설명 다이얼로그 표시 (시스템 다이얼로그 전에)
final proceed = await _showPrePermissionDialog(context);
if (!proceed) return false;

// 4. 시스템 권한 요청
final result = await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
provisional: false, // true: iOS 임시 알림 (조용히 전달)
);

// 5. Android 13+ 런타임 권한 (POST_NOTIFICATIONS)
if (Platform.isAndroid) {
final androidPlugin = FlutterLocalNotificationsPlugin()
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
await androidPlugin?.requestNotificationsPermission();
}

return result.authorizationStatus == AuthorizationStatus.authorized;
}

/// 사전 설명 다이얼로그
Future<bool> _showPrePermissionDialog(BuildContext context) async {
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('알림을 받으시겠습니까?'),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_BenefitRow(icon: Icons.message, text: '새 메시지 실시간 알림'),
_BenefitRow(icon: Icons.update, text: '중요 업데이트 안내'),
_BenefitRow(icon: Icons.local_offer, text: '혜택 및 프로모션'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('나중에'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('허용'),
),
],
),
) ?? false;
}
}

알림 설정 화면 (앱 내)

// features/notification/presentation/pages/notification_settings_page.dart

class NotificationSettingsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(notificationSettingsProvider);

return Scaffold(
appBar: AppBar(title: const Text('알림 설정')),
body: settings.when(
data: (data) => ListView(
children: [
SwitchListTile(
title: const Text('메시지 알림'),
subtitle: const Text('새 메시지 수신 시 알림'),
value: data.topics.contains('chat'),
onChanged: (enabled) => _toggleTopic(ref, 'chat', enabled),
),
SwitchListTile(
title: const Text('업데이트 알림'),
subtitle: const Text('앱 업데이트 및 공지사항'),
value: data.topics.contains('updates'),
onChanged: (enabled) => _toggleTopic(ref, 'updates', enabled),
),
SwitchListTile(
title: const Text('마케팅 알림'),
subtitle: const Text('프로모션 및 이벤트'),
value: data.topics.contains('marketing'),
onChanged: (enabled) => _toggleTopic(ref, 'marketing', enabled),
),
],
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('오류: $e')),
),
);
}

void _toggleTopic(WidgetRef ref, String topic, bool enabled) {
final notifier = ref.read(notificationSettingsProvider.notifier);
if (enabled) {
notifier.subscribeTopic(topic);
} else {
notifier.unsubscribeTopic(topic);
}
}
}

12. 체크리스트

푸시 알림 구현 체크리스트

#카테고리항목필수
1FirebaseFirebase 프로젝트 생성 + 앱 등록 (iOS/Android)O
2Firebasegoogle-services.json + GoogleService-Info.plist 배치O
3FirebaseFlavor별 Firebase 설정 분리O
4APNsAPNs 키 (.p8) 생성 + Firebase 업로드O
5APNsXcode Push Notifications + Background Modes 활성화O
6클라이언트FCM 토큰 획득 + 서버 등록O
7클라이언트토큰 갱신 리스너 등록O
8클라이언트포그라운드/백그라운드/종료 상태 처리O
9클라이언트로컬 알림 표시 (Data 메시지)O
10클라이언트Android 알림 채널 생성O
11클라이언트딥 링크 (알림 탭 -> 특정 화면)O
12서버device_tokens 테이블 + APIO
13서버Firebase Admin SDK 설정O
14서버큐 기반 비동기 발송O
15서버실패 토큰 자동 정리O
16UX알림 권한 사전 설명 다이얼로그권장
17UX거부 시 설정 화면 안내권장
18UX앱 내 알림 설정 화면 (토픽별 ON/OFF)권장
19테스트포그라운드/백그라운드/종료 상태별 알림 수신 확인O
20테스트딥 링크 동작 확인 (모든 상태)O

Related: 공통 API 연동 | 아키텍처 | 공통 모니터링