Flutter 개발

Flutter 푸시 노티피케이션 완벽 가이드 2편: 라우팅과 딥링크 처리

노티피케이션 선택 시 정확한 화면으로 이동시키는 고급 라우팅 기법

2025년 9월 20일
20분 읽기
Flutter 푸시 노티피케이션 완벽 가이드 2편: 라우팅과 딥링크 처리

노티피케이션 라우팅의 이해

푸시 노티피케이션에서 라우팅은 사용자가 알림을 선택했을 때 앱의 적절한 화면으로 이동시키는 메커니즘입니다. 효과적인 라우팅 시스템은 사용자 경험을 크게 향상시킬 수 있습니다.

라우팅 시나리오:
- 홈 화면: 일반적인 알림의 경우
- 특정 상세 화면: 게시물, 상품, 메시지 등
- 설정 화면: 권한 요청이나 업데이트 알림
- 외부 링크: 웹페이지나 다른 앱으로 이동

주요 고려사항:
- 앱 상태별 처리 (포그라운드/백그라운드/종료)
- 인증이 필요한 화면 처리
- 딥링크 검증 및 보안
- 네트워크 상태 확인

라우팅 시스템 설계

체계적인 라우팅 시스템을 구축하기 위해 먼저 노티피케이션 데이터 구조와 라우팅 규칙을 정의해보겠습니다.
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, 시간 설정 등
- 고급 기능: 그룹화, 액션 버튼, 커스텀 사운드 등

이 시리즈를 통해 실무에서 사용할 수 있는 완전한 푸시 노티피케이션 시스템을 구축할 수 있을 것입니다.
#푸시노티피케이션
#GoRouter
#딥링크
#라우팅
#네비게이션