노트

웹뷰 브리지(웹↔네이티브 통신)

WebView JavaScript Bridge

프론트엔드#browser#architecture · 연결된 개념 5개

쉽게 말하면

웹뷰 브리지는 앱 안에 끼워 넣은 웹 화면과 앱 본체가 서로 말을 거는 통로예요. 한집에 살지만 방이 나뉜 두 사람이 쪽지로 부탁을 주고받듯, 웹이 못 하는 푸시나 리뷰 팝업을 앱에 부탁해요.

비유의 한계 쪽지를 보내도 상대가 준비됐는지는 몰라요. 앱은 SPA 화면 전환도, 웹이 준비됐는지도 모르거든요. 그래서 웹이 ready 메시지를 먼저 보내는 핸드셰이크로 맞추고, 허용 출처 목록으로 아무나 못 부르게 해요.

웹뷰(WebView)는 앱 화면에 끼워 넣는 "주소창 없는 브라우저 한 장"이다. iOS는 WKWebView, Android는 WebView를 쓴다. 화면 대부분을 웹으로 그리는 하이브리드 앱은 웹을 배포하면 앱도 바뀐다는 장점이 있지만, 푸시 알림 수신이나 앱 리뷰 팝업, 다른 앱 열기처럼 웹이 못 하는 일은 네이티브가 해야 한다. 웹 기술로 쓰되 웹뷰 없이 네이티브 화면으로 그리는 Lynx 같은 프레임워크는 다른 길이다. 웹뷰 안의 JS(JavaScript)와 바깥의 네이티브 코드는 변수도 함수도 공유하지 않으므로, 플랫폼이 열어 둔 통로로만 말을 주고받는다.

통로 네 가지

통로방향하는 일
User-Agent 표식앱 → 웹·서버"앱 안에서 열렸다"는 신분 표시
메시지 핸들러웹 → 앱웹이 네이티브 기능을 부탁
스크립트 실행앱 → 웹네이티브가 웹에 값을 주입
URL(딥링크·쿼리)양쪽화면 이동 자체가 메시지

User-Agent 표식. iOS는 WKWebViewConfiguration.applicationNameForUserAgent로 기본 UA(User-Agent) 뒤에 MyApp/ios/4.2.0 같은 토큰을 붙일 수 있다. 웹뷰가 보내는 모든 요청에 붙으므로 웹과 서버가 같은 정규식으로 앱 여부를 판단한다. 다만 서버 렌더링 시점에는 클라이언트 전용 정보(브리지 존재 여부 등)를 알 수 없고, 포맷을 바꾸면 판별 코드를 모든 곳에서 함께 고쳐야 한다.

웹 → 앱. iOS는 WKUserContentController에 이름을 붙여 WKScriptMessageHandler를 등록하면, 웹에서 window.webkit.messageHandlers.<이름>.postMessage(값)으로 보낼 수 있다. 답을 받아야 하면 프로미스로 돌려주는 WKScriptMessageHandlerWithReply가 있다. Android는 2026년 현재 WebViewCompat.addWebMessageListener를 권한다. 허용할 출처 목록을 정해 두어 다른 사이트나 끼어든 iframe이 네이티브 코드를 부르지 못하게 할 수 있기 때문이다. 오래된 addJavascriptInterface는 출처 검사가 없고 모든 프레임에 노출돼 권하지 않는다.

// 웹 쪽: 앱이면 네이티브로, 아니면 웹 SDK로
if (window.webkit?.messageHandlers?.analytics) {
  window.webkit.messageHandlers.analytics.postMessage({ event, params })
} else {
  webAnalytics.track(event, params)
}

이벤트를 정의하는 쪽을 웹 한 곳에 두고 네이티브는 배달만 하면, 이벤트를 바꿀 때 앱 심사 없이 웹 배포로 끝난다.

앱 → 웹. evaluateJavaScript(iOS)·evaluateJavascript(Android)로 웹뷰 안에서 JS 문자열을 실행하고 결과값도 받아 온다. 페이지가 로드될 때마다 자동 실행할 스크립트는 iOS의 WKUserScript로 심는다. 문자열을 이어 붙여 코드를 만들 때는 값을 JSON으로 직렬화해 따옴표 깨짐과 주입을 막는다.

URL. 앱 밖에서 들어오는 링크는 iOS 유니버설 링크(Universal Links)와 Android 앱 링크(App Links), 또는 myapp:// 같은 커스텀 스킴으로 앱을 연다. 앱은 받은 URL을 웹 주소로 바꿔 웹뷰에 로드한다. 푸시 알림을 탭했을 때도 링크에 쿼리를 붙여 넘기면 웹이 그걸 읽고 처리한다. 반대로 웹뷰가 이동하려는 URL은 네이티브가 이동 직전에 검사해 외부 앱이나 시스템 브라우저로 돌릴 수 있다.

React Native WebView와 메시지 설계

React Native의 react-native-webview에서는 웹이 window.ReactNativeWebView.postMessage(문자열)로 보내고 네이티브가 onMessage의 event.nativeEvent.data로 받는다. 인자는 문자열 하나뿐이라 객체는 JSON으로 직렬화한다. onMessage를 설정하지 않으면 이 함수가 웹에 주입되지 않는다. 반대 방향은 injectJavaScript로 웹뷰 안에서 코드를 실행한다.

type BridgeMessage =
  | { type: 'REQUEST_BIOMETRIC_AUTH'; requestId: string }
  | { type: 'GET_APP_VERSION'; requestId: string }
  | { type: 'AUTH_RESULT'; requestId: string; success: boolean }
 
