.NET MAUI 푸시 알림은 iOS의 Apple Push Notification service(APNs)와 Android의 Firebase Cloud Messaging(FCM)을 각각 네이티브 API로 호출하여 구현합니다. Xamarin.Forms 시절과 달리 MAUI 10에서는 Plugin.Firebase.CloudMessaging(3.x)과 UNUserNotificationCenter를 조합해 단일 코드베이스로 두 플랫폼을 처리할 수 있지만, 토큰 갱신·권한 요청·백그라운드 페이로드 형식은 여전히 플랫폼마다 다릅니다. 이 글은 프로덕션에서 실제로 동작하는 코드와 함께 iOS·Android 양쪽의 함정을 하나씩 짚어드립니다.
iOS는 APNs 인증서 대신 .p8 토큰 방식(APNs Auth Key)을 쓰는 것이 2026년 표준이며, Firebase에 업로드하면 FCM이 APNs 프록시 역할을 합니다.
Android 13(API 33)부터는 POST_NOTIFICATIONS 런타임 권한이 필수이므로, 앱 시작 시 Permissions.RequestAsync<Permissions.PostNotifications>()를 호출해야 합니다.
iOS 15+는 알림 권한을 UNAuthorizationOptions.Alert | .Sound | .Badge로 요청하고, provisional 옵션으로 조용한 알림을 먼저 전송해 승인율을 높일 수 있습니다.
백그라운드 데이터 페이로드는 iOS에서 content-available: 1, Android에서 data 전용 메시지로 구분됩니다. 표시 알림과는 라우팅 코드가 다릅니다.
딥링크는 페이로드에 커스텀 키(예: route)를 넣고, App.OpenAsync 또는 Shell의 GoToAsync로 처리합니다. 콜드 스타트와 백그라운드 진입 경로가 다르므로 두 경우 모두 커버해야 합니다.
iOS 앱 배지는 iOS 16.1+부터 UNUserNotificationCenter.SetBadgeCount로 설정하고, Android는 런처마다 방식이 달라 ShortcutBadger 같은 라이브러리 없이는 통합 처리가 어렵습니다.
iOS·Android 푸시 알림 아키텍처 개요
먼저 두 플랫폼이 실제로 무엇을 하는지 짚고 넘어가야 합니다. 솔직히 이 부분을 대충 이해하고 넘어가면 나중에 반드시 어딘가에서 발목이 잡히더군요. iOS에서 앱이 UIApplication.RegisterForRemoteNotifications()를 호출하면, iOS는 Apple 서버(APNs)에서 디바이스 토큰을 받아 앱에 콜백으로 돌려줍니다. Android에서는 Google Play Services가 FCM 서버로부터 등록 토큰을 받아 FirebaseMessaging.GetTokenAsync()로 노출합니다. Firebase를 프록시로 사용하면 두 토큰을 모두 FCM 토큰 형식으로 통합할 수 있어, 서버 측에서는 하나의 API(HTTP v1)만 호출하면 됩니다.
여기서 초보자가 놓치는 지점은 토큰이 절대 영구적이지 않다는 사실입니다. Apple의 APNs 등록 문서는 앱 삭제·재설치·데이터 초기화·백업 복원 시 토큰이 바뀔 수 있음을 명시합니다. FCM 역시 앱 데이터 초기화나 인스턴스 ID 만료 시 새 토큰을 발급합니다. 따라서 앱은 매 시작마다 토큰을 요청하고, 변경되었으면 서버에 다시 등록해야 합니다.
Firebase 프로젝트와 APNs 인증 키 설정
플러그인 코드를 쓰기 전에 Firebase 콘솔에서 프로젝트를 만들고 iOS·Android 앱을 등록해야 합니다. Android는 google-services.json을 받아 Platforms/Android/에 배치하고, iOS는 GoogleService-Info.plist를 Platforms/iOS/에 배치합니다. 두 파일 모두 .csproj에서 BundleResource(iOS)와 GoogleServicesJson(Android)으로 지정합니다.
iOS 쪽에서 가장 실수가 잦은 부분은 APNs Auth Key 생성입니다. Apple Developer 포털의 Certificates, Identifiers & Profiles → Keys에서 Apple Push Notifications service (APNs)를 체크한 .p8 키를 생성하고, Key ID·Team ID와 함께 Firebase의 프로젝트 설정 → 클라우드 메시징 → Apple 앱 구성에 업로드합니다. 이 방식은 인증서 만료 관리가 필요 없고 여러 앱에서 재사용할 수 있어 2026년 현재 사실상 표준입니다.
iOS 프로젝트에서 반드시 Entitlements.plist에 aps-environment를 추가해야 합니다. 개발 빌드는 development, TestFlight/App Store 빌드는 production으로 서명 시점에 자동 전환되게 해야 합니다. 이 설정을 놓치면 토큰은 발급되지만 알림이 도착하지 않는 침묵의 실패가 발생합니다.
.NET MAUI 10 프로젝트 설정과 플러그인 설치
2026년 기준 실전에서 가장 안정적인 조합은 Plugin.Firebase.CloudMessaging 3.x와 Plugin.Firebase 3.x입니다. 이 플러그인은 iOS의 Firebase.CloudMessaging과 Android의 Xamarin.Firebase.Messaging 바인딩을 감싸주며, MAUI 10의 MauiAppBuilder와 자연스럽게 통합됩니다. 대안으로 Shiny.Push나 CommunityToolkit.Maui.Push도 있지만, 커뮤니티 사용자 수와 이슈 대응 속도 면에서 Plugin.Firebase 계열이 우위입니다.
iOS 10부터 알림 권한은 명시적 사용자 승인이 필요하고, Android는 13(API 33)부터 POST_NOTIFICATIONS 런타임 권한이 도입되었습니다. 이전에는 매니페스트에 선언만 하면 자동 허용되었지만, 이제는 Permissions.RequestAsync를 호출해야 합니다. 이 부분에서 Xamarin에서 넘어온 개발자들이 가장 많이 걸려 넘어지는데, 권한이 없으면 FCM 토큰은 발급되어도 시스템 트레이에 알림이 표시되지 않고 조용히 삭제됩니다.
// Services/NotificationPermissionService.cs
public class NotificationPermissionService
{
public async Task<bool> RequestAsync()
{
#if ANDROID
if (OperatingSystem.IsAndroidVersionAtLeast(33))
{
var status = await Permissions
.RequestAsync<Permissions.PostNotifications>();
return status == PermissionStatus.Granted;
}
return true; // 12 이하는 자동 허용
#elif IOS
var (granted, error) = await UNUserNotificationCenter.Current
.RequestAuthorizationAsync(
UNAuthorizationOptions.Alert |
UNAuthorizationOptions.Sound |
UNAuthorizationOptions.Badge);
if (granted)
{
await MainThread.InvokeOnMainThreadAsync(() =>
UIApplication.SharedApplication.RegisterForRemoteNotifications());
}
return granted;
#else
return false;
#endif
}
}
Permissions.PostNotifications는 .NET MAUI Essentials(Microsoft Learn 권한 문서 참조)에 포함되어 있지만, Android 매니페스트에도 <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />를 반드시 추가해야 합니다. iOS는 Info.plist에 별도 선언이 필요하지 않지만, 사용자가 최초 거부하면 다시 다이얼로그를 띄울 수 없으므로 앱 내 설명 화면을 준비해두는 것이 좋습니다.
FCM 토큰 수신과 서버 등록
권한이 승인되면 FCM 토큰을 받을 수 있습니다. Plugin.Firebase.CloudMessaging은 CrossFirebaseCloudMessaging.Current를 통해 통합 API를 제공합니다. 토큰은 앱 첫 실행 시 발급되고, OnTokenRefresh 이벤트로 갱신 시점을 알 수 있습니다. 아래 서비스는 앱 시작 시 토큰을 백엔드에 등록하고, 이후 갱신될 때마다 다시 전송합니다.
서버 측에서는 사용자 ID와 함께 토큰을 저장하고, 중복 토큰(같은 디바이스가 여러 계정으로 로그인)이나 stale 토큰(UNREGISTERED 오류 반환)을 주기적으로 정리해야 합니다. FCM HTTP v1 응답에서 NOT_FOUND가 반환되면 즉시 해당 토큰을 삭제하는 것이 iOS/Android 양쪽 모두의 베스트 프랙티스입니다.
포그라운드·백그라운드·종료 상태에서 알림 처리
가장 혼란스러운 부분입니다. 저도 처음 MAUI로 채팅 앱을 만들 때, 포그라운드에서 알림이 안 뜬다고 반나절을 삽질했던 기억이 있는데요, 알고 보니 iOS는 WillPresentNotification에서 개발자가 명시적으로 배너 표시를 결정해야 한다는 걸 몰랐던 겁니다. iOS와 Android는 앱 상태에 따라 알림을 완전히 다르게 취급하니, 표를 먼저 봅시다.
상태
iOS 동작
Android 동작
MAUI 처리 지점
포그라운드
WillPresentNotification 호출, 배너 여부 개발자가 결정
표시 알림은 자동 생성 안 됨, 데이터만 전달
NotificationReceived 이벤트
백그라운드
시스템이 배너 표시, 탭 시 DidReceiveNotificationResponse
시스템이 트레이에 표시, 탭 시 인텐트로 전달
NotificationTapped 이벤트
종료(콜드 스타트)
launchOptions에 페이로드 포함
인텐트의 Extras에 페이로드 포함
NotificationTapped는 지연 발생
Silent (data-only)
content-available: 1 필요, background fetch 권한 필요
data 필드만 있으면 자동으로 데이터 메시지
NotificationReceived에서 처리
Plugin.Firebase.CloudMessaging은 이 차이를 NotificationReceived와 NotificationTapped로 추상화합니다. 하지만 콜드 스타트 상황에서는 이벤트 구독보다 앱 초기화가 늦어질 수 있어, 시작 시점에 GetInitialNotificationAsync 같은 폴링 API로 대기 중인 페이로드를 확인해야 합니다.
public partial class App : Application
{
public App(IServiceProvider services)
{
InitializeComponent();
var fcm = CrossFirebaseCloudMessaging.Current;
fcm.NotificationReceived += OnReceived;
fcm.NotificationTapped += OnTapped;
MainPage = services.GetRequiredService<AppShell>();
}
private void OnReceived(object sender, FCMNotificationReceivedEventArgs e)
{
// 포그라운드: 앱 내 배너나 InApp 알림 UI로 표시
WeakReferenceMessenger.Default.Send(new PushReceivedMessage(e.Notification));
}
private async void OnTapped(object sender, FCMNotificationTappedEventArgs e)
{
if (e.Notification.Data.TryGetValue("route", out var route))
await Shell.Current.GoToAsync(route);
}
}
푸시 알림에서 딥링크를 어떻게 처리하나요?
알림 탭 → 특정 화면으로 이동은 사용자 경험의 핵심입니다. 페이로드 구조를 표준화해서 서버가 route·id 같은 키를 함께 보내고, 클라이언트는 Shell.Current.GoToAsync로 라우팅합니다. 아래는 서버가 보내는 FCM HTTP v1 요청 본문 예시입니다.
Shell 라우팅을 이미 다뤄본 적이 없다면 CommunityToolkit.Mvvm 가이드에서 ObservableProperty와 RelayCommand를 어떻게 뷰모델에 연결하는지 먼저 훑어보시길 권합니다. 딥링크가 도달했을 때 뷰모델이 이미 초기화되어 있어야 페이지 전환이 매끄럽습니다.
콜드 스타트 케이스는 반드시 AppShell의 OnAppearing 이후 처리해야 합니다. 그렇지 않으면 아직 Shell이 구성되지 않은 상태에서 GoToAsync를 호출해 크래시가 납니다. Android MainActivity의 OnNewIntent와 iOS AppDelegate의 DidReceiveRemoteNotification도 각각 launchOptions를 검사하도록 파셜 클래스로 분리해두는 것이 안전합니다.
앱 배지 카운트 업데이트
iOS는 배지가 시스템 표준 기능이지만, iOS 16.1부터 UIApplication.SharedApplication.ApplicationIconBadgeNumber가 deprecated 되고 UNUserNotificationCenter.Current.SetBadgeCountAsync()가 표준이 되었습니다. 서버에서 페이로드의 badge 필드로 절대값을 지정하면 되고, 증분 처리는 서버가 사용자별 미확인 카운트를 계산하는 방식이 정석입니다.
Android는 배지 표준이 없습니다. 삼성 One UI, Xiaomi MIUI, Pixel Launcher가 각각 다른 방식(도트, 숫자, 없음)을 씁니다. Xamarin.ShortcutBadger 포팅이나 MediaSession 커스텀 노티피케이션으로 시도할 수 있지만, 대부분의 앱은 그냥 NotificationChannel의 배지 활성화로 만족합니다. 채널 생성 시 SetShowBadge(true)를 호출하면 미확인 알림 수만큼 도트가 표시됩니다.
테스트와 디버깅 팁
테스트가 어렵기로 악명 높은 영역이지만, 몇 가지 도구가 삶을 크게 개선합니다. Firebase 콘솔의 Messaging 탭에서 특정 토큰으로 테스트 메시지를 보낼 수 있고, iOS는 시뮬레이터에서도 xcrun simctl push로 .apns JSON 파일을 주입할 수 있습니다. 통합 테스트는 .NET MAUI 테스트 자동화 가이드에서 다룬 xUnit + AppiumDriver 조합으로, 알림 페이로드 시뮬레이션을 목킹해 화면 전환을 검증할 수 있습니다.
디버깅 팁 몇 가지:
iOS에서 알림이 안 옴: 프로비저닝 프로필의 aps-environment, 번들 ID, Firebase 프로젝트의 APNs 키 매칭을 우선 확인합니다.
Android에서 조용히 실패: google-services.json이 최신인지, SHA-1 지문이 등록되었는지, POST_NOTIFICATIONS 권한이 승인되었는지 순서대로 확인합니다.
백그라운드 데이터 메시지가 도착 안 함: iOS는 UIBackgroundModes에 remote-notification이 있어야 하고, Android는 배터리 최적화에서 앱이 예외 처리되어 있어야 합니다.
탭했는데 이동이 안 됨: 콜드 스타트 시 이벤트 구독 시점이 늦어서 페이로드를 놓치는 경우가 대부분입니다.
프로덕션 체크리스트
배포 전 반드시 검증할 항목입니다. 저는 이 리스트 없이 앱스토어에 올렸다가 여러 번 데인 경험이 있습니다. Xamarin에서 MAUI로 마이그레이션하는 팀이라면 Xamarin.Forms → MAUI 10 마이그레이션 가이드의 플랫폼 프로젝트 초기화 절차도 함께 참고하세요. 특히 Entitlements.plist와 AndroidManifest.xml의 마이그레이션은 알림 기능과 직접적으로 연결됩니다.
iOS: Development APNs 서버가 아닌 Production 서버로 토큰 등록 시점(TestFlight 이상)에서 실제 알림 도착 확인
Android: Play Console 내부 테스트 트랙에서 배터리 최적화 자동 해제되지 않는 실기기로 도착 확인
서버: HTTP v1 응답 코드 처리(NOT_FOUND, UNREGISTERED, QUOTA_EXCEEDED) 완비
클라이언트: 로그아웃 시 토큰을 서버에서 삭제(다른 사용자에게 이전 알림이 가는 사고 방지)
개인정보처리방침에 알림 관련 데이터 수집 명시(App Store Privacy Nutrition Labels 필수)
자주 묻는 질문
.NET MAUI에서 Firebase 없이 순수 APNs로 iOS 푸시 알림을 구현할 수 있나요?
네, 가능합니다. iOS 프로젝트에서 UIApplication.RegisterForRemoteNotifications()로 받은 디바이스 토큰을 직접 백엔드로 보내고, 백엔드에서 APNs HTTP/2 API를 호출하면 됩니다. 다만 Android와 코드베이스가 완전히 분리되고 서버가 두 프로토콜을 모두 다뤄야 하므로, 대부분의 팀은 FCM을 iOS 프록시로 사용하는 편이 유지보수 측면에서 유리합니다.
Android 13 이상에서 POST_NOTIFICATIONS 권한을 요청하는 최적의 시점은 언제인가요?
앱 첫 실행 직후가 아니라, 알림이 실제로 필요한 시점(예: 알림 설정 화면, 알림이 유용한 기능을 처음 사용할 때)에 컨텍스트와 함께 요청하는 것이 승인률이 훨씬 높습니다. 온보딩 마지막 화면에 "새 메시지를 놓치지 않으려면 알림을 켜주세요" 같은 설명을 붙이는 것도 좋은 패턴입니다.
Silent push(데이터 전용 알림)로 앱을 백그라운드에서 깨울 수 있나요?
iOS는 content-available: 1과 UIBackgroundModes의 remote-notification이 필요하지만, iOS 시스템이 배터리·사용 패턴에 따라 전달을 지연하거나 스킵할 수 있어 100% 보장되지 않습니다. Android는 data 전용 메시지가 즉시 도착하지만 Doze 모드와 배터리 최적화에 걸리면 지연됩니다. 실시간성이 중요하다면 WebSocket이나 SignalR 같은 지속 연결을 병행하세요.
FCM 토큰이 자꾸 바뀌는데 정상인가요?
일반적인 사용에서는 앱 재설치·데이터 초기화·백업 복원·인스턴스 ID 만료 시에만 바뀝니다. 매일 바뀐다면 google-services.json이 잘못되었거나 앱 서명이 매번 달라지는 CI 설정 문제일 수 있습니다. TokenChanged 이벤트를 로깅해 실제 갱신 빈도를 측정하고, 이유 없이 잦다면 Firebase 프로젝트의 앱 등록을 다시 확인하세요.
MAUI Blazor Hybrid 앱에서도 같은 방식으로 푸시 알림이 동작하나요?
네. 푸시 알림은 네이티브 컨테이너(MAUI) 층에서 처리되기 때문에 UI 렌더링 방식(BlazorWebView 여부)과 무관합니다. 알림 탭 시 라우팅만 Blazor의 NavigationManager.NavigateTo로 위임하면 됩니다. Blazor Hybrid의 전반적인 구조는 별도 가이드에서 다뤘습니다.
.NET MAUI Android APK를 R8 축소기, IL Trimmer full 모드, App Bundle ABI 분할, WebP·폰트 서브셋으로 60~70% 다이어트하는 실전 가이드. .NET 10 기준 csproj 설정, proguard-rules.pro, CI 크기 회귀 방지 스크립트까지 프로덕션에서 검증한 예제로 정리했습니다.
App Center 종료 이후, .NET MAUI 10 앱을 GitHub Actions로 자동 빌드하고 iOS·Android 코드 서명, TestFlight, Google Play 내부 트랙까지 배포하는 실전 파이프라인. 러너 이미지, 시크릿 관리, OIDC까지 2026년 기준으로 정리했습니다.