LODY/기록

센디 CRM 재진입 채널의 확장 가능한 푸시, 딥링크, 웹뷰 브릿지 설계

박정환박정환

들어가며

센디 플랫폼 스쿼드에서 유입 전환율을 끌어올리기 위한 축 중 하나로 CRM 재진입 채널 강화를 시작했다. 오더를 저장(작성중 오더 생성)해 두고도 당일 결제로 이어가지 못한 사용자는 시간이 지날수록 복귀 결제율이 급격히 떨어졌고, 이들이 완전히 식기 전에 다시 앱 안으로 데려올 경로가 필요했다. 푸시, 알림톡, 외부 마케팅 URL 중 어디로 진입해도 이벤트 웹뷰나 네이티브 화면으로 이어지는 확장 가능한 공통 구조가 기획 초기 합의였다.

이 판단의 근거는 리텐션 데이터였다. 센디에서 사용자는 견적을 확인하고 오더를 저장한 뒤, 그 자리에서 바로 결제하거나 나중에 다시 들어와 결제한다. Mixpanel 리텐션 리포트로 작성중 오더 생성 이후 최초 결제까지의 복귀율을 보면, 복귀 결제는 저장 당일에 크게 몰렸다가 급격히 꺾였다. 카테고리마다 당일 복귀율은 14~36%로 갈렸지만 곡선의 모양은 공통이었다.

작성중 오더 생성 후 최초 결제 복귀율 (카테고리별, 익명화) · Mixpanel 리텐션 리포트

이 곡선이 채널의 타겟을 정했다. 저장 당일 결제는 자연 전환이 이미 가장 잘 일어나는 구간이라 외부 개입의 여지가 크지 않다. 반대로 당일 결제하지 않은 사용자는 다음 날 복귀율이 절반 이하로 꺾이고, 1~2주 안에 되돌리지 못하면 거의 회수되지 않는다. 곡선이 가장 가파르게 떨어지는 저장 직후 며칠이, 뒤집어 보면 외부 자극으로 복귀를 끌어올릴 여지가 가장 큰 골든타임이었다. 그래서 채널의 목표를 "이탈 사용자 일반"이 아니라 저장은 했지만 당일 결제하지 않은 사용자를, 복귀율이 바닥으로 꺼지기 전 며칠 안에 다시 부르는 것으로 좁혔다.

다루는 층은 셋이다.

  1. 푸시가 도달하고, 앱이 완전히 종료된 상태에서 푸시로 켜지는 ColdStart까지 안정적으로 이어지는 생명주기
  2. 세 진입 경로를 하나의 라우팅 레이어로 모으는 딥링크 처리
  3. 이벤트 웹뷰 CTA에서 네이티브 화면으로 넘기는 브릿지와 웹뷰 스택

푸시와 딥링크의 개념 정리는 docs에 따로 두었다. 푸시 생명주기(FCM/APNs, 앱 상태, NSE, ColdStart)는 푸시 알림 생명주기, URI Scheme과 Universal Link, Deferred는 딥링크와 앱 라우팅을 본다. 이 글은 센디에서 그 위에 올린 실무 설계만 적는다.


진행 배경

문제 정의

CRM 재진입 채널이 충족해야 할 조건은 다음과 같았다.

  1. 오더를 저장하고도 일정 시간 내에 결제하지 않은 사용자에게 리마케팅 푸시·알림톡을 발송할 수 있어야 한다.
  2. 알림톡을 클릭한 사용자는 앱이 설치되어 있다면 앱 내 이벤트 웹뷰로 이어진다.
  3. 푸시를 클릭한 사용자도 동일한 이벤트 웹뷰로 자연스럽게 이어진다.
  4. 이벤트 웹뷰 안의 "신청", "더 많은 차량 보러가기", "센디 시작하기" 같은 CTA를 누르면 앱 내 임의의 네이티브 화면으로 자연스럽게 전환되어야 한다.
  5. 캠페인 효과를 측정하기 위해 푸시 수신·클릭·전환 이벤트가 Mixpanel 퍼널에 일관되게 쌓여야 한다.

4번이 구조적으로 가장 까다로웠다. 한 캠페인과 한 웹뷰, 한 CTA만 연결하는 문제가 아니라 여러 캠페인과 여러 이벤트 웹뷰, 여러 CTA가 각기 다른 네이티브 화면으로 연결되어야 했고, 새 캠페인마다 네이티브 릴리스가 필요하면 채널 자체의 의미가 사라진다.

개선 방안 도출

대안평가
푸시·딥링크를 외부 SDK(OneSignal, Braze 등)에 위임도입·심사 리소스 + 기존 FCM·이벤트 taxonomy와 정합성 끊김
진입점·웹뷰마다 ad-hoc 분기단기에는 빠르지만 캠페인마다 네이티브 변경이 반복됨
기존 FCM을 유지한 채 푸시 레이어·공통 라우팅·표준 브릿지로 재정비 (채택)기존 taxonomy와 연결. 새 캠페인은 매칭 테이블·웹 계약만 추가

나는 이 셋을 별개 작업으로 나누지 않고 한 레이어 설계로 묶자고 제안했다. Rich Push와 ColdStart 복원, 세 URL 방식을 한 라우팅 레이어로 모으기, 웹뷰 CTA 브릿지와 2-depth 스택, 세 축을 한 번에 얹었다.


문제 해결 1단계: 푸시 생명주기

FCM/APNs, 앱 상태, NSE, ColdStart의 개념은 푸시 알림 생명주기를 본다. 여기서는 센디에서 핸들러와 측정, 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)
        // 딥링크 처리는 DeepLinkRouter로 위임
        DeepLinkRouter.shared.route(payload.url, from: .push)
    }
}

이 구조를 잡은 덕분에 측정 코드는 수신과 클릭 두 지점에만 두면 되고, 실제 화면 라우팅은 DeepLinkRouter 하나에 위임할 수 있었다. 이 책임 분리가 뒤에서 다룰 라우팅 설계의 기반이 된다.


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가 그대로 보이지 않는다는 점이다.

APNsNotificationService독립 샌드박스~30초 / ~24MBpayload 가로채 수정Main App자체 샌드박스UserDefaults / KeychainMixpanel SDKApp Groupshared container(명시할 때만 공유)mutable-content: 1알림 탭 후Extension 프로세스메인 앱 프로세스

이 그림에서 알 수 있는 핵심은 세 가지이다.

  • 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 원형을 다룰 필요가 없어진다.


ColdStart payload 복원

문제가 드러난 순간

구현 초기에 가장 오래 잡고 있었던 이슈가 여기였다. Foreground와 Background에서는 딥링크가 정상 동작하는데, 앱이 완전히 종료된 상태에서 푸시로 진입하면 딥링크가 동작하지 않는 현상을 내부 테스트 중에 발견했다.

원인을 추적해 보니, 종료 상태에서 알림을 탭해 앱이 켜지면 그 탭 이벤트 자체는 didReceive 콜백으로 정상 전달됐다. 문제는 그 시점이었다. ColdStart로 막 뜬 순간에는 화면 라우팅에 필요한 의존성(인증 세션, 코디네이터, 내비게이션 컨트롤러)이 아직 준비되지 않아, 콜백에서 곧장 딥링크를 호출하면 내비게이션 스택이 없어 라우팅이 조용히 실패했다.

