LODY/기록

알림톡·푸시 딥링크 라우팅

박정환박정환

시리즈

센디 CRM 재진입 채널을 확장 가능한 푸시·딥링크·웹뷰 브릿지로 설계하기

들어가며

안녕하세요, 센디 iOS엔지니어 로디입니다.

Part 1에서 푸시가 도달하고 클릭되어 앱으로 진입하는 층까지 정리했습니다. Part 2는 그 이후 흐름입니다. 도달한 payload를 실제 화면 전환으로 이어 주는 라우팅에 대한 이야기입니다.

이 단계에서 한 가지 특수한 상황이 있었습니다. 이번 CRM 캠페인은 푸시만 사용하는 게 아니라 알림톡, 그리고 마케팅팀이 직접 관리하는 외부 마케팅 URL(SMS·이메일·소셜)까지 세 가지 진입 경로에서 같은 이벤트 웹뷰·네이티브 화면으로 떨어져야 했습니다. 서로 다른 세 경로가 같은 목적지에 도달하게 하되, 각 경로의 특성을 흘리지 말아야 한다는 조건이 붙어 있었습니다.


진행 배경

문제 정의

세 진입 경로를 하나의 라우팅 레이어로 모으려고 보니, 다음 복잡성이 드러났습니다.

  1. 진입 URL 형식이 경로마다 다릅니다 — 알림톡은 앱 스킴 기반, 푸시는 payload 내 URL 문자열, 외부 마케팅 URL은 https:// 기반. 같은 목적지여도 URL 구조가 달라집니다.
  2. 앱 초기화 상태가 진입 시점마다 다릅니다 — 이미 실행 중이면 즉시 라우팅이 되어야 하고, ColdStart이면 지연 처리 큐를 거쳐야 합니다(Part 1 참조).
  3. 기존 딥링크 처리가 AppDelegate에 분산되어 있었습니다 — 새 경로를 추가할 때마다 분기가 두세 곳에 중복으로 생겨나고 있었고, CRM 캠페인 메타데이터 전달 구조가 아예 부재한 상황이었습니다.

세 경로가 앞으로 늘어나는 캠페인마다 반복될 것을 감안하면, "기존 코드에 분기 하나만 더 붙이는" 방식으로는 장기적으로 감당이 안 된다는 판단이 들었습니다. 이번 기회에 라우팅 레이어를 확장 가능한 공통 구조로 재설계해 두는 것이 이후 캠페인 비용을 크게 줄이는 길이라고 보고 방향을 잡았습니다.

개선 방안 도출

대안평가
기존 AppDelegate 중심 처리에 분기 추가단기에는 가장 빠르지만, 새 경로가 추가될 때마다 비용이 누적됨
외부 라우팅 라이브러리 도입도입 검증 시간이 스프린트 안에 부족. Sendy의 독자 스킴·CRM 메타데이터 처리와 약간의 충돌 우려
해석 · 매칭 · 내비게이션 3층으로 라우팅 레이어 재설계 (채택)스프린트 안에 충분히 가능. 책임 분리가 명확해서 새 경로 추가 비용이 일정

재설계한 라우팅 레이어는 이번 CRM 캠페인 외에도 운송 상세·임시저장·결제 같은 다른 경로에도 두루 쓰이는 공통 레이어가 되었습니다. Part 2에서는 이번 CRM 캠페인 시나리오에 집중해서, 이 레이어가 어떤 동작을 하도록 설계했는지만 다룹니다.


배경 지식

