11 KiB
11 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| mobile-push-deep | Mobile Push Deep — APNs / FCM / Topic / Silent | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
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
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
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)
}
}
}
<!-- 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)
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)
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)
// iOS
{
aps: {
'content-available': 1, // 알림 표시 X
},
syncKey: '...', // 직접 처리
}
// FCM
{
message: {
token,
data: { syncKey: '...' },
android: { priority: 'high' },
apns: {
headers: { 'apns-priority': '5', 'apns-push-type': 'background' },
payload: { aps: { 'content-available': 1 } },
},
},
}
// 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
// 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)
// 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',
}
// 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
// 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)
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
// 같은 사용자에 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
// Open 추적 — payload 안 unique id
{ data: { campaignId: 'x', messageId: 'y' } }
// App 가 open / dismiss 시 server log
→ Open rate < 5% = 너무 많이 보냄 또는 noisy.
iOS Live Activity (long-running)
// 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.