391 lines
11 KiB
Markdown
391 lines
11 KiB
Markdown
---
|
|
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
|
|
<!-- AndroidManifest.xml -->
|
|
<service android:name=".MyFcmService" android:exported="false">
|
|
<intent-filter>
|
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
|
</intent-filter>
|
|
</service>
|
|
```
|
|
|
|
### 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]]
|