본문에 자주 등장하는 용어 다섯 가지를 짧게 정리합니다.

  • Custom URL Scheme: sendy://order/123 처럼 앱이 자기만의 스킴을 등록해 외부 URL을 받는 방식. iOS 초창기부터 있는 구조로 구현이 간단하지만, 같은 스킴을 다른 앱이 등록해도 막을 수 없다는 약점이 있습니다.
  • Universal Link: https://sendy.co.kr/order/123 처럼 실제 HTTPS URL을 앱이 가로채는 방식. iOS 9 이후 기본 권장 방식이며, 앱이 설치되어 있지 않으면 자동으로 웹으로 떨어집니다.
  • AASA 파일: apple-app-site-association. Universal Link를 쓰려면 도메인 루트(/.well-known/apple-app-site-association)에 JSON 형식으로 "이 도메인의 어느 경로가 어느 앱과 연결되는가"를 명시해야 합니다. iOS가 AASA를 캐시해서 링크 가로채기 여부를 결정합니다.
  • Deferred Deeplink: 앱이 설치되어 있지 않은 상태에서 링크를 클릭했을 때, 앱스토어를 거쳐 설치된 뒤 첫 실행에서 원래 목적지로 복원시키는 기법. AppsFlyer OneLink 같은 SDK가 attribution 데이터에 원 URL을 붙여 주는 방식으로 구현됩니다.
  • CRM 캠페인 메타데이터: 서버가 발송하는 URL에 함께 실리는 식별자 묶음. campaign_id, group_id, variant 같은 쿼리 파라미터 또는 payload 필드로 내려옵니다. 수신·클릭·전환 이벤트를 Mixpanel 퍼널에 이어 붙이는 핵심 식별자입니다.

두 방식의 실질적 차이

이번 작업에서 가장 먼저 결정해야 했던 건 기본 진입 URL을 어느 방식으로 둘 것인가였습니다.

항목Custom URL SchemeUniversal Link
URL 형식sendy://...https://sendy.co.kr/...
앱 미설치 시 동작빈 페이지 / 에러웹 페이지로 자연스럽게 열림
스킴 충돌다른 앱이 같은 스킴 등록 가능도메인 소유권 검증 있음
외부 링크 공유카카오톡 등에서 프리뷰가 깨짐HTTPS라 프리뷰 정상
검색엔진·SEO영향 없음일반 웹 URL처럼 동작
설정 비용낮음 (Info.plist에 스킴 등록)중간 (AASA 파일 배포 + Associated Domains 설정)

센디의 상황에서 가장 중요했던 조건이 세 가지였습니다.

  • 알림톡 메시지는 카카오톡 안에서 표시되며, 링크를 누르는 순간 앱이 설치되어 있으면 그대로 앱이 열려야 하고 설치되어 있지 않으면 웹 페이지로 안내되어야 합니다.
  • 외부 마케팅 URL은 SMS·이메일·소셜 등에 공유되는 일반 링크이기 때문에 반드시 HTTPS여야 합니다.
  • 푸시 payload는 네이티브가 직접 파싱하므로 URL 형식에 대한 제약이 가장 적습니다.

이 조건을 따라가 보면 결론은 Universal Link 기반이 자연스럽다는 쪽이었습니다. 앱 미설치 시 자동으로 웹으로 떨어지는 점이 특히 결정적이었고, 알림톡·외부 마케팅 URL·푸시 모두를 같은 URL 구조로 통일할 수 있다는 점에서 운영팀과의 합의도 수월했습니다.

다만 완전히 치우치지 않은 이유

그럼에도 Custom URL Scheme도 보조로 남겨 두기로 했습니다. 두 가지 경우 때문이었습니다.

  • 앱 내부 네이티브 전환 같은 경우 — 앱 안에서 이미 실행 중인 상태에서 화면 이동을 URL로 표현하고 싶을 때는 https://보다 짧은 커스텀 스킴이 디버깅과 로깅에 편합니다.
  • 레거시 호환 — 기존에 발송된 알림톡·푸시 중 일부가 이미 sendy:// 스킴을 쓰고 있어서, 한동안 양쪽을 모두 수용해야 했습니다.

그래서 이번 설계의 결정은 "Universal Link를 기본 진입 경로로 두고, Custom URL Scheme은 앱 내부·레거시 호환용으로 유지"하는 이중 구조였습니다.


문제 해결 2단계: 해석·매칭·내비게이션 3층으로 분리

기존 라우팅은 AppDelegate에서 URL을 받아 바로 switch로 분기하고 있었습니다. 새 경로가 추가될 때마다 이 switch가 커지고, URL 파싱·화면 이동·인증 검사가 한 함수 안에 섞이는 구조였습니다. 이번 기회에 다음 세 가지 책임을 분리했습니다.

