---
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]]
