~17 min · beyond-the-app, push, apns, usernotifications, swift-concurrency
Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"틀린 환경은 조용한 실패처럼 보여. 토큰은 등록되고 릴레이는 보내는데, 이유를 말해주는 대답은 아무도 안 읽는 응답 속에 있어."
안 보고 있는 사람한테 닿는 유일한 길
얇은 클라이언트의 소켓은 앱이 백그라운드로 가면 닫히니까, 화면을 안 보고 있는 사람한테 전해야 할 건 푸시로 보내. 가족 엔진들은 APNs 인증 키를 가진 릴레이를 거쳐 보내. 폰은 권한을 묻고, iOS한테 기기 토큰을 받고, 릴레이한테 등록을 보내. 모든 단계가 어느 쪽에도 에러를 남기지 않고 실패할 수 있어. 아래에 나오는 조용한 실패는 하나하나 실제로 디버깅 시간을 잡아먹었어.
키와 환경
APNs엔 환경이 둘이야. 개발 빌드의 토큰은 sandbox 호스트로, TestFlight나 App Store 빌드의 토큰은 production으로 가. 틀린 쪽으로 보내면 APNs는 400 BadDeviceToken으로 대답하고 알림은 안 와. 응답을 안 읽는 릴레이는 그걸 모르니까 조용한 실패가 되는 거야. 가족 앱들이 같이 쓰는 규칙은 함수 하나야. 시뮬레이터, 또는 번들에 embedded.mobileprovision이 든 설치(개발 서명)는 sandbox고, 내장 프로필이 없는 빌드는 production이야. 앞 레슨에서 본 것처럼 라이브 액티비티 토큰은 실제로 재보니 예외였어. 그래서 릴레이는 BadDeviceToken을 받으면 다른 호스트로 한 번 더 보내.
키에도 환경이 있어. 개발자 포털에서 APNs 키를 만들 때 이제 Configure 뒤에 환경 선택이 있는데, 기본값이 sandbox 전용이었고 만든 뒤엔 못 바꿔. 그래서 production 푸시가 403 BadEnvironmentKeyInToken으로 실패했어. 증상별 응답은 알아둘 만해. 모르는 키면 InvalidProviderToken, sandbox 전용 키면 BadEnvironmentKeyInToken, 맞는 키면 200. 해결책은 두 가지였어. Sandbox와 Production을 고른 새 키를 만들고, 시작할 때 키를 읽는 릴레이를 재시작하는 거야.
켜지자마자 트랩 난 델리게이트
SwiftUI 앱은 @UIApplicationDelegateAdaptor로 푸시 콜백을 받고, 이 어댑터는 Objective-C -init으로 델리게이트를 만들어. 공유 델리게이트 클래스는 init(payloadKey:)로 페이로드 키를 받으니까 앱의 하위 클래스엔 인자 없는 초기화가 필요해. 근데 통하는 작성법은 하나뿐이야. override init()은 덮어쓸 게 없어서 컴파일이 안 돼. 그냥 init()은 컴파일은 되는데 켜질 때 "Use of unimplemented initializer 'init()'"로 트랩이 나. 이 레슨을 위해 재현했어. @objc init()으로 써야 어댑터가 부르는 Objective-C 초기화로 노출돼. 알림 콜백도 오디오 탭과 같은 Swift 6 패턴을 따라. 알림 센터 델리게이트는 메인 스레드에서 돈다는 보장이 없어서, 가족 델리게이트는 async 형태를 nonisolated로 구현하고 뽑아낸 id만 메인 액터로 넘겨.
모델이 생기기 전의 탭
알림을 누르면 앱이 꺼진 상태에서 켜질 수 있고, 델리게이트는 앱 모델이 뭘 열 준비가 되기 전에 그걸 받아. 탭은 작은 대기열에 넣어뒀다가 모델이 붙으면 배달해. 일찍 온 두 번째 탭은 첫 번째를 갈아치워. 사람이 마지막에 한 행동이 그 사람의 진짜 의도니까. 페이로드 키엔 앱마다 이름공간을 둬서 다른 가족 앱의 푸시를 이 앱 것으로 착각하지 않게 해. 그리고 앱은 켜질 때와 앞으로 나올 때마다 다시 등록해. 토큰은 바뀌고, 라우팅에 쓰이는 건 릴레이에 남은 기록뿐이거든.
Code
환경 규칙, 이름공간 있는 페이로드, 꺼진 상태에서 켜질 때 쓰는 탭 대기열·swift
import Foundation
enum APNsEnvironment: String, Sendable {
case sandbox, production
}
/// A wrong environment fails silently: the token registers, the relay sends, Apple drops it.
/// A simulator or a development-signed install carries embedded.mobileprovision and routes through
/// the sandbox; a TestFlight or App Store build carries none and routes through production.
func apnsEnvironment(isSimulator: Bool, bundleURL: URL) -> APNsEnvironment {
if isSimulator { return .sandbox }
let profile = bundleURL.appending(path: "embedded.mobileprovision")
return FileManager.default.fileExists(atPath: profile.path) ? .sandbox : .production
}
/// Namespaced, so another family app's push can never be mistaken for this one's.
func noteID(fromPayload payload: [AnyHashable: Any]) -> String? {
(payload["spark"] as? [String: Any])?["note_id"] as? String
}
/// The delegate exists before the app's model does. A tap from a cold launch waits here.
@MainActor
final class PushTapQueue {
private var parked: String?
private var deliver: ((String) -> Void)?
func tapped(_ id: String) {
if let deliver { deliver(id) } else { parked = id } // a second early tap replaces the first
}
func attach(_ handler: @escaping (String) -> Void) {
deliver = handler
if let parked {
self.parked = nil
handler(parked)
}
}
}
SwiftUI 어댑터가 만들 수 있고, 콜백이 격리를 안전하게 넘는 델리게이트·swift
import SwiftUI
import UIKit
import UserNotifications
@MainActor
class PushDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
let payloadKey: String
let taps = PushTapQueue()
init(payloadKey: String) {
self.payloadKey = payloadKey
super.init()
}
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().delegate = self
return true
}
/// Not guaranteed on main. The async form means no completion handler crosses isolation;
/// only the extracted id does.
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
guard let id = noteID(fromPayload: response.notification.request.content.userInfo) else { return }
await taps.tapped(id)
}
}
/// @UIApplicationDelegateAdaptor builds the delegate through the Objective-C -init slot.
/// `override init()` does not compile here, and a plain `init()` compiles and traps at launch.
final class SparkPushDelegate: PushDelegate {
@objc init() { super.init(payloadKey: "spark") }
}
@main
struct SparkMobileApp: App {
@UIApplicationDelegateAdaptor(SparkPushDelegate.self) private var pushDelegate: SparkPushDelegate
var body: some Scene {
WindowGroup {
Text("Spark")
.onAppear { pushDelegate.taps.attach { id in print("open note", id) } }
}
}
}
iOS 코드 없이 Mac에서 초기화 트랩을 재현해. init(payloadKey:)를 가진 NSObject 하위 클래스, 그냥 init()을 가진 두 번째 하위 클래스, 그리고 제네릭 make<T: NSObject>(_ type: T.Type) -> T { T.init() }를 선언하고 돌려서 크래시를 적어. @objc init()으로 바꿔서 다시 돌려. 이어서 embedded.mobileprovision 파일이 있는 임시 폴더와 없는 임시 폴더로 apnsEnvironment(isSimulator:bundleURL:) 테스트를 쓰고, 일찍 온 탭이 둘이면 두 번째만 배달된다는 PushTapQueue 테스트를 써.
Hint
클래스 계층엔 진짜 델리게이트가 필요로 하는 곳에만 @MainActor를 붙여. 환경 함수와 페이로드 읽기는 평범한 값을 다루니까 macOS에서도 테스트할 수 있어. MainActor.assumeIsolated를 쓰면 동기 테스트 본문에서 대기열을 쓸 수 있어.
Progress
Progress is local-only — sign in to sync across devices.