초기화 순서 문제

그래서 핵심은 딥링크 라우팅을 의존성이 준비될 때까지 미루는 것이었다. payload는 탭을 받은 순간 저장만 해 두고, 초기화가 끝난 다음 꺼내 처리하는 지연 실행 구조를 택했다.

didReceive종료 상태 탭 수신PendingDeepLink에 URL 저장탭바 viewDidAppear(의존성 준비됨)consumePendingDeepLinkIfNeeded
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로 들어온 사용자가 로그인을 완료한 시점에도 원래 의도한 딥링크로 이어지게 하기 위함이었다.


수신 측정 한계

CRM 팀에서 가장 먼저 물은 질문 중 하나가 "사용자가 푸시를 받았는지 알 수 있느냐"였다. 캠페인 효과를 측정하려면 발송 후 실제 기기 도달률을 알아야 한다는 주장이었다.

이 질문을 Android와 같은 기준으로 답하기가 어려웠다. FCM을 쓴다는 점은 같아도 두 OS에서 프로세스 상태별로 관측 가능한 범위가 다르기 때문이다. 마케팅팀이 FCM 콘솔 숫자만 보고 두 플랫폼을 비교하려 했기 때문에, 먼저 이 차이부터 정리해서 공유해야 했다.

프로세스 상태별 수신 집계 범위 비교

프로세스 상태AndroidiOS
Foreground앱이 실행 중이라 FCM SDK가 수신 이벤트를 바로 처리. 집계 가능앱이 실행 중이라 willPresent 콜백으로 잡힘. 집계 가능
BackgroundOS가 앱을 깨워 수신 처리. data-only 메시지는 onMessageReceived(), notification 메시지는 시스템이 표시하고 앱 시점에서 이벤트 집계 가능content-available: 1로 제한적 집계. iOS가 배터리·네트워크·사용 패턴을 보고 백그라운드 깨움을 스로틀링하므로 일부 누락
TerminatedOS가 앱 프로세스를 다시 깨워 FCM 메시지를 처리. 수신 자체도 집계 범위에 들어옴사용자가 직접 종료한 경우 프로세스가 존재하지 않아 탭하기 전까지 수신 자체를 앱도 FCM도 알 수 없음. 메모리 회수 등으로 시스템이 앱을 내린 경우는 alert 푸시의 Extension이나 content-available 사일런트 푸시로 일부 백그라운드 실행이 가능하지만, 이 역시 스로틀링 대상이라 보장되지는 않음

이 차이의 구조적 원인은 간단하다. Android는 OS 레벨에서 앱을 깨워 주는 경로가 있어서 Terminated 상태에서도 수신 자체가 관측 가능하지만, iOS는 사용자가 직접 종료한 경우 수신을 관측할 프로세스 자체가 없고, 시스템이 앱을 내린 경우에도 Notification Service Extension이 best-effort로 기동해 부분 신호만 남길 뿐, 저전력이거나 리소스 압박 상황에서는 스킵될 수 있어 탭 이전 구간의 수신을 보장할 수 없다. Background도 iOS 쪽은 스로틀링 때문에 상한값만 관측된다.

실무적 의미

FCM 콘솔의 "Delivered" 숫자를 두 OS가 나란히 보여 주더라도, 실제 값의 의미가 다르다.

  • Android 쪽 숫자는 실제 기기 도달에 가까움
  • iOS 쪽 숫자는 "APNs 게이트웨이 수락"에 가까운 상한값. 기기 실제 수신과의 gap이 구조적으로 존재

이 차이를 마케팅팀과 합의해 두지 않으면 "Android는 95% 도달, iOS는 20%"처럼 지표가 비정상적으로 보이는 상황이 생긴다. 실제로는 측정 가능한 범위가 달라서 생긴 숫자인데 캠페인 실패처럼 해석될 위험이 있었다.

결론: 지표 설계 변경

iOS 쪽에서 "발송됐다"와 "클릭됐다" 사이 구간이 원천적으로 일부 암흑이라는 점이 분명해진 뒤, 나는 측정 지표를 "수신률"이 아니라 "클릭률과 클릭 이후 전환률" 중심으로 다시 설계하자고 마케팅팀에 제안했고, 이 기준으로 합의를 맺었다. 두 OS 공통의 가시 구간에서 비교 가능한 지표만 쓰는 쪽이, 숫자를 잘못 읽을 가능성을 줄이는 길이었다.

개선 효과

이 단계에서 눈으로 확인된 변화는 둘이다. 개편 전에는 앱이 완전히 종료된 상태에서 푸시로 진입하면 딥링크가 조용히 실패해 사용자가 홈에 떨어졌는데, 지연 소비 구조를 넣은 뒤로는 종료 상태에서 푸시를 눌러도 원래 의도한 화면까지 끊김 없이 이어졌다. 텍스트만 오던 알림에는 이벤트 배너 이미지가 붙어, 캠페인 배너를 푸시에서 바로 보여줄 수 있게 됐다. 정량 지표는 캠페인별로 대시보드에서 확인하며, 아래는 그 측정 기준과 개편 전후 값이다.

항목측정 방식
푸시 클릭 → 앱 진입 성공률push_clicked Mixpanel 이벤트 대비 app_foreground 연속 발생 비율94% 안팎
Rich Push 이미지 첨부 성공률서버 캠페인 발송 로그 대비 Extension didReceive 이미지 attach 성공 비율97% 안팎
ColdStart 경로 딥링크 소비 성공률push_clicked(ColdStart) 이후 동일 세션 내 딥링크 타겟 화면 도달 비율91% 안팎
누락되던 Terminated 경로개편 전 ColdStart 진입 사용자의 타겟 화면 도달률, 개편 후와 비교전 0% / 후 91% 안팎

측정 참고

  • 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. Rich Push 이미지 크기 운영 규칙.

Extension은 30초 제한 안에 이미지를 내려받아야 하므로, CDN 상태가 나쁜 지역에서는 원본이 그대로 표시될 수 있었다. 이미지 크기를 300KB 이내로 제한하는 운영 규칙을 마케팅팀과 합의했지만, 이 규칙이 지켜지지 않으면 Rich가 아닌 일반 푸시로 fallback되는 구조이다.

푸시가 안정적으로 도달하고 ColdStart까지 이어져도, 그 클릭을 어느 화면으로 보낼지가 정해지지 않으면 재진입은 완성되지 않는다. 세 진입 경로를 한 곳에서 같은 목적지로 떨구는 라우팅이 다음 문제였다.


문제 해결 2단계: 딥링크 라우팅

세 진입 경로(푸시, 알림톡, 외부 마케팅 URL)가 같은 이벤트 웹뷰와 네이티브 화면으로 떨어져야 했다. URI Scheme / Universal Link / Deferred의 개념은 딥링크와 앱 라우팅을 본다. 여기서는 센디 라우팅 레이어만 적는다.

세 방식의 매칭 테이블 통합

이번 작업에서 가장 먼저 결정해야 했던 건 세 가지 URL 방식을 어떻게 한 곳에서 다룰 것인가였다.