DeepLinkHandler        URL을 타입 있는 구조체로 해석만 함 (라우팅 책임 없음)
DeeplinkConfigurations 매칭 패턴을 선언적으로 정의
DeeplinkNavigator      해석된 타입을 받아 실제 화면 이동만 담당

DeeplinkConfigurations: 매칭 패턴을 한 곳에

새 경로를 추가할 때 이 파일 하나만 건드리면 되게 하는 것이 목표였습니다.

enum DeeplinkConfiguration: String, CaseIterable {
    case orderDetail   // https://sendy.co.kr/order/:id
    case checkout      // https://sendy.co.kr/checkout/:id
    case eventCampaign // https://sendy.co.kr/event/:slug
    case reviewWrite   // https://sendy.co.kr/review/:orderId
    // ... 이번 CRM 시나리오에서는 eventCampaign이 주로 사용
 
    var pattern: String {
        switch self {
        case .orderDetail:   return "/order/:id"
        case .checkout:      return "/checkout/:id"
        case .eventCampaign: return "/event/:slug"
        case .reviewWrite:   return "/review/:orderId"
        }
    }
 
    var requiresAuth: Bool {
        switch self {
        case .orderDetail, .checkout, .reviewWrite: return true
        case .eventCampaign: return false
        }
    }
}

각 경로에 인증 필요 여부를 같이 붙여 둔 것이 이번 작업의 포인트였습니다. 비로그인 상태로 ColdStart 진입한 사용자에게 checkout 같은 경로를 바로 떨어뜨리면 화이트 스크린이 되므로, 경로 정의 시점에 이 속성을 분리해 두는 편이 안전했습니다.

DeepLinkHandler: 파싱과 매칭

URL을 받아서 어느 경로에 매칭되는지 찾고, 쿼리 파라미터·경로 파라미터·CRM 메타데이터를 구조체로 모아 반환합니다.

struct ParsedDeeplink {
    let configuration: DeeplinkConfiguration
    let pathParameters: [String: String]
    let campaignMetadata: CampaignMetadata?
}
 
struct CampaignMetadata {
    let campaignId: String?
    let groupId: String?
    let variant: String?
    let source: Source // .push / .kakaotalk / .externalURL / .appInternal
}

Source를 별도로 관리하는 이유는 같은 URL이라도 어디서 들어왔는지가 Mixpanel 퍼널에서 중요하기 때문이었습니다. 푸시와 외부 마케팅 URL을 같은 이벤트로 집계해 버리면 채널별 기여도가 섞입니다.

DeeplinkNavigator: 내비게이션 스택 조작만

Handler가 돌려준 ParsedDeeplink를 받아 실제 화면 이동만 담당합니다.

final class DeeplinkNavigator {
    func navigate(to parsed: ParsedDeeplink) {
        // 인증 필요 경로는 AuthService 통과 후 이동
        if parsed.configuration.requiresAuth, !AuthService.shared.isAuthenticated {
            PendingDeepLink.pending = parsed // Part 1의 큐 재사용
            AuthCoordinator.shared.presentLogin()
            return
        }
 
        switch parsed.configuration {
        case .eventCampaign:
            guard let slug = parsed.pathParameters["slug"] else { return }
            EventWebViewCoordinator.shared.present(
                slug: slug,
                metadata: parsed.campaignMetadata
            )
        case .orderDetail:
            // 이하 생략
            break
        default:
            break
        }
    }
}

이번 CRM 캠페인에서 실제로 자주 타는 경로는 eventCampaign 하나였지만, 다른 경로들에도 같은 인터페이스를 미리 맞춰 두는 것이 중요했습니다. 스프린트 이후에 다른 경로를 추가할 때 이 Navigator만 확장하면 되도록 설계하는 것이 재설계의 핵심 가치였습니다.


문제 해결 3단계: 앱 초기화 상태별 처리

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

Foreground: 즉시 처리

앱이 실행 중일 때는 URL을 받자마자 바로 DeeplinkNavigator.navigate(to:)를 호출합니다. 현재 화면 위에 모달 또는 push로 올라갑니다.

Background → Foreground: 즉시 처리

