CRM 캠페인에서 시작한 앱 진입 시스템 구축기 (1): 푸시 알림 수신과 클릭

시리즈
CRM 캠페인에서 시작한 앱 진입 시스템 구축기
이 글은 시리즈 1편이다. 이어지는 2편. 여러 경로를 모으는 딥링크 라우팅과 3편. 웹뷰와 네이티브를 잇는 브릿지에서는 푸시 이후 목적지로 이어지는 경로를 다룬다.
오더를 저장하고 결제하지 않은 사용자를 다시 앱으로 부르는 CRM(고객 재참여 마케팅) 채널 작업이었다. 팀 범위에는 푸시와 알림톡, 캠페인 URL에서 들어온 사용자를 이벤트 웹뷰나 네이티브 화면으로 연결하는 일이 포함됐다. 나는 iOS 쪽에서 푸시 이미지 표시를 위한 Extension 타깃과 클릭 payload(푸시 데이터) 처리를 맡았다.
1편에서는 Notification Service Extension과 알림 탭 payload 처리를 다룬다. 푸시와 딥링크의 개념은 푸시 알림 생명주기와 딥링크와 앱 라우팅에 정리했다.
진행 배경
문제 정의
CRM 캠페인의 목적은 오더를 저장하고 결제하지 않은 사용자가 다시 앱으로 돌아오게 만드는 일이었다. iOS 푸시에서 내가 맡은 구현은 두 시점으로 나뉘었다. 알림을 표시하기 전 Notification Service Extension에서 배너 이미지를 붙이는 일, 사용자가 알림을 누른 뒤 앱 진입부에서 이벤트 값을 읽고 목적지로 넘기는 일이다.
두 작업은 서로 다른 시스템 콜백에서 실행됐다. 이미지 처리는 앱이 열리기 전 Extension에서, 클릭 처리는 사용자가 알림을 누른 뒤 UNUserNotificationCenterDelegate에서 시작한다. 푸시 한 건을 이미지 표시부터 클릭 기록까지 한 함수에서 처리할 수 없으므로, 각 시점의 입력과 책임을 따로 확인했다.
구현 범위
내가 직접 추가한 Extension 타깃과 Firebase Messaging 연결, 캠페인 ID 매핑 수정은 2025년 7월 jeonghi 계정의 커밋에서 확인할 수 있다. 2026년 handler 경합 수정은 팀 동료가 작성한 후속 커밋이며, 저장소 기록에 Claude Opus 5 공동 작성이 표시되어 있다. 이 글에서는 푸시 처리의 후속 개선으로 설명하되, 내 개인 기여로 포함하지 않는다. 캠페인 URL 라우팅과 웹뷰 브릿지는 각각 2편과 3편에서 다룬다.
문제 해결 1단계: 푸시 생명주기
FCM/APNs, 앱 상태, NSE, ColdStart의 개념은 푸시 알림 생명주기에 정리했다.
푸시 탭 payload 처리
앱 진입부는 앱이 열린 상태에서 알림을 받았을 때 실행되는 foreground 콜백과, 사용자가 알림을 눌렀을 때 실행되는 클릭 콜백을 처리했다. 클릭 콜백은 푸시 데이터를 PushNotificationData로 디코딩하고, 그 안의 event 문자열을 다시 PushEventData로 디코딩해 웹뷰나 딥링크로 넘겼다.
처음에는 campaignId와 groupId를 바깥 PushNotificationData에 선언했다. 실제 값은 event 문자열을 JSON으로 읽은 PushEventData 안에 있었다. 2025년 7월 17일 수정은 두 값을 이벤트 데이터 모델로 옮기고, 클릭 이벤트도 그 모델을 디코딩한 뒤 보내도록 순서를 바꿨다.
struct PushNotificationData: Decodable {
let title: String?
let body: String?
let event: String?
}
struct PushEventData: Decodable {
let pushEventType: String
let uri: String
let campaignId: String?
let groupId: String?
enum CodingKeys: String, CodingKey {
case pushEventType = "type"
case uri
case campaignId
case groupId
}
}수정된 클릭 경로는 이벤트 문자열을 디코딩한 뒤 그 안의 ID로 기록한다.
guard let eventData = pushData.event?.data(using: .utf8) else { return }
let eventInfo = try JSONDecoder().decode(PushEventData.self, from: eventData)
sendPushClickedEvent(campaignId: eventInfo.campaignId, groupId: eventInfo.groupId)필드 위치가 맞지 않으면 클릭 사실은 기록해도 캠페인과 그룹을 연결할 수 없다. 7월 17일 변경은 payload 구조와 모델을 맞춘 수정이다. 목적지별 화면 해석은 다음 편의 딥링크 라우팅에서 다룬다.
같은 클릭 경로에서 type이 webview면 이벤트의 uri를 앱 내 웹뷰로 넘기고, 다른 유형이면 URI를 딥링크로 처리했다. 따라서 payload에는 목적지 정보와 클릭을 캠페인에 묶는 ID가 함께 필요했다.
if let eventType = PushEventType(rawValue: eventInfo.pushEventType.lowercased()) {
switch eventType {
case .webview:
handleWebViewURL(eventInfo.uri, title: pushData.title, fromPush: true)
}
} else {
handleDeeplinkURL(eventInfo.uri, fromPush: true)
}Rich Push (Notification Service Extension)
Rich Push가 필요한 이유
이번 캠페인 알림에는 텍스트와 함께 이미지도 표시해야 했다. 이를 위해 iOS의 Notification Service Extension(NSE) 타깃을 추가하고 Firebase Messaging helper를 연결했다. 실제 화면 녹화에는 알림 배너 오른쪽에 이미지 미리보기가 나온다.