항목URI SchemeUniversal LinkOneLink(Deferred)
URL 형식sendyapp://...https://sendy.ai/...https://sendy.onelink.me/...
앱 미설치 시동작 안 함웹으로 열림설치 후 복원 시도
소유권 검증없음AASA로 검증(도메인이 어떤 경로를 앱으로 열지 선언하는 파일)AppsFlyer 도메인
주 용도앱 내부·레거시웹·외부 마케팅 공유미설치 사용자 attribution
설정 비용낮음 (Info.plist 스킴 등록)중간 (AASA + Associated Domains)SDK 연동

셋 중 하나만 고르는 문제가 아니었다. 세 방식이 모두 필요했다.

  • 알림톡: 앱이 있으면 바로 열려야 한다
  • 외부 마케팅 URL: 프리뷰가 깨지지 않는 HTTPS여야 한다
  • 미설치 사용자: 설치 후 목적지가 복원되어야 한다

그래서 방식별로 분기를 나누는 대신, 하나의 목적지가 여러 URL 형태를 동시에 갖도록 매칭 테이블을 만들었다.

센디의 실제 라우트 정의(DeepLinkConfig.RoutePattern)를 보면, 하나의 라우트가 OneLink 경로, sendy.ai 웹 경로, sendyapp:// 스킴을 한꺼번에 들고 있다.

enum RoutePattern: CaseIterable {
    case draftOrder
    case checkout
    case reviews
    // ...
 
    var locations: [Location] {
        switch self {
        case .checkout:
            return [
                webLocation(authority: .oneLink, path: "/JJet/xxxxxxxx"),          // OneLink
                .init(authority: WebHost.sendyAI.rawValue, path: "/checkout-order"),   // https://sendy.ai
                .init(scheme: .sendyapp, authority: AppLinkTarget.checkout.rawValue)   // sendyapp://checkout
            ]
        case .reviews:
            return [
                webLocation(authority: .oneLink, path: "/JJet/xxxxxxxx"),
                .init(authority: WebHost.sendyAI.rawValue, path: "/reviews")
            ]
        // ...
        }
    }
}

이 구조의 이점은, 새 목적지를 추가할 때 이 테이블에 한 줄만 더 붙이면 된다는 것이다. 진입점(SceneDelegate)도, 파서도 건드릴 필요가 없다. sendyapp://를 계속 남겨 둔 이유도 명확해진다. 앱 내부 화면 이동을 짧은 스킴으로 표현하는 편이 로깅과 디버깅에 편하고, 이미 발송된 레거시 링크 일부가 스킴을 쓰고 있어 한동안 양쪽을 모두 수용해야 했기 때문이다.

해석, 매칭, 내비게이션의 책임 분리

URL이 들어와서 화면이 뜨기까지는 세 가지 책임이 한 함수에 섞여 있었다.

  • URL을 구조화된 값으로 해석
  • 어느 라우트인지 매칭
  • 실제로 화면을 이동

이 셋을 각각 다른 타입으로 갈랐다.

scene(_:continue:)Universal Linkscene(_:openURLContexts:)URI Schemescene(_:willConnectTo:)ColdStartAppsFlyer SDKdidResolveDeepLink푸시 클릭NotificationHandlerhandleDeeplinkURL(공통 합류점)DeepLinkInterpreterURL → AppRouteRequestroute()즉시 실행 또는 보류직접 호출링크 진입 (SceneDelegate)

DeepLinkInterpreter: 파싱과 매칭

URLComponents로 scheme, host, path, query를 뜯어낸 뒤, 매칭 테이블의 모든 라우트를 훑어 일치하는 Location을 찾는다. 세 URL 방식이 여기서 하나로 수렴한다.

final class DeepLinkInterpreter {
    func interpret(url: URL, source: AppRouteSource) -> AppRouteRequest? {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
              let authority = components.host else { return nil }
 
        let parameters = components.queryItems.orEmpty.reduce(into: [String: String]()) {
            if let value = $1.value { $0[$1.name] = value }
        }
 
        guard let pattern = RoutePattern.allCases.first(where: { pattern in
            pattern.locations.contains {
                $0.matches(scheme: components.scheme, authority: authority, path: components.path)
            }
        }) else { return nil }
 
        guard let route = pattern.makeRoute(parameters: parameters) else { return nil }
        return AppRouteRequest(route: route, source: source)
    }
}

AppRoute: 타입 있는 목적지

매칭 결과는 문자열이 아니라 타입 있는 열거형(AppRoute)으로 표현된다. 이 지점에서 orderId 같은 파라미터가 Int로 강제되므로, 잘못된 URL은 화면 이동 전에 걸러진다.

enum AppRoute: Equatable {
    case orderFormDraft(orderId: Int)
    case checkout(orderId: Int)
    case review(orderId: Int)
    case orderContext(orderId: Int)
    case signUpVerification(code: String)
    case webView(url: URL, title: String)
    case main
    // ...
}

이번 CRM 캠페인처럼 웹 이벤트 페이지로 보내야 하는 경우는 .webView(url:title:)로 떨어진다. 네이티브 화면과 웹뷰 목적지를 같은 타입 안에서 나란히 다룰 수 있게 해 둔 것이 뒤의 웹뷰 브릿지와 매끄럽게 이어지는 기반이 되었다.

DeepLinkHandler: 링크와 푸시의 합류

링크 진입은 AppsFlyer를 거쳐 didResolveDeepLink로 돌아오고, 푸시는 NotificationHandlerhandleDeeplinkURL을 직접 부른다. 출발은 다르지만 둘 다 handleDeeplinkURL로 모여, 해석과 라우팅을 같은 경로로 탄다. AppsFlyer 콜백에서는 추천인 코드 같은 Deferred 값을 먼저 챙긴다.

final class DeepLinkHandler: NSObject, AppsFlyerDeepLinkDelegate {
    private let interpreter = DeepLinkInterpreter()
 
    // 링크 진입 처리: AppsFlyer 콜백
    func didResolveDeepLink(_ result: DeepLinkResult) {
        guard case .found = result.status, let deepLink = result.deepLink else { return }
 
        handleReferralCodeDeeplink(deepLink) // deep_link_value == "referral_code"
 
        guard let link = deepLink.clickEvent["link"] as? String,
              let url = URL(string: link) else { return }
        handleDeeplinkURL(url, source: .oneLink)
    }
 
    // 공통 합류점: 푸시도 이 메서드로 직접 진입
    func handleDeeplinkURL(_ url: URL, source: AppRouteSource) {
        guard let request = interpreter.interpret(url: url, source: source) else { return }
        send(request)
    }
}

진입점이 넷(Universal Link, URI Scheme, ColdStart, 푸시)이어도 파싱과 매칭 로직은 이 한 경로만 존재한다. 새 캠페인이 추가돼도 손댈 곳은 매칭 테이블뿐이라는 재설계의 목표가 여기서 완성된다.

앱 초기화 상태별 처리

라우팅 레이어가 잘 설계돼 있어도, 부르는 시점이 틀리면 아무것도 동작하지 않는다. 세 가지 상태를 모두 대응해야 했다.

Foreground와 Background: 즉시 처리

앱이 실행 중이거나 백그라운드에 있다 올라오는 경우, route()가 호출되는 시점에 이미 rootViewController가 실제 화면이다. 이때는 DeferredAppRouteExecutor가 곧장 화면을 띄운다.

Terminated에서 ColdStart 진입: 스플래시 이후 보류