앱이 백그라운드에 있다가 푸시 클릭으로 올라오는 경우도 실질적으로 Foreground와 동일 처리입니다. UIApplication이 다시 활성화되는 시점에 이미 의존성은 살아 있기 때문입니다.

Terminated → ColdStart: 지연 큐 소비

가장 까다로운 경로입니다. Part 1에서 만든 PendingDeepLink.pending 큐를 재사용하되, 여기서는 한 가지를 더 추가했습니다.

  • 인증 미완료 상태에서 진입한 경우에도 같은 큐에 다시 담아 두었다가, 로그인 완료 이벤트에서 한 번 더 소비하도록 했습니다.
// AuthCoordinator에서 로그인 성공 시
func didCompleteLogin() {
    // ... 일반 로그인 후처리
    consumePendingDeepLinkIfNeeded()
}

이 구조 덕분에 비로그인 사용자가 결제 독려 푸시를 받아 ColdStart로 진입 → 로그인 완료 → 원래 의도한 체크아웃 페이지로 이동하는 플로우가 끊김 없이 이어질 수 있었습니다. 중간에 한 번이라도 화이트 스크린이나 "홈으로" 우회가 끼면 CRM 캠페인 효과가 바로 날아가는 구조였기 때문에, 이 지점은 테스트 시나리오를 여러 번 돌리며 검증했습니다.


문제 해결 4단계: CRM 캠페인 메타데이터 파싱

서버와 합의한 URL 구조

운영팀·백엔드와 합의한 URL 형태는 아래와 같습니다.

https://sendy.co.kr/event/new-user-promo
  ?campaign_id=2025Q3_signup_retarget
  &group_id=B
  &variant=card_scanner_cta
  &utm_source=kakaotalk
  &utm_medium=alimtalk
  • campaign_id: 어떤 캠페인인지 식별 (마케팅팀 관리)
  • group_id: A/B 테스트 그룹
  • variant: 동일 그룹 안에서의 세부 분기
  • utm_*: 외부 마케팅 분석 표준 필드

Handler에서 추출

extension DeepLinkHandler {
    func extractCampaignMetadata(from url: URL, source: Source) -> CampaignMetadata {
        let components = URLComponents(url: url, resolvingAgainstBaseURL: true)
        let queryItems = components?.queryItems ?? []
 
        func value(for name: String) -> String? {
            queryItems.first(where: { $0.name == name })?.value
        }
 
        return CampaignMetadata(
            campaignId: value(for: "campaign_id"),
            groupId: value(for: "group_id"),
            variant: value(for: "variant"),
            source: source
        )
    }
}

Mixpanel로 이어 붙이기

파싱된 CampaignMetadataPart 1push_clicked와 Part 3~4의 웹뷰 내 이벤트에 그대로 속성으로 붙어 집계됩니다. 예를 들어 push_clicked 이벤트에는 다음과 같은 속성이 포함됩니다.

event: push_clicked
properties:
  campaign_id: "2025Q3_signup_retarget"
  group_id: "B"
  variant: "card_scanner_cta"
  source: "push"
  path: "/event/new-user-promo"

같은 메타데이터가 event_webview_opened, event_webview_cta_clicked, 이후 네이티브 전환 이벤트까지 퍼널의 모든 단계에 공통 속성으로 따라붙도록 설계했습니다. 이게 Part 4에서 이야기할 캠페인 기여도 측정의 기반이 됩니다.

쿼리 파라미터 인코딩 주의

한국어가 섞인 campaign_idvariant를 쓰는 경우 URL 인코딩을 두 번 적용하는 실수가 종종 발생했습니다. 서버 쪽에서 이미 퍼센트 인코딩된 문자열을 앱이 다시 디코딩할 때, URLComponents가 대부분 올바르게 처리하지만 일부 엣지 케이스(공백 + 한글 혼합)에서 깨지는 경우가 있었습니다. 운영 배포 직전에 이 부분을 한영 혼합 테스트 케이스로 한 번 더 검증한 뒤 통과시켰습니다.


개선 효과 측정

