들어가며
안녕하세요, 센디 iOS엔지니어 로디입니다.
센디 플랫폼 스쿼드에서 유입 전환율을 끌어올리기 위한 축 중 하나로 CRM 재진입 채널 강화를 시작했습니다. 회원가입 이후 결제까지 이어지지 못하고 이탈한 사용자를 다시 앱 안으로 데려올 수 있는 경로가 필요했고, 푸시·알림톡·외부 마케팅 URL 중 어디로 진입해도 이벤트 웹뷰·네이티브 화면 어디로든 자연스럽게 연결되는 확장 가능한 공통 구조로 설계하는 것이 기획 초기 합의였습니다. "지금 있는 캠페인 하나를 성공시키는 일"이 아니라 "앞으로의 모든 캠페인이 같은 레일 위에서 움직일 수 있게 만드는 일"이 이 작업의 출발점이었습니다.
Part 1에서는 이 공통 구조의 가장 첫 번째 층 — 푸시가 도달하고 앱까지 안정적으로 이어지는 생명주기 — 를 다룹니다. iOS 푸시는 겉에서 보면 "알림이 뜨고 눌렀더니 화면이 열렸다"가 전부이지만, 실제 구현 관점에서는 APNs·FCM의 관계, 앱 상태에 따라 달라지는 델리게이트 호출, Rich Push를 위한 별도 Extension, 그리고 앱이 꺼져 있던 상태에서 푸시로 진입했을 때의 payload 복원까지 한 층씩 밟아 올라가야 하는 구조가 있습니다.
진행 배경
문제 정의
CRM 재진입 채널이 사용자 경험 관점에서 충족해야 할 조건은 다음과 같았습니다.
- 회원가입 후 일정 시간 내에 결제 체크아웃에 진입하지 않은 사용자에게 리마케팅 푸시·알림톡을 발송할 수 있어야 합니다.
- 알림톡을 클릭한 사용자는 앱이 설치되어 있다면 앱 내 이벤트 웹뷰로 이어집니다.
- 푸시를 클릭한 사용자도 동일한 이벤트 웹뷰로 자연스럽게 이어집니다.
- 이벤트 웹뷰 안의 "신청", "더 많은 차량 보러가기", "센디 시작하기" 같은 CTA를 누르면 앱 내 임의의 네이티브 화면으로 자연스럽게 전환되어야 합니다.
- 캠페인 효과를 측정하기 위해 푸시 수신·클릭·전환 이벤트가 Mixpanel 퍼널에 일관되게 쌓여야 합니다.
이 중 4번이 구조 설계 관점에서 가장 까다로운 지점이었습니다. 한 캠페인·한 웹뷰·한 CTA만 연결하는 문제가 아니라 여러 캠페인·여러 이벤트 웹뷰·여러 CTA가 각기 다른 네이티브 화면으로 연결되어야 했고, 새 캠페인이 들어올 때마다 네이티브 릴리스를 거쳐야 하는 구조가 되면 이 채널 자체의 의미가 사라지기 때문입니다. 즉 이번 작업의 핵심은 확장 가능한 공통 라우팅 구조를 한 번에 설계해 두는 것이었습니다.
2·3·4번은 Part 2·3의 주제이고, Part 1의 범위는 1번(수신)과 5번(수신·클릭 이벤트 측정 기반)에 집중합니다. 푸시가 앱에 도달하고 클릭되는 흐름이 먼저 안정화되어야, 그 위에 딥링크·웹뷰·측정 레이어를 얹을 수 있다는 순서로 접근했습니다.
개선 방안 도출
"확장 가능한 공통 구조"라는 목표를 기준에 두고 대안을 비교했습니다.
| 대안 | 평가 |
|---|---|
| 푸시를 외부 SDK(OneSignal, Braze 등)에 위임 | 도입·심사 리소스 + 기존 FCM 구조·이벤트 taxonomy와의 정합성 끊김 |
| 서버 주도로 SMS/알림톡에만 집중 | 푸시 권한을 이미 허용한 사용자층을 활용 못 함. 재진입 채널이 한 축만 남음 |
| 기존 FCM 구조를 살려 네이티브 푸시 레이어를 공통 구조로 재정비 (채택) | 기존 이벤트 taxonomy와 자연스럽게 연결. 새 캠페인 추가 시 네이티브 변경 없이 동작하도록 확장할 수 있음 |
기존에는 간단한 푸시만 수신하고 있었고, CRM 이벤트의 Rich 콘텐츠(이미지 첨부·커스텀 캠페인 메타데이터)를 다룰 구조는 비어 있었습니다. 이 공백을 채우면서 세 가지를 한 번에 얹기로 했습니다. Rich Push 지원, ColdStart payload 복원, 그리고 이벤트 측정 hook. 이 세 축이 이후 Part 2·3에서 확장되는 공통 라우팅 레이어의 시작점이 됩니다.
배경 지식: 짧게 짚고 넘어가는 용어
본문에 자주 등장하는 용어 몇 개를 한 줄씩 정리합니다.
- APNs (Apple Push Notification Service): Apple이 운영하는 공식 푸시 게이트웨이. iOS 기기는 항상 APNs에 TLS 연결을 유지하고 있다가 메시지를 수신합니다.
- FCM (Firebase Cloud Messaging): Google이 운영하는 푸시 전송 SDK. 실제 iOS 기기로 가는 경로는 FCM 서버 → APNs → 기기입니다. 즉 FCM은 애플의 공식 경로를 활용하는 상위 레이어일 뿐, APNs를 대체하지 않습니다.
- UNUserNotificationCenter: iOS에서 알림 권한 요청·수신·노출·탭 처리를 담당하는 단일 진입점 API.
- Notification Service Extension: 푸시 페이로드를 표시 직전에 가로채 수정할 수 있는 별도 확장 타깃. 이미지를 덧붙여 Rich Push를 만들거나 페이로드를 디코딩해 콘텐츠를 다듬을 때 씁니다.
- ColdStart: 앱이 Terminated 상태였다가 사용자가 푸시를 눌러 처음부터 실행되는 경우를 지칭합니다.
문제 해결 1단계: 푸시 생명주기부터 다시 정리
FCM · APNs · iOS 앱의 관계
처음에 가장 헷갈리기 쉬운 지점이 "FCM을 쓰면 APNs가 필요 없는 것 아닌가"라는 부분이었습니다. 구현을 진행하면서 확인해 보니, iOS 푸시는 어떤 경우에도 APNs를 거쳐야 한다는 사실이 분명해졌습니다. FCM은 서버 → APNs 구간을 감싸 주는 편의 레이어이고, 실제로 기기에 메시지를 꽂아 주는 건 언제나 APNs입니다.
이 구조를 이해하는 것이 왜 중요했냐면, "푸시가 안 왔다"라는 리포트를 받았을 때 원인 구간을 서버 → FCM / FCM → APNs / APNs → 기기 세 층으로 나눠 볼 수 있기 때문입니다. 예컨대 FCM 대시보드에서는 성공으로 찍혀 있는데 기기에서 수신이 안 된다면, APNs 구간의 문제이지 저희 쪽 코드 문제가 아닐 가능성이 높다는 판단이 가능해집니다.
앱 상태별 처리가 달라집니다
iOS에서 푸시를 처리할 때 가장 실무적으로 까다로운 부분이 앱 프로세스 상태에 따른 델리게이트 호출 차이였습니다. 공식 문서에 흩어져 있는 내용을 정리하면 아래 세 가지 상태로 나뉩니다.
| 앱 상태 | 수신 시점에 호출되는 것 | 클릭 시점에 호출되는 것 |
|---|---|---|
| Foreground (실행 중) | userNotificationCenter(_:willPresent:withCompletionHandler:) | userNotificationCenter(_:didReceive:withCompletionHandler:) |
| Background (실행 중이지만 백그라운드) | 시스템이 알림만 표시 (앱 코드 호출 없음, content-available 페이로드는 예외) | userNotificationCenter(_:didReceive:withCompletionHandler:) |
| Terminated (완전 종료) | 시스템만 알림 표시. 앱은 꺼져 있음 | ColdStart로 앱 실행 + UIApplicationDelegate.application(_:didFinishLaunchingWithOptions:)의 launchOptions에 payload 포함 |
세 번째 Terminated 상태가 이번 CRM 시나리오에서 가장 중요한 경로였습니다. 사용자가 회원가입만 하고 앱을 종료한 뒤 푸시를 받아 클릭한다는 플로우가 타깃 시나리오였기 때문에, ColdStart를 제대로 처리하지 못하면 캠페인 전체가 의미를 잃는 구조였습니다.
한 군데로 모은 수신 핸들러
기존 코드에서는 푸시 관련 콜백이 AppDelegate에 흩어져 있어, 상태별 분기가 들어갈 때마다 같은 로직이 두세 곳에 복제되고 있었습니다. 이번 기회에 수신·클릭 처리를 한 타입으로 모으는 리팩토링을 같이 진행했습니다.
final class PushNotificationHandler: NSObject, UNUserNotificationCenterDelegate {
static let shared = PushNotificationHandler()
// Foreground 수신
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
handleReceived(notification: notification)
completionHandler([.banner, .sound, .list])
}
// 모든 상태에서 클릭
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
handleClicked(notification: response.notification)
completionHandler()
}
private func handleReceived(notification: UNNotification) {
let payload = parse(notification: notification)
// Mixpanel: push_received
EventReporter.shared.sendPushReceived(path: payload.path)
}
private func handleClicked(notification: UNNotification) {
let payload = parse(notification: notification)
// Mixpanel: push_clicked
EventReporter.shared.sendPushClicked(campaignId: payload.campaignId, groupId: payload.groupId)
// 딥링크는 Part 2에서 다룰 DeepLinkRouter로 위임
DeepLinkRouter.shared.route(payload.url, from: .push)
}
}이 구조를 잡은 덕분에 측정 코드는 수신·클릭 두 지점에만 두면 되고, 실제 화면 라우팅은 DeepLinkRouter 하나에 위임할 수 있었습니다. 이 책임 분리가 Part 2에서 이야기할 라우팅 설계의 기반이 됩니다.
문제 해결 2단계: Rich Push 지원 (Notification Service Extension)
왜 Rich Push가 필요했나
CRM 캠페인에서는 텍스트만으로는 클릭률이 충분히 나오지 않는다는 경험이 마케팅 쪽에 이미 있었습니다. 이번 캠페인에서도 이벤트 배너 이미지를 푸시에 붙여 보내는 것을 전제로 기획이 짜여 있어서, Rich Push를 지원하지 않으면 "푸시가 왔는지 안 왔는지" 수준의 관측만 가능해지는 상황이었습니다.
샌드박스와 Notification Service Extension
iOS는 앱 샌드박스의 제약을 우회하지 않는 방식으로 특정 작업을 허가하기 위해 App Extension이라는 전용 프로세스 체계를 두고 있습니다. Rich Push의 이미지 첨부 역시 메인 앱 프로세스 안에서 처리하기 어려운 작업이라, iOS는 푸시 payload 가공을 위한 별도 프로세스인 Notification Service Extension을 열어 두었습니다.
iOS 샌드박스·Extension 체계 전반과 각 Extension 종류의 제약은 이 글의 범위를 넘기 때문에 iOS 앱 샌드박스와 Extension 정리에 따로 정리해 두었습니다. 이 섹션에서는 CRM 푸시 시나리오에서 Notification Service Extension을 어떻게 구성했는지에 집중합니다. 핵심 전제 한 가지만 짚어 두면, Extension은 메인 앱과 완전히 분리된 샌드박스에서 실행되며 메인 앱의 토큰·캐시·UserDefaults가 그대로 보이지 않는다는 점입니다.
이 그림에서 알 수 있는 핵심은 세 가지입니다.
- Extension과 메인 앱이 사용하는 저장소가 다릅니다. 메인 앱의
UserDefaults·Keychain(기본 access group)·URLSession 캐시는 Extension에서 그대로 보이지 않습니다. Extension이 메인 앱 데이터를 참조해야 한다면 App Group을 명시적으로 구성해 공유 컨테이너를 쓰는 방법이 유일합니다. - Extension은 제약이 타이트합니다. iOS는 Extension에 약 30초의 실행 시간과 24MB 안팎의 메모리만 허용합니다. 이미지 다운로드·가공 같은 작업을 이 안에서 끝내야 하며, 실패 시에는 원본 payload라도 표시되도록
serviceExtensionTimeWillExpire()에서 fallback을 잡아 두어야 합니다. - 실행 시점이 메인 앱과 분리됩니다. 푸시가 도착하면 APNs는 먼저 Extension을 깨워 payload를 수정할 기회를 주고, 그다음에 알림이 표시됩니다. 메인 앱은 사용자가 알림을 탭한 뒤에야 실행됩니다. 즉 "푸시가 도착한 순간"과 "메인 앱 코드가 동작하는 순간"은 다릅니다.
구현
이 구조 위에서 저희가 작성한 Extension은 다음 역할을 수행합니다.
- 이벤트 배너 이미지 URL을 payload에서 꺼내 다운로드 후 첨부
- CRM 캠페인 메타데이터 일부를 미리 파싱해 표시용 문구를 다듬음
- 다운로드가 실패하거나 시간이 초과돼도 원본이라도 사용자에게 표시
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
self.bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let bestAttemptContent else {
contentHandler(request.content)
return
}
// payload에서 이미지 URL을 꺼내 별도 URLSession으로 다운로드
if let imageURLString = request.content.userInfo["image_url"] as? String,
let imageURL = URL(string: imageURLString) {
downloadAttachment(from: imageURL) { attachment in
if let attachment = attachment {
bestAttemptContent.attachments = [attachment]
}
contentHandler(bestAttemptContent)
}
} else {
contentHandler(bestAttemptContent)
}
}
override func serviceExtensionTimeWillExpire() {
// 30초 안에 처리 못 하면 원본이라도 표시되도록 fallback
if let contentHandler, let bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
}여기에서 downloadAttachment는 Extension 내부에서 자체 URLSession을 생성해 이미지를 내려받습니다. 메인 앱의 네트워크 레이어를 그대로 가져다 쓸 수 없으니 최소한의 세션만 구성하도록 주의했습니다.
구현 중 주의했던 지점들
별도 프로세스·별도 번들이라는 특성 때문에 실무적으로 걸리는 포인트가 몇 가지 있었습니다.
- 번들 ID 네이밍: 메인 앱이
com.sendy.ios일 때 Extension은com.sendy.ios.NotificationService처럼 접두사 규칙을 맞춰야 Provisioning Profile이 자동 연결됩니다. - 코드 서명 이중 분리: Ad Hoc·App Store 빌드 시 Extension 타깃에도 별도 Provisioning Profile이 필요합니다. CI 파이프라인에서 이 부분을 놓쳐서 서명 실패가 한 번 있었습니다.
- 이미지 다운로드는 Extension이 직접: 앞서 그림에서 본 샌드박스 분리 때문에 메인 앱의
URLCache·쿠키를 공유할 수 없습니다. Extension이 푸시마다 새로 네트워크를 타야 하므로 이미지 크기(권장 300KB 이하)를 운영팀과 합의했습니다. mutable-content플래그 필수: APNs payload에"mutable-content": 1이 빠지면 Extension 자체가 호출되지 않습니다. 이 플래그 누락이 처음엔 아무 에러 없이 Extension이 실행만 안 되는 형태로 나타나서 파악에 시간이 걸렸습니다.- 공유가 필요한 데이터는 App Group으로 명시 처리: 메인 앱이 저장한 디바이스 식별자나 AB 테스트 variant 같은 값을 Extension에서 참조해야 했다면, Entitlements에 App Group을 추가하고
UserDefaults(suiteName:)또는FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)을 써서 shared container를 명시적으로 사용해야 합니다.
Extension이 해 주는 일의 범위
Extension의 역할을 "이미지 첨부"로만 보면 작아 보이지만, 샌드박스가 분리된 상태에서 메인 앱보다 먼저 실행되는 지점이라는 특성이 이번 프로젝트에서 생각보다 중요한 자산이 됐습니다. CRM 캠페인 메타데이터를 Extension에서 미리 파싱해 두고 메인 앱은 이미 정제된 데이터를 꺼내 쓰도록 책임을 나누면, 메인 앱이 푸시 payload 원형을 다룰 필요가 없어집니다. 샌드박스를 "제약"이 아니라 "책임 분리 경계"로 읽은 결정이었습니다.
문제 해결 3단계: ColdStart 상태에서의 payload 복원
문제가 드러난 순간
구현 초기에 가장 오래 잡고 있었던 이슈가 여기였습니다. Foreground·Background에서는 딥링크가 정상 동작하는데, 앱이 완전히 종료된 상태에서 푸시로 진입하면 딥링크가 동작하지 않는 현상을 내부 테스트 중에 발견했습니다.
원인을 추적해 보니 ColdStart 시에는 일반 콜백 시퀀스가 아닌 UIApplicationDelegate.application(_:didFinishLaunchingWithOptions:)의 launchOptions를 통해 notification payload가 전달된다는 사실이 있었습니다. 일반 수신·클릭 콜백에만 로직을 두면 이 경로가 빠지게 됩니다.
launchOptions 경로
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// ... 일반 초기화
if let remoteNotification = launchOptions?[.remoteNotification]
as? [AnyHashable: Any] {
// ColdStart로 진입한 경우
handleColdStartPayload(remoteNotification)
}
return true
}한 가지 더: 초기화 순서 문제
단순히 launchOptions에서 payload를 꺼내는 것만으로는 부족했습니다. 앱이 ColdStart로 뜰 때는 화면 라우팅에 필요한 의존성들(인증 세션·코디네이터·내비게이션 컨트롤러 등)이 아직 초기화되지 않은 상태입니다. 이때 딥링크를 바로 호출해 버리면 내비게이션 스택 자체가 준비되지 않아 라우팅이 실패하거나 조용히 무시됩니다.
그래서 저희는 "payload는 당장 저장만 하고, 초기화가 끝난 다음 꺼내 처리"하는 지연 실행 구조를 택했습니다.
enum PendingDeepLink {
static var pending: URL?
}
func handleColdStartPayload(_ userInfo: [AnyHashable: Any]) {
let payload = parsePayload(userInfo)
// Mixpanel: push_clicked (ColdStart 경로)
EventReporter.shared.sendPushClicked(
campaignId: payload.campaignId,
groupId: payload.groupId
)
// 라우팅은 당장 하지 않고 일단 저장
PendingDeepLink.pending = payload.url
}
// 내비게이션 스택·인증이 모두 준비된 지점에서 호출
func consumePendingDeepLinkIfNeeded() {
guard let url = PendingDeepLink.pending else { return }
PendingDeepLink.pending = nil
DeepLinkRouter.shared.route(url, from: .push)
}왜 "언제 소비할지"가 까다로웠나
consumePendingDeepLinkIfNeeded()를 호출하는 시점을 잡는 것도 간단하지 않았습니다. 너무 이르면 내비게이션 스택이 없고, 너무 늦으면 사용자가 의도한 화면 전환 타이밍을 놓칩니다. 최종적으로는 메인 탭바 컨트롤러가 화면에 올라간 직후(viewDidAppear 최초 호출 시점)를 소비 지점으로 잡았습니다. 이 위치가 라우팅이 요구하는 모든 의존성이 준비되는 가장 이른 시점이었습니다.
이후 인증이 필요한 딥링크는 로그인 완료 이벤트에서도 같은 큐를 한 번 더 소비하도록 두었습니다. 비로그인 상태에서 ColdStart로 들어온 사용자가 로그인을 완료한 시점에도 원래 의도한 딥링크로 이어지게 하기 위함이었습니다.
문제 해결 4단계: 수신 여부 측정이 가능한가
CRM 팀에서 가장 먼저 물은 질문 중 하나가 "사용자가 푸시를 받았는지 알 수 있느냐"였습니다. 캠페인 효과를 측정하려면 발송 후 실제 기기 도달률을 알아야 한다는 주장이었습니다.
이 질문을 Android와 같은 기준으로 답하기가 어려웠습니다. FCM을 쓴다는 점은 같아도 두 OS에서 프로세스 상태별로 관측 가능한 범위가 다르기 때문입니다. 마케팅팀이 FCM 콘솔 숫자만 보고 두 플랫폼을 비교하려 했기 때문에, 먼저 이 차이부터 정리해서 공유해야 했습니다.
프로세스 상태별 수신 집계 범위 비교
| 프로세스 상태 | Android | iOS |
|---|---|---|
| Foreground | 앱이 실행 중이라 FCM SDK가 수신 이벤트를 바로 처리. 집계 가능 | 앱이 실행 중이라 willPresent 콜백으로 잡힘. 집계 가능 |
| Background | OS가 앱을 깨워 수신 처리. data-only 메시지는 onMessageReceived(), notification 메시지는 시스템이 표시하고 앱 시점에서 이벤트 집계 가능 | content-available: 1로 제한적 집계. iOS가 배터리·네트워크·사용 패턴을 보고 백그라운드 깨움을 스로틀링하므로 일부 누락 |
| Terminated | OS가 앱 프로세스를 다시 깨워 FCM 메시지를 처리. 수신 자체도 집계 범위에 들어옴 | 앱 프로세스가 존재하지 않음. 사용자가 탭하기 전까지 수신 자체를 앱도 FCM도 알 수 없음 |
이 차이의 구조적 원인은 간단합니다. Android는 OS 레벨에서 앱을 깨워 주는 경로가 있어서 Terminated 상태에서도 수신 자체가 관측 가능하지만, iOS는 Terminated 상태에서 앱이 실행되지 않아 탭 이전 구간이 완전 암흑이 됩니다. Background도 iOS 쪽은 스로틀링 때문에 상한값만 관측됩니다.
실무적으로 의미하는 것
FCM 콘솔의 "Delivered" 숫자를 두 OS가 나란히 보여 주더라도, 실제 값의 의미가 다릅니다.
- Android 쪽 숫자는 실제 기기 도달에 가까움
- iOS 쪽 숫자는 "APNs 게이트웨이 수락"에 가까운 상한값. 기기 실제 수신과의 gap이 구조적으로 존재
이 차이를 마케팅팀과 합의해 두지 않으면 "Android는 95% 도달, iOS는 20%"처럼 지표가 비정상적으로 보이는 상황이 생깁니다. 실제로는 측정 가능한 범위가 달라서 생긴 숫자인데 캠페인 실패처럼 해석될 위험이 있었습니다.
결론: 지표 설계 자체를 바꾸기로
iOS 쪽에서 "발송됐다"와 "클릭됐다" 사이 구간이 원천적으로 일부 암흑이라는 점이 분명해진 뒤, 측정 지표를 "수신률"이 아니라 "클릭률 · 클릭 이후 전환률" 중심으로 설계하자는 합의를 마케팅팀과 맺었습니다. 두 OS 공통의 가시 구간에서 비교 가능한 지표만 쓰는 쪽이, 숫자를 잘못 읽을 가능성을 줄이는 길이었습니다.
개선 효과 측정
Part 1 범위에서 측정하려 한 지표는 "푸시 레이어가 흔들림 없이 돌아가는가"에 가까웠습니다. 수치 자체는 대시보드에서 확인하는 편이라 여기서는 측정 기준과 채울 빈칸 위치를 정리해 둡니다.
| 항목 | 측정 방식 | 값 |
|---|---|---|
| 푸시 클릭 → 앱 진입 성공률 | push_clicked Mixpanel 이벤트 대비 app_foreground 연속 발생 비율 | 채울 빈칸 % |
| Rich Push 이미지 첨부 성공률 | 서버 캠페인 발송 로그 대비 Extension didReceive 이미지 attach 성공 비율 | 채울 빈칸 % |
| ColdStart 경로 딥링크 소비 성공률 | push_clicked(ColdStart) 이후 동일 세션 내 딥링크 타겟 화면 도달 비율 | 채울 빈칸 % |
| 누락되던 Terminated 경로 | 개편 전 ColdStart 진입 사용자의 타겟 화면 도달률 → 개편 후 | 전 채울 빈칸 % / 후 채울 빈칸 % |
채울 기준
- Mixpanel:
push_clicked이벤트 정의 참고. ColdStart 경로는source속성을cold_start로 분기해 두면 집계 편합니다.- Rich Push 성공률: 서버 전송 건수와 Extension 쪽 attach 로그 비교. Extension은 메인 앱과 별도 프로세스라 로그 수집 경로를 따로 두어야 합니다.
단점과 예상치 못한 변수
1. Extension 타깃 관리 오버헤드가 생각보다 컸습니다.
메인 앱과 별도 번들이라 Provisioning Profile·CI 빌드 설정·SDK 초기화까지 두 번씩 생각해야 했습니다. 특히 DEBUG/RELEASE 분기를 Extension 쪽에도 똑같이 맞추는 작업이 추가로 필요했습니다.
2. ColdStart 딥링크의 소비 시점 결정이 까다로웠습니다.
처음에는 didFinishLaunchingWithOptions에서 바로 라우팅을 시도했는데, 내비게이션 스택이 준비되지 않아 무작위로 실패하는 현상이 있었습니다. 소비 시점을 "메인 화면 진입 직후"로 옮긴 뒤에는 안정화됐지만, 시점 결정 자체에 비용이 꽤 들었습니다.
3. silent push 스로틀링의 관찰 가능성.
iOS의 내부 정책을 코드 레벨에서 들여다볼 수 없기 때문에, silent push 기반 수신 측정은 구조적으로 100%가 될 수 없었습니다. 이 한계를 캠페인팀에 먼저 설명하고 지표 설계를 바꾸는 쪽으로 합의한 결정이 사후적으로는 옳았다고 봅니다.
4. Rich Push 이미지 크기 운영 규칙.
Extension은 30초 제한 안에 이미지를 내려받아야 하므로, CDN 상태가 나쁜 지역에서는 원본이 그대로 표시될 수 있었습니다. 이미지 크기를 300KB 이내로 제한하는 운영 규칙을 마케팅팀과 합의했지만, 이 규칙이 지켜지지 않으면 Rich가 아닌 일반 푸시로 fallback되는 구조입니다.
이후 방향성
Part 1에서 정리한 푸시 수신·측정·ColdStart 복원까지가 다음 단계(딥링크 라우팅)를 얹기 위한 기반이었습니다. 특히 PushNotificationHandler와 PendingDeepLink 두 축은 Part 2에서 바로 소비되는 구조로 이어집니다.
Part 2에서는 이렇게 확보한 payload를 가지고 알림톡·푸시·외부 마케팅 URL 각각의 진입 경로에 대해 어떻게 라우팅을 재설계했는지를 다룹니다. Custom URL Scheme과 Universal Link 중 어느 쪽을 기본으로 두었고, 앱 초기화 상태별 처리 구조를 어떻게 잡았는지가 중심이 됩니다.