가장 까다로운 경로이다. 앱이 죽어 있다 링크로 켜지면 scene(_:willConnectTo:options:)connectionOptions의 userActivity와 urlContext를 AppsFlyer로 넘기는데, 이 시점의 rootViewController는 아직 LaunchScreenViewController이다. 여기서 바로 화면을 띄우면 스플래시 위에 얹히거나 초기화가 끝나기 전에 튕긴다.

그래서 route()는 rootViewController가 아직 런치스크린이면 라우트를 보류 저장소에 넣어 두고, 초기화가 끝난 뒤 소비한다.

@MainActor
private func route(_ routeRequest: AppRouteRequest) {
    guard let rootViewController = keyWindow?.rootViewController else { return }
 
    if rootViewController is LaunchScreenViewController {
        PendingAppRouteStore.shared.save(routeRequest) // 스플래시가 끝나면 소비
        return
    }
    DeferredAppRouteExecutor.shared.execute(routeRequest, from: rootViewController)
}

PendingAppRouteStore는 라우트를 한 칸만 들고 있다. save가 이전 값을 덮어쓰고 consume이 꺼내면서 비우는 구조라, 스플래시 도중 링크가 여러 번 들어와도 마지막 의도만 남는다(last-wins). 초기 로딩 중 사용자가 링크를 두 번 누르는 경우에 라우트가 쌓여 엉키는 상황을 이 단순한 규칙으로 막았다.

덕분에 사용자가 결제 독려 푸시를 받아 ColdStart로 진입한 뒤, 스플래시와 초기화를 거쳐 원래 의도한 체크아웃 페이지로 이동하는 흐름이 끊김 없이 이어진다. 중간에 한 번이라도 빈 화면이나 "홈으로" 우회가 끼면 CRM 캠페인 효과가 바로 날아가는 구조였기 때문에, 이 지점은 상태별 시나리오를 여러 번 돌리며 검증했다.

추천인 코드: 온보딩까지 지연되는 Deferred 값

미설치 사용자가 추천 링크로 앱을 설치한 경우는 결이 다르다. 라우트를 바로 실행하는 게 아니라, AppsFlyer가 돌려준 deep_link_sub1(추천인 키)을 온보딩이 끝날 때까지 상태로 들고 있어야 한다.

private func handleReferralCodeDeeplink(_ deepLink: DeepLink) {
    guard deepLink.deeplinkValue?.lowercased() == "referral_code",
          let referral = deepLink.clickEvent["deep_link_sub1"] as? String else { return }
 
    DeferredDeepLinkState.shared.friendReferralKey = referral // 가입 완료 시점에 사용
}

프로모션 코드를 따로 입력하는 화면 없이, 링크로 설치한 것만으로 추천 혜택이 적용되는 경험이 여기서 나온다. 앞서 다룬 확률적 매칭 때문에 이 값이 항상 도착하지는 않으므로, 값이 없을 때 온보딩이 막히지 않게 하는 것이 첫 번째 포인트였다. 확률적 매칭은 반대 방향의 위험도 있다. 다른 사용자에게 코드가 잘못 붙는 오매칭이다. 추천인 값을 클라이언트가 곧장 혜택으로 확정하지 않고 가입 시점에 서버 검증을 거치는 값으로 둔 것은, 이 오차가 클라이언트에서 그대로 굳지 않게 하려는 것이었다.

경로마다 다른 캠페인 메타데이터 출처

CRM 캠페인의 기여도를 측정하려면 campaign_id, group_id 같은 식별자가 퍼널 이벤트에 붙어야 한다. 그런데 이 메타데이터가 어디서 오는지가 진입 경로마다 다르다는 점이 함정이었다. URL 쿼리에서 일괄로 뽑을 수 있으리라 생각했지만, 실제 구조는 그렇지 않았다.

푸시: payload 2단계 디코딩

푸시로 들어온 캠페인 식별자는 링크 URL이 아니라 푸시 payload(userInfo) 안에 있다. NotificationHandler가 payload를 데이터로 만들고, 그 안의 event 문자열을 다시 한 번 디코딩해 캠페인 정보와 목적지 URL을 꺼낸다.

// NotificationHandler: userInfo를 PushNotificationData로 디코딩한 뒤, event 문자열에서 PushEventData를 파싱
let jsonData = try JSONSerialization.data(withJSONObject: userInfo)
let pushData = try JSONDecoder().decode(PushNotificationData.self, from: jsonData)
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)
// 목적지는 같은 라우팅 레이어로 넘긴다
if let url = URL(string: eventInfo.uri) {
    deepLinkHandler?.handleDeeplinkURL(url, source: .push)
}
// EventReporting: 값이 있을 때만 속성으로 부착
func sendPushClickedEvent(campaignId: String?, groupId: String?) {
    var properties: Properties = [:]
    if let campaignId { properties["campaign_id"] = campaignId }
    if let groupId { properties["group_id"] = groupId }
    sendEvent(event: AnalyticsEvent.push_clicked.rawValue, properties: properties)
    Analytics.logEvent(AnalyticsEvent.push_clicked.rawValue, parameters: properties)
}

미설치 상태로 OneLink를 눌러 유입된 사용자는 URL에 식별자가 없다. attribution 정보는 AppsFlyer가 didResolveDeepLinkclickEvent로 돌려준다(앞의 추천인 코드가 그 예이다). 즉 같은 "캠페인 기여"라도 푸시는 payload, OneLink는 SDK 콜백이라는 서로 다른 출처를 하나의 이벤트 스키마로 정규화해야 했다.

이 사실을 초반에 URL 쿼리 파싱으로 뭉뚱그리려다 한 번 헛짚었다. 푸시 payload와 AppsFlyer 콜백은 애초에 URL이 아니어서, "URL에서 campaign_id를 파싱한다"는 접근으로는 절반만 커버됐다. 출처가 갈린다는 걸 인정하고 나서야 이벤트 스키마가 정리됐다.

Mixpanel 속성 통일

출처가 어디든, 최종적으로 붙는 속성 이름은 통일했다. push_clicked, 이후 웹뷰와 네이티브 전환 이벤트까지 같은 campaign_id, group_id퍼널의 모든 단계에 공통 속성으로 따라붙도록 설계했다.

event: push_clicked
properties:
  campaign_id: "2025Q3_signup_retarget"
  group_id: "B"
  source: "push"    // push / onelink 로 구분

이렇게 정규화한 이벤트 스키마가 캠페인별 기여도를 퍼널 단위로 측정하는 기반이 된다.

개선 효과

이 작업의 결과는 두 축으로 나눠 봐야 한다.

  • 라우팅 정확도: 의도한 화면으로 떨어지는가
  • 캠페인 관측: 기여가 끊김 없이 추적되는가

라우팅 정확도. 재설계 전에는 딥링크로 진입한 세션 중 일부가 의도한 화면 대신 홈으로 떨어졌다. URL 형식과 초기화 상태 조합을 한 함수에서 처리하다 놓치는 경우였다. 재설계 후에는 아래 조합을 QA 시나리오로 전수 검증했고, 정의된 모든 라우트가 의도 화면에 도달했다.

  • 진입 경로: Universal Link · URI Scheme · 푸시
  • 초기화 상태: Foreground · Background · ColdStart

이후 새 캠페인이 추가돼도 이 조합이 회귀 없이 유지되는지를 같은 시나리오로 확인한다.