Notification Service Extension 구현
2025년 7월 10일 추가한 Extension 타깃은 FCM payload를 직접 파싱하지 않고 FIRMessagingExtensionHelper에 알림 내용을 전달했다. 완료 handler는 iOS가 처리를 마치고 수정된 알림을 표시할 때 부르는 콜백이다. 당시 프로젝트 코드는 다음과 같다.
import UserNotifications
import FirebaseMessaging
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
guard let bestAttemptContent = bestAttemptContent else {
contentHandler(request.content)
return
}
FIRMessagingExtensionHelper().populateNotificationContent(
bestAttemptContent,
withContentHandler: contentHandler
)
}
override func serviceExtensionTimeWillExpire() {
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
}Firebase helper가 먼저 완료하면 수정된 알림 내용을 표시 경로로 넘긴다. 처리가 끝나기 전 Extension 실행 시간이 만료되면 보관해 둔 최선의 알림 내용을 대신 전달한다. 당시 두 경로는 같은 handler를 각각 직접 호출할 수 있었다.
2025년 7월 17일 화면 녹화에는 홈 화면 위에 표시된 센디 알림과, 이를 누른 뒤 열린 비용 계산기 첫 화면이 연속으로 담겼다.

기기 화면 녹화에서 발췌한 푸시 배너

알림 탭 후 열린 비용 계산기 첫 화면
2025년 7월 10일 프로젝트 이력에서 Extension 타깃 생성과 Firebase Messaging 의존성 추가를 확인했다.
2026년 8월: 완료 handler 경합 수정
2026년에는 푸시 알림을 앱 내 알림함에도 기록하는 비동기 작업이 Extension에 추가됐다. 알림 기록을 기다리는 Task와 실행 시간 만료 콜백 serviceExtensionTimeWillExpire()가 모두 하나뿐인 contentHandler를 가져가야 했다. 두 경로가 잠금 없이 동시에 handler를 읽고 비우면 완료 콜백을 중복 호출하거나, 이미 소비한 뒤에도 다른 경로가 handler를 사용하려는 경합이 생길 수 있었다.
2026년 8월 6일 변경은 두 경로의 handler 접근을 takeContentHandler() 하나로 모으고, NSLock 안에서 handler를 꺼낸 뒤 nil로 비웠다. 실제 수정의 핵심은 다음과 같다.
private let lock = NSLock()
private var contentHandler: ((UNNotificationContent) -> Void)?
private func takeContentHandler() -> ((UNNotificationContent) -> Void)? {
lock.lock()
defer { lock.unlock() }
let handler = contentHandler
contentHandler = nil
return handler
}알림 기록 Task와 시간 만료 콜백이 이 함수를 거쳐 handler를 경쟁적으로 가져간다. 먼저 가져간 쪽만 handler를 받고, 나중에 들어온 쪽에는 nil이 돌아온다. Task가 handler를 얻으면 그 값을 Firebase helper에 넘긴다. 이 변경은 이미지 배너의 표시를 바꾸지 않고, 비동기 알림 기록과 Extension 만료가 겹쳐도 완료 처리를 한 번으로 제한한다. 커밋에는 중복 호출이나 누락을 재현한 로그가 없다. 이 경합 수정은 앞서 밝힌 대로 팀 동료의 커밋이며, 코드 진화 과정을 설명하기 위해 기록했다.
확인한 동작과 남은 범위
2025년 7월 17일 기기 화면 녹화에는 이미지가 포함된 센디 알림과, 탭 뒤 열린 비용 계산기 첫 단계가 연속으로 담겼다. 이 자료가 보여 주는 것은 한 번의 표시와 이동 사례다. 앱 프로세스가 완전히 종료된 상태였는지는 확인할 수 없다.
코드에서는 foreground 수신과 클릭 기록을 확인했지만 전환 이벤트와 Mixpanel 퍼널 구성은 찾지 못했다. 따라서 전체 기기 도달률, 캠페인 전환율, 앱이 완전히 종료된 상태에서의 진입(ColdStart) 성공률은 결과로 제시하지 않는다. 푸시 탭 당시 앱 화면의 시작점을 맡는 화면 컨트롤러가 LaunchScreen이면 경로 정보를 저장하는 분기도 있었다. 당시 코드에서는 저장 값을 실제 화면 이동에 사용하는 부분을 찾지 못했다. 앱 초기화 중 푸시 탭 처리는 확인되지 않은 과제로 남아 있다.
다음 편: 딥링크 라우팅
캠페인별 클릭을 목적지 화면으로 연결하는 라우팅은 2편. 여러 경로를 모으는 딥링크 라우팅에서 다룬다.