CRM 캠페인에서 시작한 앱 진입 시스템 구축기 (3): 웹뷰와 네이티브를 잇는 브릿지

시리즈
CRM 캠페인에서 시작한 앱 진입 시스템 구축기
안녕하세요. 센디 플랫폼 스쿼드에서 iOS 앱을 개발하는 박정환(Lody)입니다.
이 글은 이 시리즈의 3편입니다. 1편. 푸시 알림 수신과 클릭과 2편. 여러 경로를 모으는 딥링크 라우팅을 먼저 보면 맥락이 이어집니다.
1편의 기기 녹화는 이미지 푸시와 탭 이후 화면을 보여주지만, 앱 종료 상태에서의 진입까지 입증하지는 않습니다. 2편은 여러 URL 진입을 목적지에 연결하는 코드를 다룹니다. 이 글에서는 이벤트 웹뷰의 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 전환이 의도한 네이티브 화면(비용 계산기)에 정확히 도달하는지, 그리고 그 화면의 버튼이 다음에 어디로 연결되는지를 함께 표시해 검증했습니다.

같은 브릿지로 처리하는 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 자체의 설정 몇 가지도 이번에 정리했습니다.
Cookie 공유
인증이 필요한 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)
}이 구조에서는 스택을 사용자의 시선과 일치시키는 것이 핵심이었습니다. 화면 위에서 본 순서 그대로 네이티브 네비게이션 스택이 쌓이고, 뒤로 가기는 그 순서를 역순으로 따라갑니다.

웹뷰 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 진입률이나 CS 문의의 전후 수치를 재현할 대시보드 자료는 확인하지 못해 성과 수치로 제시하지 않는다.
단점과 예상치 못한 변수: 웹뷰 브릿지와 스택
1. 화이트리스트 밖 링크 처리에서 Universal Link가 예상과 다르게 동작했습니다.
앞서 콘텐츠 보안 정책에서 다룬 "화이트리스트에 없는 도메인은 시스템 브라우저로" 처리 경로에서, 운영 중 예상치 못한 동작이 실제로 하나 나왔습니다. 화이트리스트에 없는 sendy.ai Universal Link를 UIApplication.shared.open(url)로 넘길 때, 앱이 이미 실행 중인 상태라면 같은 앱으로 돌아오지 않고 Safari가 새로 열리는 경우가 있었습니다. CMS 콘텐츠 쪽을 담당하는 동료가 이 현상을 먼저 발견해 공유해 주었습니다.

decidePolicyFor navigationAction의 로직 자체를 바꾸는 대신, 이 경로로 흘려보내는 링크의 형식만 바꾸는 쪽을 택했습니다. sendy.ai Universal Link 대신 sendyapp:// 커스텀 스킴으로 링크를 생성하도록 콘텐츠 발급 쪽과 합의했습니다. 커스텀 스킴은 UIApplication.shared.open()이 브라우저를 거치지 않고 곧장 앱으로 넘기기 때문에, 앱이 실행 중이든 아니든 결과가 갈리지 않았습니다. 클라이언트 로직은 그대로 두고 링크를 만드는 쪽의 형식만 바꿔 해결한 셈이며, 2편 라우팅 레이어의 단점에서 스킴 충돌 위험 때문에 최소화하기로 했던 그 스킴이 이 지점에서 실제로 쓸모를 증명했습니다.
2. 기존 웹뷰 화면과의 이중 구조 기간.
이벤트 웹뷰는 새 표준 브릿지를 쓰고, 기존 웹뷰 화면(공지, 약관 등)은 여전히 ad-hoc 방식을 씁니다. 점진 이관 전까지는 두 패턴이 공존해서 새 개발자 온보딩 시 혼란 요소가 됩니다. 팀 내부 문서로 "새 웹뷰는 표준 브릿지 기준" 원칙만 고정해 두었고, 이관은 이후 스프린트에서 순차 진행 중입니다.
3. evaluateJavaScript의 실행 시점 보장 문제.
네이티브에서 웹으로 이벤트를 보낼 때 웹 문서가 아직 준비되지 않았다면 리스너가 없는 시점에 이벤트가 버려집니다. didFinish 이후에만 호출하도록 내부 큐를 두었지만, 단일 페이지 앱(SPA)의 라우트 전환 중에는 여전히 누락 가능성이 있습니다. 이 부분은 중요 이벤트에 한해 3회 재시도 + 실패 이벤트 기록으로 완화했습니다.
4. App-Bound Domain 정책의 전파 시차.
WKAppBoundDomains를 바꾸면 앱 재설치 전까지 정책이 완전히 반영되지 않는 경우가 있었습니다. 새 도메인이 추가될 때마다 이 변경도 릴리스 사이클에 같이 들어가야 해서, CMS 쪽 호스트 추가는 앱 릴리스 주기에 맞춰 계획해야 한다는 운영 제약이 생겼습니다.
5. 디버깅 환경의 한계.
WKWebView 안의 JS는 Safari Web Inspector에서만 디버깅할 수 있는데, 실기기 + Safari Inspector 연결이 간헐적으로 끊기는 문제가 있었습니다. 이런 상황에서 브릿지 호출이 실패하면 원인을 좁히기 어려웠습니다. 사내 디버깅 도구 쪽에 브릿지 호출 로그를 별도로 남기도록 보완하고 있습니다.
마치며
선택한 구조
푸시 알림 표시와 클릭 처리, 여러 진입 경로를 모으는 라우팅, 웹뷰 CTA 브릿지와 2-depth 스택을 차례로 살펴봤습니다. 코드에서 확인되는 각 단계의 선택은 다음과 같습니다.
- 푸시 생명주기(1편): 수신·클릭 콜백을 한 핸들러로 모으고 측정은 두 지점에만 둔 뒤 라우팅은
DeepLinkRouter에 위임. ColdStart payload는 의존성이 준비되는 탭바 등장 시점까지 지연 소비 - 딥링크 라우팅(2편): 세 URL 방식을 분기하지 않고 하나의 매칭 테이블로 모아, 해석·매칭·내비게이션을 타입별로 분리. 새 캠페인은 테이블 한 줄로 끝남
- 웹뷰 브릿지(3편): 웹에서 네이티브로는 단일 채널과 enum 강제 디코딩으로 무반응을 차단하고,
navigate는 라우팅 레이어에 그대로 위임. 2-depth 스택은 네이티브 네비게이션으로만 관리
남은 비용과 판단 기준
세 편의 코드에서 확인되는 것은 캠페인 진입 값을 푸시 payload와 URL에서 읽어 앱 목적지로 전달하고, 이벤트 웹뷰 CTA도 같은 라우팅 규약을 사용하도록 연결한 구조입니다. 캠페인별 복귀 결제 전환율이나 운영 비용 변화는 비교 자료를 확인하지 못해 결과로 주장하지 않습니다.
각각의 선택이 가장 간단한 구현은 아니었습니다. Extension 타깃을 따로 관리해야 했고, AASA 캐시와 App-Bound Domain 전파 시차, 이벤트 웹뷰와 기존 웹뷰의 이중 구조처럼 남은 비용도 분명합니다. 그래도 계속 지킨 기준은 하나였습니다. 당장 쉬운 분기보다, 새 캠페인이 들어올 때 네이티브가 다시 손대지 않아도 되는 구조를 택한다는 것이었습니다. 남은 과제 중 이중 구조 이관과 App-Bound Domain 릴리스 동기화는 다음 분기 스프린트에서 순차적으로 정리할 계획입니다.