캠페인 관측. push_clicked부터 웹뷰와 네이티브 전환까지 campaign_id가 누락 없이 이어지는지를 Mixpanel 속성 분포로 추적한다. 재설계 전에는 출처(푸시 payload / OneLink 콜백)가 정규화되지 않아 채널별 기여가 섞였는데, 스키마를 통일한 뒤로는 한 캠페인의 유입부터 전환까지를 하나의 퍼널로 볼 수 있게 됐다.

도달률과 전환율의 전후 수치는 Mixpanel 대시보드 기준으로 캠페인마다 따로 집계한다. 캠페인 규모가 제각각이라 하나의 대표값으로 뭉치기보다, 캠페인별 분모(발송 건수)와 함께 보는 편이 해석이 정확하다.

정량 지표 외에 가장 크게 바뀐 건 운영팀이 새 캠페인 링크를 직접 만들어 보낼 수 있게 되었다는 점이었다. 기존에는 경로별로 URL 포맷이 달라서 캠페인마다 iOS에 "이 링크 형식이 맞냐"는 확인 질의가 반복됐는데, sendy.ai 웹 경로와 매칭 테이블로 규약을 통일한 뒤로는 그 질의가 사라졌다. 미설치 attribution이 필요한 캠페인은 AppsFlyer OneLink 콘솔에서 마케팅팀이 직접 링크를 발급한다.


단점과 예상치 못한 변수: 라우팅 레이어

1. AASA 파일 캐시 문제.

Universal Link는 iOS가 AASA 파일을 기기별로 캐시하기 때문에, 도메인의 라우팅 규칙을 바꿔도 기기에 즉시 반영되지 않을 수 있다. 새 경로를 추가한 뒤 기기에서 테스트하면 "왜 안 되지" 상태가 나올 때가 있었고, 이때는 앱 재설치가 필요했다. 운영 중 캠페인에서는 AASA 변경 후 전파 시간을 며칠 두는 것을 팀 내 관행으로 정했다.

2. AppsFlyer 경유의 트레이드오프.

링크 진입을 AppsFlyer로 모으면 attribution은 얻지만, SDK 세션 시작이 ATT 동의 이후로 게이팅되어 있어 그 시작이 늦어지면 Universal Link 소비도 함께 늦어진다. 순수하게 scene(_:continue:)에서 iOS가 준 URL만 바로 처리하는 방식보다 의존이 한 겹 늘어난다. 대신 푸시 경로는 AppsFlyer를 거치지 않고 NotificationHandler가 직접 라우팅하도록 분리해, 캠페인의 핵심 진입인 푸시가 SDK 세션 지연에 묶이지 않게 했다.

3. URI Scheme의 스킴 충돌 위험.

sendyapp://를 다른 앱이 먼저 등록하면 iOS는 어느 앱이 처리할지 보장하지 않는다. 이 때문에 스킴을 외부로 노출하는 것은 최소화하고, 앱 내부와 레거시 호환 용도로만 유지했다. 외부 공유 링크는 소유권이 검증되는 sendy.ai Universal Link로만 내보낸다. 이 스킴을 완전히 걷어내지 않은 이유는 뒤에 나오는 웹뷰 브릿지의 콘텐츠 보안 정책에서 실제로 확인된다.

4. Deferred DeepLink의 확률적 매칭과 지연.

OneLink의 사용자 매칭은 확률적이고, attribution 데이터 도착이 네트워크나 AppsFlyer 서버 응답에 따라 수 초에서 수십 초까지 지연되는 경우가 있었다. 그사이 사용자가 앱 메인을 이미 둘러보기 시작하면 딥링크 소비 타이밍이 애매해진다. 추천인 코드처럼 온보딩까지 지연 소비하는 값은 별도 상태로 들고, 값이 끝내 도착하지 않아도 흐름이 막히지 않도록 fallback을 두었다.

5. 인앱 브라우저 편차.

카카오톡, 인스타그램 등 자체 인앱 브라우저를 쓰는 앱에서 Universal Link가 앱으로 열리기도, 웹으로 열리기도 하는 편차가 있었다. 알림톡은 카카오 안에서 표시되므로 이 편차의 영향을 직접 받는 경로였고, 캠페인 발송 전 카카오와 문자, 소셜 각각에서 링크를 눌러 보는 체크리스트를 돌려 확인했다.

라우팅으로 이벤트 웹뷰까지 도착시켜도, 그 웹뷰의 CTA가 네이티브 화면으로 넘어가지 못하면 캠페인은 웹 페이지에서 끝난다. 웹과 네이티브를 잇는 브릿지가 마지막 문제였다.


문제 해결 3단계: 웹뷰 브릿지와 스택

이벤트 웹뷰의 CTA는 결국 딥링크 라우팅과 같은 목적지 규약을 웹 쪽에서 참조해야 했다. 그 참조를 어떻게 안전하게 잇느냐가 이 단계의 문제였다.

실제 캠페인 예시

센디에서 진행한 CRM 재진입 캠페인은 두 가지 형태의 이벤트 웹뷰를 활용했다. 하나는 랜덤 쿠폰을 지급해 결제를 독려하는 웹뷰, 다른 하나는 친구 초대로 재유입을 유도하는 웹뷰다. 둘 다 CTA를 누르면 각자 다른 네이티브 화면(쿠폰은 체크아웃, 초대는 초대 코드 입력)으로 넘어가야 했고, 이 서로 다른 목적지를 하나의 브릿지 계약으로 처리하는 것이 이번 단계의 과제였다.

랜덤 쿠폰 이벤트 웹뷰친구 초대 이벤트 웹뷰
랜덤 쿠폰 캠페인(왼쪽)과 친구 초대 캠페인(오른쪽) 이벤트 웹뷰

브릿지 계약 명시

하나의 채널로 통합

가장 먼저 한 것은 웹에서 네이티브로 보내는 모든 메시지를 단일 채널로 통일하는 작업이었다.

// 웹뷰 구성 시 메시지 핸들러 등록
let config = WKWebViewConfiguration()
let userController = WKUserContentController()
userController.add(bridge, name: "sendy") // 채널 이름을 "sendy"로 고정
config.userContentController = userController

웹 쪽에서는 아래 한 줄로만 네이티브를 호출한다.

window.webkit.messageHandlers.sendy.postMessage({
  type: "navigate",
  payload: { route: "order/checkout", params: { orderId: 123 } }
});

채널이 하나라는 점이 중요했다. 여러 개의 WKScriptMessageHandler를 두는 구조도 가능하지만, 그러면 "이건 어느 채널로 보내야 하는지"를 프론트와 매번 합의해야 한다. 단일 채널 + type 필드로 분기하는 쪽이 계약의 단순함에서 이겼다.

메시지 타입의 타입 안정성

수신 쪽은 type에 따라 payload를 서로 다른 구조체로 해석한다.

enum BridgeMessage: Decodable {
    case navigate(route: String, params: [String: AnyCodable])
    case closeWebView
    case share(url: URL, title: String?)
    case track(event: String, properties: [String: AnyCodable])
 
    enum CodingKeys: String, CodingKey { case type, payload }
    enum PayloadKeys: String, CodingKey { case route, params, url, title, event, properties }
 
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let type = try container.decode(String.self, forKey: .type)
 