const allowed = new Set(['REQUEST_BIOMETRIC_AUTH', 'GET_APP_VERSION'])
// onMessage 안: 허용 목록에 없는 type은 버린다
const msg = JSON.parse(event.nativeEvent.data)
if (!allowed.has(msg.type)) return
  • 메시지는 type이 붙은 스키마로 정하고, 요청마다 requestId를 붙여 비동기 응답을 요청과 잇는다
  • 받은 type을 그대로 네이티브 함수 이름처럼 호출하지 않는다. 허용 목록에 있는 것만 처리하고 필드도 검증한다
  • 웹 페이지에는 꼭 필요한 메서드와 데이터만 노출한다. 페이지에 XSS가 있으면 공격자 스크립트가 같은 브리지를 쓸 수 있으니, 리프레시 토큰이나 PIN 같은 비밀은 가능하면 넘기지 않고 민감한 작업 전에는 메시지를 검증한다

이름이 같은 브라우저 표준 window.postMessage는 iframe·팝업 같은 창끼리 주고받는 API라 성격이 다르다. 보낼 때 둘째 인자 targetOrigin을 *로 두면 그 창이 다른 사이트로 이동했을 때 데이터를 엉뚱한 곳에 넘길 수 있다. 그래서 정확한 출처를 적고, 받는 쪽은 event.origin을 확인한 뒤 데이터 형식까지 검증한다.

두 세계의 시차

  • SPA 화면 전환을 앱은 모른다. 웹뷰가 아는 건 문서를 새로 받는 진짜 이동뿐이다. history.pushState로 주소만 바꾸는 SPA(Single-Page Application) 이동은 신호가 오지 않는다. 앱이 경로를 알아야 하면 웹이 메시지 핸들러로 직접 알리게 한다. pushState를 덮어쓰는 몽키패치는 replaceState를 놓치거나 라우터 구현이 바뀌면 조용히 깨진다
  • 앱은 웹이 준비됐는지 모른다. 로드 완료 콜백은 HTML 파싱 직후라, React가 전역 함수를 붙이기 전일 수 있다. 폴링과 재시도로 버틸 수도 있지만, 정석은 웹이 준비됐을 때 "ready" 메시지를 먼저 보내고 앱이 그때 값을 넣는 웹 주도 핸드셰이크다. 받은 값은 웹이 저장해 두면 다음 기회에 복구할 수 있다

앱 배포는 심사와 사용자 업데이트를 기다려야 하고 웹 배포는 즉시라서, 빈틈은 웹 쪽에서 메우는 쪽으로 자연스럽게 기운다. 웹이 저장한 값은 웹뷰의 웹 저장소에 있어서 네이티브 저장소와 자동으로 공유되지 않는다 → 모바일 네이티브 저장소

safe area

노치와 홈 인디케이터를 피하는 방법은 둘이다. 네이티브가 웹뷰 자체를 safe area 안으로 줄이거나, 웹뷰를 화면 전체에 깔고 웹이 CSS로 피한다. 후자는 <meta name="viewport" content="width=device-width, viewport-fit=cover">로 화면 끝까지 그리게 한 뒤 env(safe-area-inset-*)로 여백을 준다.

.bottom-bar {
  padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
}

웹뷰 디버깅은 iOS에서 isInspectable을 켜면 Safari 웹 인스펙터가 붙는다. 운영 빌드에서는 끄는 것이 안전하다. 출처 개념은 동일 출처 정책, 서버 렌더링과 클라이언트 값의 어긋남은 하이드레이션을 본다.

출처: Apple — WKScriptMessageHandler · Apple — applicationNameForUserAgent · Apple — evaluateJavaScript · Android — Access native APIs with JavaScript bridge · MDN — env() · WebKit — Designing Websites for iPhone X · react-native-webview — Communicating between JS and Native · MDN — Window.postMessage() 보안

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • WebGPU

    웹페이지에서 GPU로 그래픽과 범용 계산을 하는 브라우저 API. WebGL의 후속으로, 브라우저 안에서 ML 모델을 돌리는 기반이 된다.

  • Web Worker

    Web Worker는 페이지의 메인 스레드와 별개인 백그라운드 스레드에서 자바스크립트를 돌리는 브라우저 기능이다. 클릭에 반응하는 스크립트에 끼어들지 않고 긴 계산을 돌릴 수 있어서, 무거운 일을 하는 동안에도 페이지가 응답한다.

  • Activity와 숨겨진 라우트 보존

    Activity는 React 19.2의 컴포넌트로, UI를 언마운트하지 않고 display: none으로 숨기면서 state와 DOM(Document Object Model)을 보존한다. 숨기는 동안 Effect는 정리(cleanup)된다. 예전 이름은 Offscreen이다.

  • HTMX

    HTML 속성만으로 서버에 요청을 보내고, 응답으로 받은 HTML 조각을 페이지 일부에 끼워 넣게 해 주는 작은 라이브러리. 클라이언트에서 JSON(JavaScript Object Notation)을 받아 상태와 화면을 그리는 SPA(Single Page Application)와 달리, 서버가 화면 조각을 만들어 보내는 방식(하이퍼미디어, Hypermedia)으로 돌아간다.

  • Electron 앱 구조

    Electron은 Chromium과 Node.js를 묶어 웹 기술(HTML·CSS·JavaScript)로 데스크톱 앱을 만드는 프레임워크다. 앱은 OS(Operating System) 권한을 가진 메인 프로세스(Main Process) 하나와, 창마다 하나씩 뜨는 렌더러 프로세스(Renderer Process)로 나뉜다.

보기 옵션