Android 앱에서 푸시가 오는 길은 이미 한 번 다 놓았다. 그래서 iPhone 쪽은 같은 다리를 하나 더 놓으면 될 줄 알았다. 서버 코드를 열어 보기 전까지는.
식단서랍 서버는 FCM 메시지를 data 전용으로 보낸다. notification 필드 없이 {title, body, url, tag, channel} 만 싣는다. Android 는 그래야 앱이 어떤 상태든 onMessageReceived 로 받아서 알림을 직접 그리고, 탭했을 때 열 주소를 고를 수 있다. 「TWA에서 WebView로」 연재에서 일부러 그렇게 만들었다.
iOS 에서는 바로 그 선택이 발목을 잡는다. data 전용 메시지는 iOS 에서 알림이 아니라 조용한 백그라운드 신호에 가깝다. 앱이 꺼져 있으면 시스템이 아무것도 띄우지 않는다. 앱 쪽 코드를 아무리 잘 짜도, 서버가 보내는 모양이 바뀌지 않으면 잠금 화면에는 아무것도 오지 않는다.
APNs 로 가는 길을 먼저 연다
iPhone 에 푸시를 띄우는 건 결국 Apple 의 APNs 다. FCM 은 그 앞에서 메시지를 받아 APNs 로 넘겨 주는 중계자다. 그러려면 Firebase 가 내 팀 이름으로 APNs 에 말을 걸 수 있어야 하고, 그 자격이 APNs 인증 키(.p8)다.
예전에는 앱마다 푸시 인증서를 만들고 해마다 갱신했다. 인증 키는 팀 단위다. Apple Developer 에서 한 번 만들면 팀의 모든 앱에, 개발용과 운영용 환경에 같이 쓴다. 업무용 A/S 웹 서비스 앱을 옮길 때 만든 키를 식단서랍에도 그대로 썼다. 새 앱마다 할 일은 Firebase 프로젝트에 iOS 앱을 추가하고, 클라우드 메시징 설정에 같은 .p8 파일과 키 ID, 팀 ID 를 올리는 것뿐이다.
여기에 작은 함정이 하나 있다. Firebase 의 Apple 앱 구성 화면에는 칸이 두 개다. 위가 APNs 인증 키, 아래가 APNs 인증서다. 키는 위 칸에 올리고 아래 칸은 비워 둔다. 엉뚱한 칸에 올리면 서버 발송이 ThirdPartyAuthError 로 튕긴다. 앱 코드와는 아무 상관 없는 오류라 원인을 찾기 어렵다. 또 하나, Bundle ID 에 Push Notifications 기능을 켠 뒤에 배포 프로파일을 만들어야 한다. 순서를 거꾸로 하면 프로파일에 푸시 권한이 빠진다.
그다음이 서버였다. FCM 메시지에 Apns 블록을 붙였다. 이 블록은 iOS 대상에만 쓰이고 Android 는 보지 않는다. 그래서 Android 앱이 data 를 받아 직접 그리는 동작은 한 글자도 바뀌지 않는다.
Apns = new ApnsConfig
{
Headers = headers, // apns-push-type=alert, apns-priority=10, apns-expiration=1일, apns-collapse-id=tag
Aps = new Aps
{
Alert = new ApsAlert { Title = title, Body = message.Body ?? string.Empty },
Sound = message.Channel == "daily_menu" ? null : "default",
},
},
헤더에 Android 쪽 설정을 하나씩 옮겼다. Android 의 TTL 1일은 apns-expiration 으로, 같은 tag 면 알림을 덮어쓰던 규칙은 apns-collapse-id 로 옮겼다. collapse-id 는 64바이트까지라 그보다 긴 tag 는 SHA-256 해시 앞 32자로 줄인다. 「식단서랍」 그 후 연재에서 오늘의 메뉴 알림만 소리 없는 채널로 나눴는데, iOS 에는 채널이 없다. 대신 daily_menu 일 때 sound 를 빼서 조용히 쌓이게 했다. 채널 값은 서버가 고르니 iOS 도 같은 값을 보고 판단하면 된다.
권한은 웹이 부를 때만 묻는다
앱을 처음 켜자마자 "알림을 보내도 될까요?" 를 띄우는 앱이 많다. 식단서랍 Android 앱은 그러지 않는다. 사용자가 웹의 알림 설정에서 켜기를 눌러야 requestPushToken() 이 불리고, 그때 Android 13 의 알림 권한을 묻는다. iOS 도 똑같이 했다. iOS 의 권한 대화상자는 앱 평생 한 번만 뜨고, 거절하면 설정 앱으로 보내는 수밖에 없다. 그 한 번을 맥락 없는 첫 화면에서 쓰고 싶지 않았다.
다만 iOS 는 순서가 하나 더 있다. 앱이 시작되면 registerForRemoteNotifications() 로 APNs 등록부터 해 둔다. 이건 권한과 별개라 대화상자 없이 조용히 끝나고, 기기 토큰이 AppDelegate 로 돌아온다. Firebase 의 자동 연결(swizzling)은 꺼 두고 그 토큰을 Messaging.messaging().apnsToken 에 직접 넣었다. 웹이 알림을 켜면 권한을 묻고, 허용하면 APNs 토큰이 Messaging 에 들어왔는지 확인한 뒤 FCM 등록으로 넘어간다.
APNs 토큰이 아직 안 왔으면 기다린다. 기다리는 요청을 목록에 걸어 두고, 토큰이 오거나 등록이 실패하면 한꺼번에 푼다. 20초가 지나면 unavailable 로 끝낸다. 웹의 push.js 가 30초 뒤에 시간 초과를 내니, 앱이 먼저 답해야 웹이 엉뚱한 메시지를 띄우지 않는다. 결과는 늘 메인 스레드에서 돌려준다. 권한 상태도 Android 때처럼 웹의 Notification.permission 과 같은 세 값으로 맞췄다. 아직 묻지 않았으면 default, 거절했으면 denied, 나머지는 granted 다.
포그라운드는 앱이 받는다
앱을 보고 있을 때 온 푸시는 또 다르다. iOS 는 기본적으로 앱이 앞에 떠 있으면 알림을 표시하지 않는다. UNUserNotificationCenterDelegate 의 willPresent 에서 배너로 보여 달라고 답해야 Android 처럼 위에 뜬다. 여기서 오늘의 메뉴만 소리 옵션을 뺀다.
배너만으로는 모자랐다. 식단서랍 웹에는 상단 종 아이콘에 안 읽은 알림 수가 뜬다. 푸시가 왔는데 열린 화면의 숫자가 그대로면 이상하다. Android 앱은 알림을 그린 뒤 웹의 _onPushReceived 를 불러 종을 다시 불러오게 한다. iOS 도 willPresent 에서 원격 푸시일 때만 같은 함수를 부르게 했다. 웹은 어느 앱이 불렀는지 모른다. 같은 이벤트를 받고 같은 일을 한다.
서버가 Apns 블록을 붙이기 전의 메시지, 즉 data 전용 메시지가 백그라운드로 들어오는 경우도 남겨 두었다. 그때는 Android 처럼 앱이 로컬 알림을 직접 만든다. aps.alert 가 있으면 시스템이 이미 그렸으니 만들지 않는다. 같은 알림이 두 번 뜨는 일을 막는 조건이다. 이 경로 덕분에 서버와 앱 중 어느 쪽이 먼저 바뀌어도 알림이 아예 빠지지는 않는다.
알림을 탭하면 열 주소를 고르는 규칙은 Android 의 resolveTarget 을 그대로 옮겼다. 상대 경로면 앱 도메인을 붙이고, 앱 도메인의 https 주소만 연다. 앱이 완전히 꺼진 상태에서 알림을 눌러 켜진 경우에는 아직 WebView 가 없다. 주소를 들고 있다가 화면이 준비되면 연다. 알림 delegate 를 didFinishLaunching 에서 연결해 둔 이유도 이것이다. 늦게 연결하면 탭 이벤트를 놓친다.
토큰 대신 FID 를 보낸다
여기까지 짜고 빌드하니 경고가 두 개 떴다. FCM 등록 토큰을 받는 API 가 deprecated 라는 것이다. 확인해 보니 Firebase 가 방식을 바꾸고 있었다. iOS SDK 12.18 부터 token, deleteToken, didReceiveRegistrationToken 이 deprecated 가 됐고, 그 자리에 register() 와 unregister() 가 생겼다. 서버의 Admin SDK 문서도 기기 하나에 보낼 때 FID(Firebase 설치 ID)를 권장하고, token 필드는 없앨 예정이라고 적고 있었다. 언제 없앤다는 날짜는 어디에도 없었다.
지금 방식도 계속 동작한다. 그래도 바꾸기로 한 건 시점 때문이었다. 두 방식은 한 앱 안에서 섞이지 않는다. Info.plist 에 FirebaseMessagingInstallationIdEnabled 를 켜면 토큰 API 가 실패하고, 끄면 register() 가 실패한다. 즉 앱 버전 단위로 갈아타야 한다. Android 는 이미 사용자가 있어서 갈아타면 옛 토큰과 새 FID 가 섞이는 기간을 견뎌야 한다. iOS 는 아직 한 명도 없다. 처음부터 FID 로 내면 나중에 iOS 사용자를 옮길 일 자체가 없다.
앱 쪽은 흐름이 한 단계 늘었다. FCM 에 등록하고, 성공하면 이 설치의 FID 를 따로 묻는다.
Messaging.messaging().register { error in
if error != nil { finish(nil, "unavailable"); return }
Installations.installations().installationID { fid, _ in
if let fid = fid, !fid.isEmpty {
PushTokenStore.save(fid)
finish(fid, nil)
} else {
finish(nil, "unavailable")
}
}
}
웹 브리지는 손대지 않았다. getPushToken() 이라는 이름 그대로, 토큰이 있던 자리에 FID 를 넘긴다. 웹은 받은 문자열을 서버에 등록할 뿐이라 그 값이 무엇인지 모른다. 이름을 getPushId() 로 바꾸면 Android 옛 버전과 웹이 어긋난다. 이름이 내용과 조금 다른 건 감수했다. 갱신 감지는 didReceiveRegistration 이 맡는다. 구독한 적 있는 기기에서 값이 바뀐 경우에만 캐시를 고치고 웹에 알린다. 끄기는 unregister() 다. 해제한 FID 로 보내면 FCM 이 404 를 돌려주고, 서버는 원래 하던 대로 그 구독을 비활성으로 돌린다.
서버는 저장된 값의 모양으로 둘을 가른다. 등록 토큰은 : 이 들어간 150~250자 문자열이고, FID 는 : 없는 22자 안팎의 base64url 문자열이다.
public static bool IsFid(string? value)
=> !string.IsNullOrEmpty(value) && value.Length is >= 16 and <= 64
&& value.All(c => char.IsAsciiLetterOrDigit(c) || c == '-' || c == '_');
FID 면 Message.Fid 에, 아니면 기존 Message.Token 에 싣는다. DB 스키마도, 등록 API 도, 토큰 검증도 그대로다. FID 도 기존 검증(공백 없음, 512자 이하)을 통과한다. Android 의 등록 토큰은 이 함수에서 false 가 나오니 예전 길로 간다.
순서는 서버가 먼저였다. 문서상으로는 전환 기간 동안 Token 필드에 FID 를 넣어도 받아 준다고 한다. 그래도 곧 없어질 필드에 기대는 셈이라, 분기를 먼저 넣고 iOS 를 FID 로 내기로 했다. A/S 웹 서비스 서버에 먼저 넣어 운영에 올렸고, 식단서랍 서버에는 Apns 블록과 함께 넣었다. 테스트는 IsFid 경계값(15·16·64·65자, : 가 든 토큰), FID 와 토큰이 각각 맞는 필드로 가는지, 오늘의 메뉴만 소리가 빠지는지, 긴 tag 의 collapse-id 가 64바이트를 넘지 않는지를 확인한다.
Android 는 일부러 그대로 두었다. 옛 토큰 앞부분이 그 기기의 FID 와 같은지, FID 로 바꾼 뒤 옛 토큰이 계속 받는지는 실기기로 확인해야 같은 알림이 두 번 가는 일을 막을 수 있다. 다음 정기 업데이트 때 할 일로 넘겼다.
돌아보면 iOS 푸시는 앱보다 서버에서 먼저 풀렸다. 앱이 받을 준비를 아무리 해도, 서버가 보내는 메시지 모양이 Android 에 맞춰져 있으면 아이폰은 조용했다. 그리고 출시 전이라는 짧은 틈은 생각보다 값졌다. 옮길 사용자가 없을 때 바꾸는 게 가장 싸다. 실기기에서 세 가지 상태로 알림이 오는지는 TestFlight 에서 확인할 일로 남아 있다.
다음 편은 로그인이다. 카카오 로그인이 있는 웹을 아이폰 앱에 담으려면 Apple 로그인이 있어야 했다.