        switch type {
        case "navigate":
            let payload = try container.nestedContainer(keyedBy: PayloadKeys.self, forKey: .payload)
            let route  = try payload.decode(String.self, forKey: .route)
            let params = try payload.decodeIfPresent([String: AnyCodable].self, forKey: .params) ?? [:]
            self = .navigate(route: route, params: params)
        case "closeWebView":
            self = .closeWebView
        case "share":
            let payload = try container.nestedContainer(keyedBy: PayloadKeys.self, forKey: .payload)
            let url    = try payload.decode(URL.self, forKey: .url)
            let title  = try payload.decodeIfPresent(String.self, forKey: .title)
            self = .share(url: url, title: title)
        case "track":
            let payload = try container.nestedContainer(keyedBy: PayloadKeys.self, forKey: .payload)
            let event      = try payload.decode(String.self, forKey: .event)
            let properties = try payload.decodeIfPresent([String: AnyCodable].self, forKey: .properties) ?? [:]
            self = .track(event: event, properties: properties)
        default:
            throw BridgeError.unknownType(type)
        }
    }
}

여기서 type 문자열을 그대로 Navigator에 넘기지 않고 enum으로 강제 변환하는 것이 결정적이었다. 프론트에서 오타나 미정의 타입이 올라왔을 때 네이티브가 조용히 통과시키는 대신 디코딩 실패로 바로 잡히도록 의도한 설계이다. 이게 "무반응" 상태를 만들지 않는 첫 안전망 역할을 했다.


웹 CTA에서 네이티브 Route 전환

CTA 버튼이 네이티브 전환을 일으킨다는 건, 결국 딥링크 라우팅과 동일한 경로를 웹 쪽에서도 참조한다는 뜻이다. 그래서 웹뷰 브릿지의 navigate 타입은 그대로 라우팅 레이어로 위임하도록 설계했다.

final class WebViewBridge: NSObject, WKScriptMessageHandler {
    func userContentController(
        _ userContentController: WKUserContentController,
        didReceive message: WKScriptMessage
    ) {
        // 1. 메시지 이름 검증
        guard message.name == "sendy" else { return }
 
        // 2. payload 디코딩
        let decoder = JSONDecoder()
        guard let data = try? JSONSerialization.data(withJSONObject: message.body),
              let parsed = try? decoder.decode(BridgeMessage.self, from: data)
        else {
            trackBridgeError(raw: message.body, reason: .decodeFailed)
            return
        }
 
        // 3. 타입별 분기
        switch parsed {
        case .navigate(let route, let params):
            routeToNative(route: route, params: params)
        case .closeWebView:
            presentingViewController?.dismiss(animated: true)
        case .share(let url, let title):
            shareHelper.present(url: url, title: title)
        case .track(let event, let properties):
            forwardToAnalytics(event: event, properties: properties)
        }
    }
 
    private func routeToNative(route: String, params: [String: AnyCodable]) {
        // DeeplinkNavigator에 위임. sendy.ai는 매칭 테이블에도 등록된 도메인이라 그대로 재사용한다
        let url = URL(string: "https://sendy.ai/\(route)")
        guard let url else { return }
 
        // 웹뷰에서 진입했음을 source에 명시
        DeepLinkRouter.shared.route(url, from: .webview, extraParams: params)
    }
}

이 위임 구조가 가지는 이점은 두 가지였다.

  • 라우팅 레이어가 이미 인증, ColdStart 큐, 매칭 테이블을 처리하고 있으므로, 브릿지가 직접 네비게이션 스택을 건드릴 일이 없다.
  • 웹뷰에서 온 요청인지 푸시에서 온 요청인지만 source로 구분되고, 이후 퍼널 이벤트의 속성으로 자연스럽게 이어진다. (아래 측정 절과 이어진다)

아래는 QA 과정에서 실제로 확인한 화면이다. CTA 전환이 의도한 네이티브 화면(비용 계산기)에 정확히 도달하는지, 그리고 그 화면의 버튼이 다음에 어디로 연결되는지를 함께 표시해 검증했다.

CTA 전환 후 네이티브 화면과 다음 이동 지점을 표시한 QA 캡처

같은 브릿지로 처리하는 Mixpanel track

이벤트 웹뷰 내부에서 사용자 액션(예: 스크롤 깊이, 특정 영역 노출)을 추적할 때도 같은 브릿지 채널을 통해 네이티브로 내려보내도록 했다. 웹 쪽에서 자체 Mixpanel SDK를 심는 것도 가능하지만, 같은 사용자가 웹과 네이티브 양쪽에서 서로 다른 distinct_id로 잡히는 위험을 피하기 위함이었다.

window.webkit.messageHandlers.sendy.postMessage({
  type: "track",
  payload: {
    event: "event_webview_cta_clicked",
    properties: {
      cta_label: "센디 시작하기",
      scroll_depth: 0.85,
      campaign_id: "2025Q3_signup_retarget"
    }
  }
});

네이티브 forwardToAnalytics가 이를 받아 기존 EventReporter로 그대로 흘려보낸다. 웹과 네이티브 이벤트가 동일 Mixpanel 인스턴스, 동일 distinct_id에 쌓이게 되어 퍼널이 끊기지 않는다.


네이티브에서 웹 방향 통신

웹에서 네이티브로 가는 방향만으로는 해결 안 되는 시나리오가 있었다. 예를 들어 로그인 상태가 바뀐 순간 웹뷰 쪽 UI를 갱신해야 하는 경우이다. 비로그인 상태에서 웹뷰를 보다가 CTA를 눌러 로그인한 뒤 다시 돌아왔을 때, 웹뷰는 여전히 비로그인 상태의 화면을 그리고 있을 수 있었다.

이 방향의 통신은 evaluateJavaScript로 처리했다.

extension WKWebView {
    func dispatchNativeEvent(_ name: String, payload: [String: Any] = [:]) {
        let payloadData = (try? JSONSerialization.data(withJSONObject: payload)) ?? Data()
        let payloadString = String(data: payloadData, encoding: .utf8) ?? "{}"
 
        let script = """
        window.dispatchEvent(
            new CustomEvent('sendy:\(name)', { detail: \(payloadString) })
        );
        """
        evaluateJavaScript(script) { _, _ in }
    }
}

웹 쪽에서는 이 이벤트를 구독하면 된다.

window.addEventListener("sendy:authChanged", (e) => {
  const { isAuthenticated, userId } = e.detail;
  // ... UI 갱신
});

설계 원칙 하나를 이 시점에 정했다. 네이티브에서 웹으로 가는 방향은 "이벤트 브로드캐스트" 모델로만 쓴다. JavaScript 함수를 직접 호출하는 방식은 피했다.

  • 웹 쪽이 어떤 구현을 쓰는지(React / Vue / 순수 JS)에 네이티브가 결합되면 안 된다
  • 함수가 아직 정의되지 않은 타이밍에 호출하면 조용히 실패한다. 이벤트 모델은 리스너가 없으면 그냥 버려지는 의미가 더 분명하다

에러 경계의 위치

무반응 상태를 막는 것이 이번 설계의 주요 목표였기 때문에, 에러 경계를 세 지점에 명시적으로 두었다.

1. JS 실행 실패

네이티브에서 웹으로 가는 호출에서 evaluateJavaScript가 실패하는 경우(웹이 아직 로드 중이거나, 스크립트 오류가 있는 경우)가 있었다. 이 경우 네이티브 쪽에서 해당 이벤트를 내부 큐에 쌓아 두었다가, 다음 didFinish 내비게이션 완료 시점에 다시 flush하도록 했다.

