CRM 캠페인에서 시작한 앱 진입 시스템 구축기 (2): 여러 경로를 모으는 딥링크 라우팅

시리즈
CRM 캠페인에서 시작한 앱 진입 시스템 구축기
안녕하세요. 센디 플랫폼 스쿼드에서 iOS 앱을 개발하는 박정환(Lody)입니다.
이 글은 이 시리즈의 2편입니다. 1편. 푸시 알림 수신과 클릭과 3편. 웹뷰와 네이티브를 잇는 브릿지도 함께 봅니다.
1편에서는 기기 화면 녹화로 이미지가 포함된 푸시와 탭 이후 화면을 확인했습니다. 녹화에서 앱 종료 상태는 확인되지 않습니다. 이 글에서는 푸시, Universal Link, OneLink 진입 경로를 같은 목적지로 연결하는 라우팅 코드를 다룹니다.
문제 해결 2단계: 딥링크 라우팅
세 진입 경로(푸시, 알림톡, 외부 마케팅 URL)가 같은 이벤트 웹뷰와 네이티브 화면으로 떨어져야 했습니다. URI Scheme / Universal Link / Deferred의 개념은 딥링크와 앱 라우팅을 봅니다. 여기서는 센디 라우팅 레이어만 적습니다.

세 방식의 매칭 테이블 통합
이번 작업에서 가장 먼저 결정해야 했던 건 세 가지 URL 방식을 어떻게 한 곳에서 다룰 것인가였습니다.
| 항목 | URI Scheme | Universal Link | OneLink(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을 구조화된 값으로 해석
- 어느 라우트인지 매칭
- 실제로 화면을 이동
이 셋을 각각 다른 타입으로 갈랐습니다.
링크 진입 방식과 앱 시작 상태를 별도 축으로 그리면 다음과 같습니다. 링크나 푸시 클릭은 공통 라우팅으로 합류하고, Foreground·Background·ColdStart 상태는 목적지를 실행하거나 보류하는 시점에 영향을 줍니다.
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:)로 떨어집니다. 네이티브 화면과 웹뷰 목적지를 같은 타입 안에서 나란히 다룰 수 있게 해 둔 것이 3편의 웹뷰 브릿지와 매끄럽게 이어지는 기반이 되었습니다.
DeepLinkHandler: 링크와 푸시의 합류
링크 진입은 AppsFlyer를 거쳐 didResolveDeepLink로 돌아오고, 푸시는 NotificationHandler가 handleDeeplinkURL을 직접 부릅니다. 출발은 다르지만 둘 다 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, OneLink, 푸시 등 여러 방식으로 들어와도 파싱과 매칭 로직은 한 경로로 모입니다. Foreground, Background, 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). 초기 로딩 중 사용자가 링크를 두 번 누르는 경우에 라우트가 쌓여 엉키는 상황을 이 단순한 규칙으로 막았습니다.
코드에서는 런치스크린이 root 화면일 때 라우트를 보류하고, 화면 전환이 가능한 시점에 실행하는 흐름을 확인할 수 있습니다. 1편의 녹화만으로 앱 종료 상태의 진입 결과를 검증할 수는 없어, 구현 경로와 실제 기기에서 확인한 동작을 구분합니다.
추천인 코드: 온보딩까지 지연되는 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: AppsFlyer 콜백
미설치 상태로 OneLink를 눌러 유입된 사용자는 URL에 식별자가 없습니다. attribution 정보는 AppsFlyer가 didResolveDeepLink의 clickEvent로 돌려줍니다(앞의 추천인 코드가 그 예입니다). 즉 같은 "캠페인 기여"라도 푸시는 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 로 구분
이렇게 정규화한 이벤트 스키마가 캠페인별 기여도를 퍼널 단위로 측정하는 기반이 됩니다.
개선 효과
이 문서에서 확인한 결과는 코드의 라우트 매핑과 초기화 상태별 분기입니다. 캠페인 전환율, 라우트별 QA 통과율, 문의 감소량을 재현할 대시보드나 기록은 확인하지 못해 효과 수치로 제시하지 않습니다. 라우팅 구조와 payload 매핑은 코드에서 확인되는 변경으로 설명합니다.
단점과 예상치 못한 변수: 라우팅 레이어
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로만 내보냅니다. 이 스킴을 완전히 걷어내지 않은 이유는 3편의 웹뷰 브릿지 콘텐츠 보안 정책에서 실제로 확인됩니다.
4. Deferred DeepLink의 확률적 매칭과 지연.
OneLink의 사용자 매칭은 확률적이고, attribution 데이터 도착이 네트워크나 AppsFlyer 서버 응답에 따라 수 초에서 수십 초까지 지연되는 경우가 있었습니다. 그사이 사용자가 앱 메인을 이미 둘러보기 시작하면 딥링크 소비 타이밍이 애매해집니다. 추천인 코드처럼 온보딩까지 지연 소비하는 값은 별도 상태로 들고, 값이 끝내 도착하지 않아도 흐름이 막히지 않도록 fallback을 두었습니다.
5. 인앱 브라우저 편차.
카카오톡, 인스타그램 등 자체 인앱 브라우저를 쓰는 앱에서 Universal Link가 앱으로 열리기도, 웹으로 열리기도 하는 편차가 있었습니다. 알림톡은 카카오 안에서 표시되므로 이 편차의 영향을 직접 받는 경로였고, 캠페인 발송 전 카카오와 문자, 소셜 각각에서 링크를 눌러 보는 체크리스트를 돌려 확인했습니다.
라우팅으로 이벤트 웹뷰까지 도착시켜도 CTA가 네이티브 화면으로 넘어가려면 웹과 앱 사이의 브릿지가 필요합니다. 이 연결은 3편. 웹뷰와 네이티브를 잇는 브릿지에서 다룹니다.