항목측정 방식
딥링크 → 타겟 화면 도달률push_clicked (또는 algoo 진입 이벤트) 대비 타겟 화면 스크린뷰채울 빈칸 % / 후 채울 빈칸 %
빈 화면 도달률딥링크 진입 후 홈으로 fallback된 비율전 23% / 후 채울 빈칸 %
ColdStart → 로그인 후 소비 성공률비로그인 ColdStart 진입자의 의도 경로 도달률채울 빈칸 % / 후 채울 빈칸 %
CRM 캠페인별 기여 추적campaign_id 속성이 퍼널 전 단계에 일관되게 붙는 비율채울 빈칸 %

채울 기준

  • 타겟 화면 도달률: push_clicked → 타겟 스크린뷰 2-step Funnel (Mixpanel)
  • 빈 화면 도달률: deeplink 이벤트 이후 10초 내 home_screen 도달한 비율 (의도와 다른 이동)
  • ColdStart 소비 성공률: source=cold_start 속성을 가진 push_clicked 이벤트 이후 sign_in_success를 거쳐 의도 화면 도달한 세션 비율
  • 메타데이터 일관성: 퍼널 각 단계에서 campaign_id가 누락 없이 이어지는지 Mixpanel 속성 분포로 확인

정량 지표 외에 가장 크게 바뀐 건 운영팀이 새 캠페인 URL을 직접 만들어 보낼 수 있게 되었다는 점이었습니다. 기존에는 iOS·Android 모두 URL 포맷이 경로별로 달라서 매번 확인 질문이 돌아왔는데, Universal Link + 매칭 테이블 구조로 통일된 이후에는 운영팀 쪽에서 URL 규약만 따르면 자동으로 라우팅되는 형태가 됐습니다.


단점과 예상치 못한 변수

1. AASA 파일 캐시 문제.

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

2. Custom URL Scheme의 스킴 충돌 위험.

sendy://를 다른 앱이 먼저 등록하면 iOS는 어느 앱이 처리할지 보장하지 않습니다. 이 때문에 Custom Scheme을 외부로 노출하는 것은 최소화하고, 내부 디버깅·레거시 호환 용도로만 유지했습니다.

3. Deferred Deeplink의 attribution 지연.

앱 미설치 상태에서 링크를 클릭해 앱스토어를 거쳐 설치한 뒤 첫 실행에서 원래 목적지로 이어지는 경로는 AppsFlyer OneLink가 돌려주는 attribution 데이터에 의존합니다. 이 데이터 도착이 네트워크 상태나 AppsFlyer 서버 응답 속도에 따라 수 초~수십 초 지연되는 경우가 있었고, 그사이 사용자가 앱 메인을 이미 둘러보기 시작하면 딥링크 소비 타이밍이 애매해집니다. 이 구간은 실제 attribution이 도착한 시점에 살짝 스낵바로 안내하는 수준의 UX로 보완했습니다.

4. 쿼리 파라미터 이중 인코딩.

운영팀이 URL을 손수 작성해서 발송하는 경우, 퍼센트 인코딩을 어느 단계에서 할지에 따라 이중 인코딩이 발생할 수 있었습니다. 운영팀 도구에 "URL 자동 생성" 기능을 제공해서 이 문제를 구조적으로 줄이는 쪽으로 정리했습니다.


이후 방향성

Part 2까지 오면 사용자는 이제 알림톡·푸시·외부 링크 중 어느 경로로 진입하더라도, 이벤트 웹뷰에 도달하게 됩니다. 여기서 새로운 문제가 시작됩니다. 웹뷰 안에서 "신청", "더 많은 차량 보러가기", "센디 시작하기" 같은 CTA를 눌렀을 때 앱 내 특정 네이티브 화면으로 매끄럽게 전환되어야 한다는 요구사항입니다. 즉, 웹 → 네이티브 방향 브릿지가 이번 라우팅 구조 위에 한 층 더 얹혀야 한다는 뜻입니다.

Part 3에서는 WKWebView 브릿지를 어떻게 재설계했는지, JavaScript ↔ Native 계약을 어디에 두었는지, 웹·네이티브 간 데이터 전달과 에러 처리 경계를 어떻게 잡았는지를 다룹니다.