2. Payload 디코딩 실패

BridgeMessage enum의 default 케이스에서 BridgeError.unknownType을 throw하도록 두었다. 이 에러는 Mixpanel의 운영 이벤트로만 집계하고 사용자에게는 노출하지 않았다. 대신 debug 빌드에서는 alert으로 즉시 드러나도록 해서, 개발 중에 놓치지 않도록 했다.

private func trackBridgeError(raw: Any, reason: BridgeErrorReason) {
    EventReporter.shared.sendBridgeError(reason: reason.rawValue, raw: String(describing: raw))
 
    #if DEBUG
    DispatchQueue.main.async {
        UIAlertController.debugAlert(title: "Bridge Error", message: "\(reason)")
    }
    #endif
}

3. 네이티브 라우팅 실패

DeeplinkNavigator가 매칭되지 않는 route를 받으면 홈으로 fallback하지 않고, 이벤트 웹뷰에 머물도록 결정했다. 사용자가 의도와 다른 화면으로 튕겨 나가는 것보다 그대로 웹뷰에 있는 편이 덜 혼란스럽다고 판단했기 때문이다. 실패 사실은 네이티브에서 웹으로 가는 이벤트로 알려 주어 웹 쪽이 "잠시 후 다시 시도해 주세요" 토스트를 띄울 수 있도록 했다.


WKWebView 설정 주의점

브릿지 구조와 별개로, WKWebView 자체의 설정 몇 가지도 이번에 정리했다.

인증이 필요한 API를 웹 측에서 호출해야 하는 경우가 있었다. 이때 네이티브 URLSession이 가진 세션 쿠키를 웹뷰에도 주입해야 했다.

let cookieStore = webView.configuration.websiteDataStore.httpCookieStore
let authCookie = HTTPCookie(properties: [
    .name: "auth_token",
    .value: AuthService.shared.accessToken ?? "",
    .domain: "sendy.ai",
    .path: "/",
    .secure: "TRUE"
])
if let cookie = authCookie {
    cookieStore.setCookie(cookie)
}

한 가지 덧붙이면, WKAppBoundDomains(App-Bound Domains)는 쿠키 주입의 필수 조건이 아니라 옵트인 보안 제약이다. limitsNavigationsToAppBoundDomains를 켜서 옵트인하면 그때부터 메시지 핸들러, evaluateJavaScript, 데이터스토어 조작이 선언한 도메인으로만 제한된다. 브릿지가 특정 도메인에서만 동작하도록 잠그고 싶을 때 쓰는 장치이다.

customUserAgent

서버 측에서 요청을 보고 "앱 내 웹뷰에서 온 것"을 구분할 수 있도록 UA에 식별자를 추가했다.

webView.customUserAgent = "Sendy/iOS (in-app-webview; version=1.2.3)"

이게 있어야 서버가 분석 집계에서 앱 내 웹뷰 트래픽과 일반 브라우저 트래픽을 구분할 수 있다.

콘텐츠 보안 정책

이벤트 웹뷰는 사내 CMS에서 만든 콘텐츠만 로드하도록 호스트 화이트리스트를 뒀다.

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
    guard let url = navigationAction.request.url else {
        decisionHandler(.cancel); return
    }
    if allowedHosts.contains(url.host ?? "") {
        decisionHandler(.allow)
    } else {
        // 외부 도메인은 시스템 브라우저로
        UIApplication.shared.open(url)
        decisionHandler(.cancel)
    }
}

화이트리스트를 두지 않으면 CMS에 삽입된 제3자 iframe이나 이미지 링크가 웹뷰 안에서 열려, 사용자가 앱에서 빠져나간 느낌을 받을 수 있었다.


2-depth 웹뷰 스택 관리

이번 캠페인에서 브릿지 설계 위에 한 층 더 복잡한 요구사항이 붙었다. 이벤트 웹뷰 안에서 "자세히 보기"를 누르면 또 다른 웹뷰로 넘어가고, 그 웹뷰 안의 CTA를 누르면 네이티브 화면으로 전환되는 플로우였다. 즉 이벤트 웹뷰 A에서 상세 웹뷰 B를 거쳐 네이티브 C로 넘어가는 것처럼 2-depth 이상의 스택이 필요했다.

WKWebView 자체 히스토리 미사용

첫 번째 결정은 웹뷰 내부 히스토리에 의존하지 않는 것이었다. WKWebView의 goBack()을 허용하면 뒤로 가기 동작이 상황에 따라 "웹뷰 안에서 한 페이지 뒤로"인지 "웹뷰 자체를 닫고 이전 화면으로"인지 모호해진다. 각 웹뷰를 독립적인 네이티브 ViewController로 띄우고, 뒤로 가기는 항상 네이티브 네비게이션 스택의 popViewController(animated:)로만 처리되게 두었다. 웹 쪽이 "다른 웹 페이지로 이동하고 싶다"고 요청하면 새 ViewController를 push하는 식으로 응답한다.

extension BridgeMessage {
    case openWebView(url: URL, title: String?)
}
 
private func handleOpenWebView(url: URL, title: String?) {
    let next = EventWebViewController(url: url, title: title)
    next.campaignMetadata = self.campaignMetadata // 메타데이터 전달
    navigationController?.pushViewController(next, animated: true)
}

이 구조에서는 스택을 사용자의 시선과 일치시키는 것이 핵심이었다. 화면 위에서 본 순서 그대로 네이티브 네비게이션 스택이 쌓이고, 뒤로 가기는 그 순서를 역순으로 따라간다.

Root VC이벤트 웹뷰 A상세 웹뷰 B네이티브 Cpushbridge: openWebViewbridge: navigate

웹뷰 B에서 네이티브 C 진입 시 B는 스택에서 제거

가장 많이 받은 질문이 "네이티브 C에서 뒤로 가면 어디로 가야 하나"였다. 나는 네이티브 C로 진입하는 순간 웹뷰 B를 스택에서 제거하는 쪽을 택했다. 사용자가 네이티브 C에서 뒤로 가기를 눌렀을 때 방금 지나온 광고 성격의 웹뷰보다는 이벤트 페이지(웹뷰 A)로 돌아가는 편이 기대와 가깝다는 판단이었다.

private func handleNavigateToNative(route: String, params: [String: AnyCodable]) {
    guard let navController = navigationController else { return }
    var stack = navController.viewControllers
    if let idx = stack.firstIndex(of: self) { stack.remove(at: idx) } // 자기 자신 제거
    let nativeVC = NativeRouteFactory.make(route: route, params: params)
    stack.append(nativeVC)
    navController.setViewControllers(stack, animated: true)
}

이 결정은 플로우 테스트를 돌리면서 "무엇이 자연스러운가"를 사용자 시선으로 확인한 뒤 최종 반영했다.

웹뷰 간 메타데이터 pass-through

캠페인 메타데이터(campaign_id, group_id, variant)는 첫 번째 웹뷰에서 두 번째 웹뷰로 넘어갈 때 네이티브 측에서 직접 전달하도록 했다. 웹 쪽 쿼리 파라미터에 다시 붙이는 방식도 가능했지만, URL 인코딩과 길이 제한이 있었고 무엇보다 웹이 모르는 내부 식별자까지 같이 흘려보낼 수 있어야 했기 때문이다.

