--- id: mobile-push-deep title: Mobile Push Deep — APNs / FCM / Topic / Silent category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [mobile, push, apns, fcm, vibe-coding] tech_stack: { language: "Swift / Kotlin / TS", applicable_to: ["iOS", "Android"] } applied_in: [] aliases: [APNs, FCM, push notification deep, silent push, topic, rich notification] --- # Mobile Push Deep > 권한 + token 등록 + 서버 전송 + handle. **Silent (background sync), Rich (image, action), Topic (broadcast)**. iOS Live Activity / Android FGS 같이 활용. ## 📖 핵심 개념 - APNs (iOS): Apple Push Notification service. - FCM (Android + iOS option): Firebase Cloud Messaging. - Token: device 별 unique. - Topic: subscribe 기반 broadcast. ## 💻 코드 패턴 ### iOS — APNs 권한 + token ```swift import UIKit import UserNotifications class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ app: UIApplication, didFinishLaunchingWithOptions opts: ...) -> Bool { UNUserNotificationCenter.current().delegate = self UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in if granted { DispatchQueue.main.async { app.registerForRemoteNotifications() } } } return true } func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let token = deviceToken.map { String(format: "%02x", $0) }.joined() // Server 에 등록 Task { try? await api.registerPushToken(token: token, platform: "ios") } } func application(_ app: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { print("Push register failed:", error) } } extension AppDelegate: UNUserNotificationCenterDelegate { // App foreground 시 도착 func userNotificationCenter(_ c: UNUserNotificationCenter, willPresent n: UNNotification, withCompletionHandler h: @escaping (UNNotificationPresentationOptions) -> Void) { h([.banner, .sound, .badge]) } // 사용자가 tap func userNotificationCenter(_ c: UNUserNotificationCenter, didReceive r: UNNotificationResponse, withCompletionHandler h: @escaping () -> Void) { let userInfo = r.notification.request.content.userInfo // Deep link 처리 if let path = userInfo["path"] as? String { Router.shared.navigate(to: path) } h() } } ``` ### Android — FCM ```kotlin class MyFcmService : FirebaseMessagingService() { override fun onNewToken(token: String) { super.onNewToken(token) // Server 등록 CoroutineScope(Dispatchers.IO).launch { api.registerPushToken(token, "android") } } override fun onMessageReceived(msg: RemoteMessage) { super.onMessageReceived(msg) if (msg.notification != null) { // Display 자동 (foreground 시 만) showNotification(msg.notification!!.title, msg.notification!!.body, msg.data) } else { // Data-only — 직접 처리 / 자체 표시 showNotification(msg.data["title"] ?: "", msg.data["body"] ?: "", msg.data) } } } ``` ```xml ``` ### Server 전송 — APNs (Node) ```ts import jwt from 'jsonwebtoken'; import http2 from 'node:http2'; const apnsToken = jwt.sign( { iss: TEAM_ID, iat: Math.floor(Date.now() / 1000) }, fs.readFileSync('AuthKey_XXXX.p8'), { algorithm: 'ES256', header: { alg: 'ES256', kid: KEY_ID } } ); const client = http2.connect('https://api.push.apple.com:443'); const req = client.request({ ':method': 'POST', ':path': `/3/device/${deviceToken}`, 'apns-topic': BUNDLE_ID, 'apns-push-type': 'alert', 'apns-priority': '10', authorization: `bearer ${apnsToken}`, }); req.write(JSON.stringify({ aps: { alert: { title: 'Hello', body: 'World' }, sound: 'default', badge: 1, }, customData: { orderId: '42' }, })); req.end(); req.on('response', (headers) => { if (headers[':status'] === 200) console.log('sent'); else console.error('failed', headers); }); ``` ### Server 전송 — FCM (HTTP v1) ```ts import { GoogleAuth } from 'google-auth-library'; const auth = new GoogleAuth({ credentials: JSON.parse(process.env.GCP_SA!), scopes: ['https://www.googleapis.com/auth/firebase.messaging'], }); async function sendFcm(token: string, title: string, body: string, data: any) { const client = await auth.getClient(); const accessToken = await client.getAccessToken(); const r = await fetch(`https://fcm.googleapis.com/v1/projects/${PROJECT_ID}/messages:send`, { method: 'POST', headers: { Authorization: `Bearer ${accessToken.token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ message: { token, notification: { title, body }, data, // string values only android: { priority: 'high', notification: { channel_id: 'default', click_action: 'OPEN_ACTIVITY' }, }, apns: { payload: { aps: { sound: 'default', badge: 1 } }, }, }, }), }); if (!r.ok) throw new Error(`FCM ${r.status}: ${await r.text()}`); } ``` ### Silent push (background sync) ```ts // iOS { aps: { 'content-available': 1, // 알림 표시 X }, syncKey: '...', // 직접 처리 } ``` ```ts // FCM { message: { token, data: { syncKey: '...' }, android: { priority: 'high' }, apns: { headers: { 'apns-priority': '5', 'apns-push-type': 'background' }, payload: { aps: { 'content-available': 1 } }, }, }, } ``` ```swift // iOS handle silent func application(_ app: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completion: @escaping (UIBackgroundFetchResult) -> Void) { Task { await syncData(syncKey: userInfo["syncKey"] as? String ?? "") completion(.newData) } } ``` ⚠️ iOS silent push = 시스템 throttle. 정확 실행 보장 X. ### Topic / broadcast ```ts // FCM topic await fetch(`https://fcm.googleapis.com/v1/projects/${PROJECT_ID}/messages:send`, { method: 'POST', body: JSON.stringify({ message: { topic: 'news_korean', notification: { title: '...', body: '...' }, }, }), }); // Client subscribe import messaging from '@react-native-firebase/messaging'; await messaging().subscribeToTopic('news_korean'); ``` → 1 send → 모든 subscribed user. ### Rich notification (image) ```ts // FCM { message: { notification: { title, body, image: 'https://example.com/image.jpg' }, }, } // APNs (mutable) { aps: { alert: { title, body }, 'mutable-content': 1, // Notification Service Extension 호출 }, attachment: 'https://example.com/image.jpg', } ``` ```swift // Notification Service Extension class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler handler: @escaping (UNNotificationContent) -> Void) { guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { return handler(request.content) } guard let urlStr = content.userInfo["attachment"] as? String, let url = URL(string: urlStr) else { return handler(content) } URLSession.shared.downloadTask(with: url) { tempUrl, _, _ in if let tempUrl = tempUrl { let dest = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(url.lastPathComponent) try? FileManager.default.moveItem(at: tempUrl, to: dest) if let attachment = try? UNNotificationAttachment(identifier: "image", url: dest) { content.attachments = [attachment] } } handler(content) }.resume() } } ``` ### Action button ```ts // iOS — UNNotificationCategory 사전 등록 let yes = UNNotificationAction(identifier: "YES", title: "Yes") let no = UNNotificationAction(identifier: "NO", title: "No") let cat = UNNotificationCategory(identifier: "CONFIRM", actions: [yes, no], intentIdentifiers: []) UNUserNotificationCenter.current().setNotificationCategories([cat]) // Server { aps: { alert: { ... }, category: 'CONFIRM', }, } ``` ### Token rotation ``` Token 가 가끔 변경 (앱 재설치, OS update). Server 가 "invalid token" 응답 받으면 → DB 에서 제거. FCM "Unregistered" / APNs "BadDeviceToken" / "Unregistered". ``` ### Multi-device (한 사용자 = N device) ```sql CREATE TABLE push_tokens ( user_id UUID, token TEXT, platform TEXT, -- 'ios' / 'android' app_version TEXT, created_at TIMESTAMPTZ, PRIMARY KEY (user_id, token) ); ``` → User 한 번 send = 모든 device 보냄 (또는 가장 최근 만). ### Throttle / dedup ```ts // 같은 사용자에 1분 안 같은 type 보내지 X const key = `push:${userId}:${type}`; const ok = await redis.set(key, '1', 'EX', 60, 'NX'); if (!ok) return; await sendPush(...); ``` ### Open rate / metrics ```ts // Open 추적 — payload 안 unique id { data: { campaignId: 'x', messageId: 'y' } } // App 가 open / dismiss 시 server log ``` → Open rate < 5% = 너무 많이 보냄 또는 noisy. ### iOS Live Activity (long-running) ```swift // Configure let attrs = OrderActivityAttributes(orderId: "...") let initial = OrderActivityAttributes.ContentState(status: .preparing) let activity = try Activity.request(attributes: attrs, content: .init(state: initial, staleDate: nil)) // Push update from server { aps: { timestamp: Date(), event: 'update', 'content-state': { status: 'shipped' }, 'attributes-type': 'OrderActivityAttributes', attributes: { orderId: '...' }, }, } ``` → Lock screen 에 라이브 표시. ## 🤔 의사결정 기준 | 사용 | 추천 | |---|---| | 일반 알림 | FCM (cross-platform) | | iOS only 정확 | APNs 직접 | | 백그라운드 sync | Silent push | | Broadcast | Topic | | 풍부 (image) | Rich notification | | 실시간 status | Live Activity (iOS) / 일반 (Android) | ## ❌ 안티패턴 - **권한 즉시 page load**: 사용자 거부. 적절 시점. - **Token rotate 무시**: bounce 율 ↑. - **모든 사용자 한 번에 send**: APNs / FCM throttle. - **PII payload (user email)**: 권한 없는 view 가 보일 수 있음. - **Open rate 무모니터링**: spam 가능. - **Silent push 의존**: 시스템 throttle. - **Server cert / key leak**: hijack 가능. ## 🤖 LLM 활용 힌트 - FCM v1 (HTTP) 권장 + APNs 직접 (정확). - Token 등록 + rotation handle. - Silent / Rich / Topic / Action 활용. - Cleanup 무효 token. ## 🔗 관련 문서 - [[iOS_Push_Notifications]] - [[Android_Notification_Patterns]] - [[iOS_Live_Activities]]