들어가며
안녕하세요, 센디 iOS엔지니어 로디입니다.
Part 2에서 알림톡·푸시·외부 마케팅 URL 세 진입 경로가 모두 같은 이벤트 웹뷰로 떨어지도록 라우팅을 정리했습니다. Part 3은 그 웹뷰에 사용자가 도착한 이후의 이야기입니다. 웹뷰 안에는 "신청", "더 많은 차량 보러가기", "센디 시작하기" 같은 CTA 버튼이 있는데, 이 버튼을 누르면 앱 내 네이티브 화면으로 자연스럽게 전환되어야 한다는 요구사항이 있었습니다.
즉, 웹 쪽에서는 "사용자가 CTA를 눌렀다"는 사실을 알고 있지만, 그 결과로 무엇을 해야 하는지는 네이티브 쪽의 라우팅 구조에 맡겨야 합니다. 웹 ↔ 네이티브 사이의 브릿지 계약이 필요해진 지점입니다.
진행 배경
문제 정의
기존에도 센디 앱 안에는 WKWebView 기반 페이지가 몇 군데 있었지만, 웹 → 네이티브 방향 통신은 각 화면마다 ad-hoc 하게 구현되어 있었습니다. 화면마다 다른 JS 함수명을 썼고, payload 포맷도 조금씩 달랐습니다. 새 이벤트 웹뷰를 붙이려고 기존 구조를 보니 두 가지 문제가 드러났습니다.
- 브릿지 계약이 일관되지 않았습니다 — 어느 화면은
window.sendyAction(...)을 쓰고, 다른 화면은window.webkit.messageHandlers.sendy.postMessage(...)를 씁니다. 두 구조가 섞여 있어 새 화면을 붙일 때마다 프론트·네이티브가 이 중 어느 쪽을 쓸지 합의하는 시간이 낭비되고 있었습니다. - 에러 경계가 불분명했습니다 — JS에서 잘못된 payload를 보내거나, 네이티브가 해석하지 못하는 Route를 받을 때 화면이 무반응이 되는 경우가 있었습니다. 무반응은 사용자 입장에서 가장 나쁜 상태입니다.
이번 CRM 캠페인에서는 웹뷰 CTA → 네이티브 전환이 퍼널 전환의 핵심 지점이었기 때문에, 이 두 문제를 이번 기회에 구조적으로 정리하기로 했습니다.
개선 방안 도출
| 대안 | 평가 |
|---|---|
| 기존 ad-hoc 방식 유지, 이벤트 웹뷰 전용으로만 계약 추가 | 단기에는 빠르지만 파편화가 유지됨. 다음 웹뷰 화면에서 같은 문제 재발 |
| 공통 브릿지 프로토콜을 앱 전체에 확산 | 스프린트 범위 초과. 다른 웹뷰 화면의 회귀 리스크 |
| 이벤트 웹뷰를 기준으로 표준 브릿지를 만들고, 기존 화면은 점진적 이관 (채택) | 당장 안전하고, 이후 다른 웹뷰도 같은 구조로 흡수할 수 있는 기반 |
배경 지식
본문에 등장하는 용어 세 가지를 짧게 정리합니다.
- WKWebView: iOS 앱 안에서 웹 페이지를 표시할 수 있는 웹 엔진. Safari와 같은 WebKit 엔진을 쓰지만, 앱 프로세스와는 별도 프로세스에서 렌더링됩니다.
- WKScriptMessageHandler: JS 쪽에서
window.webkit.messageHandlers.xxx.postMessage(...)를 호출했을 때 네이티브가 받을 수 있게 해 주는 공식 브릿지 프로토콜. 웹 → 네이티브 방향 통신의 표준 진입점입니다. - evaluateJavaScript: 네이티브에서 웹 쪽 JS 코드를 실행할 수 있는 API. 네이티브 → 웹 방향 통신은 주로 이 API로 이루어집니다.
문제 해결 1단계: 브릿지 계약부터 명시
하나의 채널로 모으기
가장 먼저 한 것은 웹에서 네이티브로 보내는 모든 메시지를 단일 채널로 통일하는 작업이었습니다.
// 웹뷰 구성 시 메시지 핸들러 등록
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으로 강제 변환하는 것이 결정적이었습니다. 프론트에서 오타나 미정의 타입이 올라왔을 때 네이티브가 조용히 통과시키는 대신 디코딩 실패로 바로 잡히도록 의도한 설계입니다. 이게 "무반응" 상태를 만들지 않는 첫 안전망 역할을 했습니다.
문제 해결 2단계: 웹 CTA를 네이티브 Route로
CTA 버튼이 네이티브 전환을 일으킨다는 건, 결국 Part 2의 DeeplinkConfiguration과 동일한 경로를 웹 쪽에서도 참조한다는 뜻입니다. 그래서 웹뷰 브릿지의 navigate 타입은 그대로 Part 2 라우팅 레이어로 위임하도록 설계했습니다.
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]) {
// Part 2의 DeeplinkNavigator에 위임
let url = URL(string: "https://sendy.co.kr/\(route)")
guard let url else { return }
// 웹뷰에서 진입했음을 source에 명시
DeepLinkRouter.shared.route(url, from: .webview, extraParams: params)
}
}이 위임 구조가 가지는 이점은 두 가지였습니다.
- Part 2의 라우팅 레이어가 이미 인증·ColdStart 큐·매칭 테이블을 처리하고 있으므로, 브릿지가 직접 네비게이션 스택을 건드릴 일이 없습니다.
- 웹뷰에서 온 요청인지 푸시에서 온 요청인지만
source로 구분되고, 이후 퍼널 이벤트의 속성으로 자연스럽게 이어집니다. (Part 4의 측정 이야기로 연결)
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에 쌓이게 되어 퍼널이 끊기지 않습니다.
문제 해결 3단계: 네이티브 → 웹 방향 통신
웹 → 네이티브만으로는 해결 안 되는 시나리오가 있었습니다. 예를 들어 로그인 상태가 바뀐 순간 웹뷰 쪽 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)에 네이티브가 결합되면 안 된다는 점
- 함수가 아직 정의되지 않은 타이밍에 호출하면 조용히 실패하는데, 이벤트 모델은 리스너가 없으면 그냥 버려지는 명확한 의미를 가짐
문제 해결 4단계: 에러 경계를 어디에 둘 것인가
무반응 상태를 막는 것이 이번 설계의 주요 목표였기 때문에, 에러 경계를 세 지점에 명시적으로 두었습니다.
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하지 않고, 이벤트 웹뷰에 머물도록 결정했습니다. 사용자가 의도와 다른 화면으로 튕겨 나가는 것보다 그대로 웹뷰에 있는 편이 덜 혼란스럽다고 판단했기 때문입니다. 실패 사실은 네이티브 → 웹 방향 이벤트로 알려 주어 웹 쪽이 "잠시 후 다시 시도해 주세요" 토스트를 띄울 수 있도록 했습니다.
문제 해결 5단계: WKWebView 설정 주의점
브릿지 구조와 별개로, WKWebView 자체의 설정 몇 가지도 이번에 정리했습니다.
Cookie 공유
인증이 필요한 API를 웹 측에서 호출해야 하는 경우가 있었습니다. 이때 네이티브 URLSession이 가진 세션 쿠키를 웹뷰에도 주입해야 했습니다.
let cookieStore = webView.configuration.websiteDataStore.httpCookieStore
let authCookie = HTTPCookie(properties: [
.name: "auth_token",
.value: AuthService.shared.accessToken ?? "",
.domain: "sendy.co.kr",
.path: "/",
.secure: "TRUE"
])
if let cookie = authCookie {
cookieStore.setCookie(cookie)
}단 주의 — iOS 14+의 App-Bound Domain 정책에 해당 도메인을 등록해야 쿠키 주입이 반영됩니다. Info.plist의 WKAppBoundDomains 키에 sendy.co.kr을 추가했습니다.
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·이미지 링크가 웹뷰 안에서 열려, 사용자가 앱에서 빠져나간 느낌을 받을 수 있었습니다.
개선 효과 측정
| 항목 | 측정 방식 | 값 |
|---|---|---|
| 웹뷰 CTA 클릭 → 네이티브 진입 성공률 | event_webview_cta_clicked 대비 의도 화면 진입 | 전 채울 빈칸 % / 후 채울 빈칸 % |
| 브릿지 payload 디코딩 실패율 | 전체 브릿지 호출 대비 bridge_error (decodeFailed) 비율 | 채울 빈칸 % |
| 무반응 상태(white-screen) 리포트 | CS 티켓 중 "웹뷰 버튼이 안 눌려요" 계열 건수 | 전 채울 빈칸 건 / 후 채울 빈칸 건 |
| 웹·네이티브 퍼널 일관성 | campaign_id가 웹·네이티브 이벤트에 일관되게 이어지는 비율 | 채울 빈칸 % |
채울 기준
- CTA 진입 성공률: Mixpanel 2단계 Funnel
- 디코딩 실패율:
bridge_error이벤트 수 / 전체 브릿지 호출 수. 구조적 버그 여부 모니터링용- 무반응 리포트: Zendesk·CS 대시보드에서 "웹뷰" 태그 필터
- 퍼널 일관성: Mixpanel에서
campaign_id속성 누락 비율 (null비율) 측정
단점과 예상치 못한 변수
1. 기존 웹뷰 화면과의 이중 구조 기간.
이벤트 웹뷰는 새 표준 브릿지를 쓰고, 기존 웹뷰 화면(공지·약관 등)은 여전히 ad-hoc 방식을 씁니다. 점진 이관 전까지는 두 패턴이 공존해서 새 개발자 온보딩 시 혼란 요소가 됩니다. 팀 내부 문서로 "새 웹뷰는 표준 브릿지 기준" 원칙만 고정해 두었고, 이관은 이후 스프린트에서 순차 진행 중입니다.
2. evaluateJavaScript의 실행 시점 보장 문제.
네이티브 → 웹 이벤트를 보낼 때 웹 문서가 아직 준비되지 않았다면 리스너가 없는 시점에 이벤트가 버려집니다. didFinish 이후에만 호출하도록 내부 큐를 두었지만, 단일 페이지 앱(SPA)의 라우트 전환 중에는 여전히 누락 가능성이 있습니다. 이 부분은 중요 이벤트에 한해 3회 재시도 + 실패 이벤트 기록으로 완화했습니다.
3. App-Bound Domain 정책의 전파 시차.
WKAppBoundDomains를 바꾸면 앱 재설치 전까지 정책이 완전히 반영되지 않는 경우가 있었습니다. 새 도메인이 추가될 때마다 이 변경도 릴리스 사이클에 같이 들어가야 해서, CMS 쪽 호스트 추가는 앱 릴리스 주기에 맞춰 계획해야 한다는 운영 제약이 생겼습니다.
4. 디버깅 환경의 한계.
WKWebView 안의 JS는 Safari Web Inspector에서만 디버깅할 수 있는데, 실기기 + Safari Inspector 연결이 간헐적으로 끊기는 문제가 있었습니다. 이런 상황에서 브릿지 호출이 실패하면 원인을 좁히기 어려웠습니다. 사내 디버깅 도구 쪽에 브릿지 호출 로그를 별도로 남기도록 보완하고 있습니다.
문제 해결 6단계: 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 퍼널 끝까지 이어지게 되었습니다.
시리즈를 마치며
이번 작업을 한 문장으로 정리하면 "센디 CRM 재진입 채널을 앞으로의 모든 캠페인이 같은 레일 위에서 움직일 수 있는 공통 구조로 설계하는 것"이었습니다. 각 레이어를 따로따로 완성하는 방식이 아니라, "다음 캠페인이 왔을 때 같은 구조를 그대로 재사용할 수 있는가"를 매 결정의 기준으로 두고 서로 얽힌 판단들을 풀어 나갔습니다.
- Part 1에서 푸시 생명주기와 ColdStart payload 복원을 잡았고,
- Part 2에서 Universal Link 기반 라우팅 레이어로 알림톡·푸시·외부 URL을 하나로 모았으며,
- Part 3에서 WKWebView 브릿지를 표준화해 웹 → 네이티브 전환을 명시적 계약으로 만들고, 2-depth 스택 관리까지 연결했습니다.
결과적으로 운영팀은 네이티브 배포 없이 기존 구조 위에서 새 캠페인을 발송할 수 있게 되었고, 마케팅 쪽은 동일한 Funnel 규격으로 여러 캠페인을 비교할 수 있게 되었습니다.
읽어 주셔서 감사합니다.