final class EventWebViewController: UIViewController {
    var campaignMetadata: CampaignMetadata?
 
    override func viewDidLoad() {
        super.viewDidLoad()
        webView.dispatchNativeEvent("initCampaignContext", payload: [
            "campaign_id": campaignMetadata?.campaignId ?? "",
            "group_id": campaignMetadata?.groupId ?? "",
            "variant": campaignMetadata?.variant ?? ""
        ])
    }
}

웹 측은 sendy:initCampaignContext 이벤트를 받아 이후 모든 트래킹 호출에 동일 값을 붙여 올려 보낸다. 이 한 줄 덕분에 캠페인 메타데이터가 웹뷰 depth와 무관하게 Mixpanel 퍼널 끝까지 이어지게 되었다.

개선 효과

이 단계의 목표는 무반응 상태를 없애는 것이었다. 개편 전에는 이벤트 웹뷰의 CTA를 눌러도 네이티브로 넘어가지 않고 멈추는(white-screen) 사례가 CS로 올라왔다. 단일 채널과 enum 강제 디코딩, 라우팅 위임으로 무반응 경로를 좁힌 뒤로는 CTA가 의도한 네이티브 화면으로 이어졌다. 아래는 그 측정 기준과 개편 전후 값이다.

항목측정 방식
웹뷰 CTA 클릭 → 네이티브 진입 성공률event_webview_cta_clicked 대비 의도 화면 진입전 72% / 후 96% 안팎
브릿지 payload 디코딩 실패율전체 브릿지 호출 대비 bridge_error (decodeFailed) 비율0.4% 안팎
무반응 상태(white-screen) 리포트CS 티켓 중 "웹뷰 버튼이 안 눌려요" 계열 건수전 월 30건 안팎 / 후 월 3건 미만
웹·네이티브 퍼널 일관성campaign_id가 웹·네이티브 이벤트에 일관되게 이어지는 비율97% 안팎

측정 참고

  • CTA 진입 성공률: Mixpanel 2단계 Funnel 기준이다.
  • 디코딩 실패율: bridge_error 이벤트 수를 전체 브릿지 호출 수로 나눈 값이며, 구조적 버그 여부를 모니터링하는 용도로 본다.
  • 무반응 리포트: Zendesk CS 대시보드에서 "웹뷰" 태그로 필터링한 값이다.
  • 퍼널 일관성: Mixpanel에서 campaign_id 속성의 null 비율을 측정한 값이다.

단점과 예상치 못한 변수: 웹뷰 브릿지와 스택

1. 화이트리스트 밖 링크 처리에서 Universal Link가 예상과 다르게 동작했다.

앞서 콘텐츠 보안 정책에서 다룬 "화이트리스트에 없는 도메인은 시스템 브라우저로" 처리 경로에서, 운영 중 예상치 못한 동작이 실제로 하나 나왔다. 화이트리스트에 없는 sendy.ai Universal Link를 UIApplication.shared.open(url)로 넘길 때, 앱이 이미 실행 중인 상태라면 같은 앱으로 돌아오지 않고 Safari가 새로 열리는 경우가 있었다. CMS 콘텐츠 쪽을 담당하는 동료가 이 현상을 먼저 발견해 공유해 주었다.

웹뷰 open 액션에서 Universal Link가 예상과 다르게 동작한 사례를 공유받은 대화

decidePolicyFor navigationAction의 로직 자체를 바꾸는 대신, 이 경로로 흘려보내는 링크의 형식만 바꾸는 쪽을 택했다. sendy.ai Universal Link 대신 sendyapp:// 커스텀 스킴으로 링크를 생성하도록 콘텐츠 발급 쪽과 합의했다. 커스텀 스킴은 UIApplication.shared.open()이 브라우저를 거치지 않고 곧장 앱으로 넘기기 때문에, 앱이 실행 중이든 아니든 결과가 갈리지 않았다. 클라이언트 로직은 그대로 두고 링크를 만드는 쪽의 형식만 바꿔 해결한 셈이며, 앞서 라우팅 레이어의 단점에서 스킴 충돌 위험 때문에 최소화하기로 했던 그 스킴이 이 지점에서 실제로 쓸모를 증명했다.

2. 기존 웹뷰 화면과의 이중 구조 기간.

이벤트 웹뷰는 새 표준 브릿지를 쓰고, 기존 웹뷰 화면(공지, 약관 등)은 여전히 ad-hoc 방식을 쓴다. 점진 이관 전까지는 두 패턴이 공존해서 새 개발자 온보딩 시 혼란 요소가 된다. 팀 내부 문서로 "새 웹뷰는 표준 브릿지 기준" 원칙만 고정해 두었고, 이관은 이후 스프린트에서 순차 진행 중이다.

3. evaluateJavaScript의 실행 시점 보장 문제.

네이티브에서 웹으로 이벤트를 보낼 때 웹 문서가 아직 준비되지 않았다면 리스너가 없는 시점에 이벤트가 버려진다. didFinish 이후에만 호출하도록 내부 큐를 두었지만, 단일 페이지 앱(SPA)의 라우트 전환 중에는 여전히 누락 가능성이 있다. 이 부분은 중요 이벤트에 한해 3회 재시도 + 실패 이벤트 기록으로 완화했다.

4. App-Bound Domain 정책의 전파 시차.

WKAppBoundDomains를 바꾸면 앱 재설치 전까지 정책이 완전히 반영되지 않는 경우가 있었다. 새 도메인이 추가될 때마다 이 변경도 릴리스 사이클에 같이 들어가야 해서, CMS 쪽 호스트 추가는 앱 릴리스 주기에 맞춰 계획해야 한다는 운영 제약이 생겼다.

5. 디버깅 환경의 한계.

WKWebView 안의 JS는 Safari Web Inspector에서만 디버깅할 수 있는데, 실기기 + Safari Inspector 연결이 간헐적으로 끊기는 문제가 있었다. 이런 상황에서 브릿지 호출이 실패하면 원인을 좁히기 어려웠다. 사내 디버깅 도구 쪽에 브릿지 호출 로그를 별도로 남기도록 보완하고 있다.


이후 방향성

푸시 수신과 ColdStart 복원, 세 진입 경로를 모은 라우팅, 웹뷰 CTA 브릿지와 2-depth 스택까지를 하나의 구조로 이었다. 이 구조를 만든 이유는 처음으로 돌아가면 하나다. 오더를 저장하고도 당일 결제하지 않은 사용자를, 복귀율이 꺼지기 전 골든타임 안에 다시 부르는 것. 세 단계에서 각각 측정한 진입 성공률, 라우팅 정확도, 무반응 해소율은 결국 이 골든타임 안에서 사용자가 중간에 이탈하지 않고 결제 화면까지 도달하는 비율을 층별로 뜯어본 값이다. 캠페인별 최종 복귀 결제 전환율은 Mixpanel 퍼널에서 캠페인마다 따로 집계하며, 이 구조 위에서 새 캠페인은 네이티브 배포 없이 매칭 테이블과 웹 계약을 추가하는 쪽으로 운영 비용이 줄었다.

남은 과제는 위 다섯 가지이며, 그중 이중 구조 이관과 App-Bound Domain 릴리스 동기화는 다음 분기 스프린트에서 순차적으로 정리할 계획이다.