Files
2nd/10_Wiki/Topics/Coding/Mobile_Push_Deep.md
T
2026-05-09 21:08:02 +09:00

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
apns
fcm
vibe-coding
language applicable_to
Swift / Kotlin / TS
iOS
Android
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

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.

🔗 관련 문서