노티피케이션 라우팅의 이해
푸시 노티피케이션에서 라우팅은 사용자가 알림을 선택했을 때 앱의 적절한 화면으로 이동시키는 메커니즘입니다. 효과적인 라우팅 시스템은 사용자 경험을 크게 향상시킬 수 있습니다.
라우팅 시나리오:
- 홈 화면: 일반적인 알림의 경우
- 특정 상세 화면: 게시물, 상품, 메시지 등
- 설정 화면: 권한 요청이나 업데이트 알림
- 외부 링크: 웹페이지나 다른 앱으로 이동
주요 고려사항:
- 앱 상태별 처리 (포그라운드/백그라운드/종료)
- 인증이 필요한 화면 처리
- 딥링크 검증 및 보안
- 네트워크 상태 확인
라우팅 시나리오:
- 홈 화면: 일반적인 알림의 경우
- 특정 상세 화면: 게시물, 상품, 메시지 등
- 설정 화면: 권한 요청이나 업데이트 알림
- 외부 링크: 웹페이지나 다른 앱으로 이동
주요 고려사항:
- 앱 상태별 처리 (포그라운드/백그라운드/종료)
- 인증이 필요한 화면 처리
- 딥링크 검증 및 보안
- 네트워크 상태 확인
라우팅 시스템 설계
체계적인 라우팅 시스템을 구축하기 위해 먼저 노티피케이션 데이터 구조와 라우팅 규칙을 정의해보겠습니다.
Dart
class NotificationData {
final String? route;
final Map<String, String>? params;
final Map<String, String>? queryParams;
final String? externalUrl;
final NotificationType type;
final String? entityId;
final bool requiresAuth;
const NotificationData({
this.route,
this.params,
this.queryParams,
this.externalUrl,
required this.type,
this.entityId,
this.requiresAuth = false,
});
factory NotificationData.fromMap(Map<String, dynamic> map) {
return NotificationData(
route: map['route'],
params: Map<String, String>.from(map['params'] ?? {}),
queryParams: Map<String, String>.from(map['queryParams'] ?? {}),
externalUrl: map['externalUrl'],
type: NotificationType.fromString(map['type'] ?? 'general'),
entityId: map['entityId'],
requiresAuth: map['requiresAuth'] == 'true',
);
}
}
enum NotificationType {
general,
chat,
post,
product,
system,
promotion;
static NotificationType fromString(String value) {
return NotificationType.values.firstWhere(
(type) => type.name == value,
orElse: () => NotificationType.general,
);
}
}
GoRouter 설정
GoRouter를 사용하여 체계적인 라우팅 시스템을 구축합니다.
yaml
dependencies:
go_router: ^12.1.3
riverpod: ^2.4.9
flutter_riverpod: ^2.4.9
Dart
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class AppRoutes {
static const String home = '/';
static const String chat = '/chat';
static const String chatDetail = '/chat/:chatId';
static const String post = '/post/:postId';
static const String product = '/product/:productId';
static const String profile = '/profile/:userId';
static const String settings = '/settings';
static const String login = '/login';
}
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: AppRoutes.home,
redirect: (context, state) {
return null;
},
routes: [
GoRoute(
path: AppRoutes.home,
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: AppRoutes.chat,
builder: (context, state) => const ChatListScreen(),
routes: [
GoRoute(
path: ':chatId',
builder: (context, state) {
final chatId = state.pathParameters['chatId']!;
return ChatDetailScreen(chatId: chatId);
},
),
],
),
GoRoute(
path: AppRoutes.post,
builder: (context, state) {
final postId = state.pathParameters['postId']!;
return PostDetailScreen(postId: postId);
},
),
GoRoute(
path: AppRoutes.product,
builder: (context, state) {
final productId = state.pathParameters['productId']!;
return ProductDetailScreen(productId: productId);
},
),
GoRoute(
path: AppRoutes.profile,
builder: (context, state) {
final userId = state.pathParameters['userId']!;
return ProfileScreen(userId: userId);
},
),
GoRoute(
path: AppRoutes.settings,
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: AppRoutes.login,
builder: (context, state) => const LoginScreen(),
),
],
);
});
노티피케이션 라우팅 서비스
1편에서 만든 NotificationService를 확장하여 라우팅 기능을 추가합니다.
Dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
class NotificationRoutingService {
static final NotificationRoutingService _instance =
NotificationRoutingService._internal();
factory NotificationRoutingService() => _instance;
NotificationRoutingService._internal();
GoRouter? _router;
void setRouter(GoRouter router) {
_router = router;
}
Future<void> handleNotificationRoute(
Map<String, dynamic> notificationData,
) async {
try {
final data = NotificationData.fromMap(notificationData);
if (data.requiresAuth && !await _isUserAuthenticated()) {
await _navigateToLogin();
return;
}
await _routeByType(data);
} catch (e) {
print('라우팅 처리 오류: $e');
_router?.go(AppRoutes.home);
}
}
Future<void> _routeByType(NotificationData data) async {
switch (data.type) {
case NotificationType.chat:
await _handleChatRoute(data);
break;
case NotificationType.post:
await _handlePostRoute(data);
break;
case NotificationType.product:
await _handleProductRoute(data);
break;
case NotificationType.system:
await _handleSystemRoute(data);
break;
case NotificationType.promotion:
await _handlePromotionRoute(data);
break;
case NotificationType.general:
default:
await _handleGeneralRoute(data);
break;
}
}
Future<void> _handleChatRoute(NotificationData data) async {
if (data.entityId != null) {
_router?.go('/chat/\${data.entityId}');
} else {
_router?.go(AppRoutes.chat);
}
}
Future<void> _handlePostRoute(NotificationData data) async {
if (data.entityId != null) {
_router?.go('/post/\${data.entityId}');
} else {
_router?.go(AppRoutes.home);
}
}
Future<void> _handleProductRoute(NotificationData data) async {
if (data.entityId != null) {
_router?.go('/product/\${data.entityId}');
} else {
_router?.go(AppRoutes.home);
}
}
Future<void> _handleSystemRoute(NotificationData data) async {
if (data.route != null) {
_router?.go(data.route!);
} else {
_router?.go(AppRoutes.settings);
}
}
Future<void> _handlePromotionRoute(NotificationData data) async {
if (data.externalUrl != null) {
await _launchExternalUrl(data.externalUrl!);
} else if (data.route != null) {
_router?.go(data.route!);
} else {
_router?.go(AppRoutes.home);
}
}
Future<void> _handleGeneralRoute(NotificationData data) async {
if (data.route != null) {
if (data.queryParams?.isNotEmpty == true) {
final uri = Uri.parse(data.route!);
final newUri = uri.replace(queryParameters: data.queryParams);
_router?.go(newUri.toString());
} else {
_router?.go(data.route!);
}
} else {
_router?.go(AppRoutes.home);
}
}
Future<void> _launchExternalUrl(String url) async {
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
print('URL을 열 수 없습니다: $url');
}
} catch (e) {
print('외부 URL 실행 오류: $e');
}
}
Future<bool> _isUserAuthenticated() async {
return true;
}
Future<void> _navigateToLogin() async {
_router?.go(AppRoutes.login);
}
}
NotificationService 업데이트
1편에서 만든 NotificationService에 라우팅 기능을 통합합니다.
Dart
class NotificationService {
final NotificationRoutingService _routingService =
NotificationRoutingService();
Future<void> initialize({GoRouter? router}) async {
if (_initialized) return;
if (router != null) {
_routingService.setRouter(router);
}
await _initializeFirebaseMessaging();
await _initializeLocalNotifications();
await _setupMessageHandlers();
_initialized = true;
}
void _handleBackgroundMessage(RemoteMessage message) {
print('백그라운드 메시지 처리: \${message.messageId}');
_routingService.handleNotificationRoute(message.data);
}
void _onNotificationTapped(NotificationResponse response) {
print('노티피케이션 탭: \${response.payload}');
if (response.payload != null) {
try {
final data = json.decode(response.payload!);
_routingService.handleNotificationRoute(data);
} catch (e) {
print('페이로드 처리 오류: $e');
}
}
}
Future<void> _showLocalNotification(RemoteMessage message) async {
const AndroidNotificationDetails androidDetails =
AndroidNotificationDetails(
'default_notification_channel',
'기본 알림',
importance: Importance.high,
priority: Priority.high,
);
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails();
const NotificationDetails notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
final payload = message.data.isNotEmpty
? json.encode(message.data)
: null;
await _localNotifications.show(
message.hashCode,
message.notification?.title ?? '알림',
message.notification?.body ?? '새로운 메시지가 있습니다.',
notificationDetails,
payload: payload,
);
}
}
보안 고려사항
노티피케이션 라우팅에서 중요한 보안 고려사항들을 알아보겠습니다.
Dart
class RoutingSecurity {
static final Set<String> allowedRoutes = {
'/',
'/chat',
'/post',
'/product',
'/profile',
'/settings',
};
static final Set<RegExp> allowedPatterns = {
RegExp(r'^/chat/[a-zA-Z0-9_-]+\$'),
RegExp(r'^/post/[a-zA-Z0-9_-]+\$'),
RegExp(r'^/product/[a-zA-Z0-9_-]+\$'),
RegExp(r'^/profile/[a-zA-Z0-9_-]+\$'),
};
static bool isRouteAllowed(String route) {
if (allowedRoutes.contains(route)) {
return true;
}
return allowedPatterns.any((pattern) => pattern.hasMatch(route));
}
static String sanitizeRoute(String route) {
return route.replaceAll(RegExp(r'[<>"\'&]'), '');
}
static bool validateEntityId(String entityId) {
return RegExp(r'^[a-zA-Z0-9_-]+\$').hasMatch(entityId);
}
}
class AuthGuard {
static Future<bool> canAccessRoute(String route) async {
final protectedRoutes = [
'/profile',
'/chat',
'/settings',
];
if (protectedRoutes.any((protected) => route.startsWith(protected))) {
return await _isUserAuthenticated();
}
return true;
}
static Future<bool> _isUserAuthenticated() async {
return true;
}
}
실제 화면 구현 예제
노티피케이션으로 이동할 수 있는 몇 가지 화면을 구현해보겠습니다.
Dart
class ChatDetailScreen extends StatelessWidget {
final String chatId;
const ChatDetailScreen({
required this.chatId,
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('채팅 $chatId'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('채팅 ID: $chatId'),
const Text('노티피케이션으로 이동했습니다!'),
ElevatedButton(
onPressed: () => context.go(AppRoutes.home),
child: const Text('홈으로 이동'),
),
],
),
),
);
}
}
class PostDetailScreen extends StatelessWidget {
final String postId;
const PostDetailScreen({
required this.postId,
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('게시물 $postId'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('게시물 ID: $postId'),
const Text('노티피케이션으로 이동했습니다!'),
ElevatedButton(
onPressed: () => context.go(AppRoutes.home),
child: const Text('홈으로 이동'),
),
],
),
),
);
}
}
서버에서 메시지 전송 예제
서버 측에서 라우팅 정보가 포함된 푸시 메시지를 전송하는 방법입니다.
JavaScript
const admin = require('firebase-admin');
async function sendChatNotification(userToken, chatId, senderName) {
const message = {
token: userToken,
notification: {
title: '새로운 메시지',
body: `\${senderName}님이 메시지를 보냈습니다`,
},
data: {
type: 'chat',
entityId: chatId,
route: `/chat/\${chatId}`,
requiresAuth: 'true',
},
android: {
notification: {
channelId: 'default_notification_channel',
priority: 'high',
},
},
apns: {
payload: {
aps: {
badge: 1,
sound: 'default',
},
},
},
};
try {
const response = await admin.messaging().send(message);
console.log('메시지 전송 성공:', response);
} catch (error) {
console.error('메시지 전송 실패:', error);
}
}
async function sendPostNotification(userToken, postId, postTitle) {
const message = {
token: userToken,
notification: {
title: '새로운 게시물',
body: postTitle,
},
data: {
type: 'post',
entityId: postId,
route: `/post/\${postId}`,
requiresAuth: 'false',
},
};
await admin.messaging().send(message);
}
테스트 및 디버깅
라우팅 기능을 테스트하고 디버깅하는 방법을 알아보겠습니다.
Dart
class NotificationTestHelper {
static NotificationData createTestNotification({
required NotificationType type,
String? entityId,
String? route,
}) {
switch (type) {
case NotificationType.chat:
return NotificationData(
type: type,
entityId: entityId ?? 'test-chat-123',
route: route ?? '/chat/test-chat-123',
);
case NotificationType.post:
return NotificationData(
type: type,
entityId: entityId ?? 'test-post-456',
route: route ?? '/post/test-post-456',
);
default:
return NotificationData(
type: type,
route: route ?? '/',
);
}
}
}
class NotificationTestScreen extends ConsumerWidget {
const NotificationTestScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final routingService = NotificationRoutingService();
return Scaffold(
appBar: AppBar(
title: const Text('노티피케이션 테스트'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
ElevatedButton(
onPressed: () {
final testData = NotificationTestHelper.createTestNotification(
type: NotificationType.chat,
);
routingService.handleNotificationRoute(testData.toMap());
},
child: const Text('채팅 노티피케이션 테스트'),
),
],
),
),
);
}
}
다음 편 예고
2편에서는 노티피케이션 선택 시 정확한 화면으로 라우팅하는 방법을 자세히 다뤘습니다.
3편에서 다룰 주요 내용:
- 앱 생명주기별 노티피케이션 처리: 포그라운드, 백그라운드, 종료 상태에서의 다른 처리 방법
- 노티피케이션 상태 관리: 읽음/안읽음 상태, 노티피케이션 히스토리 관리
- 배치 처리와 큐잉: 여러 노티피케이션의 효율적인 처리
- 사용자 설정 관리: 노티피케이션 유형별 on/off, 시간 설정 등
- 고급 기능: 그룹화, 액션 버튼, 커스텀 사운드 등
이 시리즈를 통해 실무에서 사용할 수 있는 완전한 푸시 노티피케이션 시스템을 구축할 수 있을 것입니다.
3편에서 다룰 주요 내용:
- 앱 생명주기별 노티피케이션 처리: 포그라운드, 백그라운드, 종료 상태에서의 다른 처리 방법
- 노티피케이션 상태 관리: 읽음/안읽음 상태, 노티피케이션 히스토리 관리
- 배치 처리와 큐잉: 여러 노티피케이션의 효율적인 처리
- 사용자 설정 관리: 노티피케이션 유형별 on/off, 시간 설정 등
- 고급 기능: 그룹화, 액션 버튼, 커스텀 사운드 등
이 시리즈를 통해 실무에서 사용할 수 있는 완전한 푸시 노티피케이션 시스템을 구축할 수 있을 것입니다.