브릿지(Bridge)란? 웹앱에서 브릿지는 서로 다른 실행 컨텍스트 간의 통신을 추상화한 레이어 입니다.
postMessage나 네이티브 API는 저수준(low-level)이라 메시지 타입 관리, 요청-응답 매핑, 에러 처리를 직접 구현해야 합니다. 브릿지 패턴을 적용하면 이를 추상화하여 RPC(Remote Procedure Call)처럼 쉽게 사용할 수 있습니다.
브릿지가 필요한 대표적인 상황
상황
통신 대상
iframe 내부 ↔ 부모 페이지
다른 origin의 window
웹뷰(WebView) ↔ 네이티브 앱
React Native, iOS, Android
Web Worker ↔ 메인 스레드
백그라운드 스레드
브라우저 확장(Extension) ↔ 웹 페이지
확장 콘텐츠 스크립트
1. 기본 브릿지 패턴 (iframe ↔ 부모) 요청마다 고유한 id를 부여하고, 응답이 오면 해당 id의 Promise를 resolve하는 방식입니다.
class IframeBridge { constructor (targetWindow, targetOrigin ) { this .targetWindow = targetWindow; this .targetOrigin = targetOrigin; this .pendingRequests = new Map (); this .handlers = new Map (); window .addEventListener ('message' , (event ) => { if (event.origin !== this .targetOrigin ) return ; this ._handleMessage (event.data ); }); } request (type, payload ) { return new Promise ((resolve, reject ) => { const id = crypto.randomUUID (); this .pendingRequests .set (id, { resolve, reject }); this .targetWindow .postMessage ( { id, type, payload }, this .targetOrigin ); setTimeout (() => { if (this .pendingRequests .has (id)) { this .pendingRequests .delete (id); reject (new Error (`Request timeout: ${type} ` )); } }, 5000 ); }); } on (type, handler ) { this .handlers .set (type, handler); } _handleMessage (message ) { const { id, type, payload, error, isResponse } = message; if (isResponse && this .pendingRequests .has (id)) { const { resolve, reject } = this .pendingRequests .get (id); this .pendingRequests .delete (id); error ? reject (new Error (error)) : resolve (payload); return ; } const handler = this .handlers .get (type); if (handler) { Promise .resolve () .then (() => handler (payload)) .then ((result ) => { this .targetWindow .postMessage ( { id, type, payload : result, isResponse : true }, this .targetOrigin ); }) .catch ((err ) => { this .targetWindow .postMessage ( { id, type, error : err.message , isResponse : true }, this .targetOrigin ); }); } } }
부모 페이지에서 사용 const iframe = document .getElementById ('myFrame' );iframe.addEventListener ('load' , () => { const bridge = new IframeBridge ( iframe.contentWindow , 'https://child-domain.com' ); bridge.request ('GET_USER_INFO' , { userId : 42 }) .then ((user ) => console .log ('사용자 정보:' , user)) .catch ((err ) => console .error (err)); bridge.on ('CART_UPDATED' , (payload ) => { console .log ('장바구니 변경:' , payload); }); });
iframe 내부에서 사용 const bridge = new IframeBridge (window .parent , 'https://parent-domain.com' );bridge.on ('GET_USER_INFO' , async ({ userId }) => { const user = await fetchUser (userId); return user; }); bridge.request ('CART_UPDATED' , { itemCount : 3 });
2. WebView 브릿지 (하이브리드 앱) React Native, Cordova 등 하이브리드 앱 에서는 네이티브 코드와 웹뷰(WebView) 간 통신에 브릿지를 사용합니다. 웹 측에서는 window.ReactNativeWebView.postMessage 또는 webkit.messageHandlers를 통해 네이티브로 메시지를 보냅니다.
React Native WebView 브릿지 class ReactNativeBridge { constructor ( ) { this .pendingRequests = new Map (); window .addEventListener ('message' , (event ) => { try { const message = JSON .parse (event.data ); this ._handleNativeMessage (message); } catch (e) { } }); } call (action, params = {} ) { return new Promise ((resolve, reject ) => { const id = crypto.randomUUID (); this .pendingRequests .set (id, { resolve, reject }); const message = JSON .stringify ({ id, action, params }); if (window .ReactNativeWebView ) { window .ReactNativeWebView .postMessage (message); } else { reject (new Error ('ReactNativeWebView not available' )); } }); } _handleNativeMessage ({ id, result, error } ) { if (!this .pendingRequests .has (id)) return ; const { resolve, reject } = this .pendingRequests .get (id); this .pendingRequests .delete (id); error ? reject (new Error (error)) : resolve (result); } } const bridge = new ReactNativeBridge ();bridge.call ('openCamera' , { quality : 'high' }) .then ((photoUri ) => console .log ('촬영된 사진:' , photoUri)) .catch ((err ) => console .error ('카메라 실패:' , err)); bridge.call ('getStorageItem' , { key : 'authToken' }) .then ((token ) => console .log ('토큰:' , token));
import { WebView } from 'react-native-webview' ;function AppWebView ( ) { const webViewRef = useRef (null ); const handleMessage = async (event ) => { const { id, action, params } = JSON .parse (event.nativeEvent .data ); let result, error; try { if (action === 'openCamera' ) { result = await openNativeCamera (params); } else if (action === 'getStorageItem' ) { result = await AsyncStorage .getItem (params.key ); } } catch (e) { error = e.message ; } const response = JSON .stringify ({ id, result, error }); webViewRef.current .injectJavaScript ( `window.dispatchEvent(new MessageEvent('message', { data: ${JSON .stringify(response)} }));` ); }; return ( <WebView ref ={webViewRef} source ={{ uri: 'https: //my-webapp.com ' }} onMessage ={handleMessage} /> ); }
Android WebView 브릿지 Android에서는 @JavascriptInterface 어노테이션이 붙은 Kotlin/Java 객체를 WebView에 주입하면, 웹에서 window.AndroidBridge.메서드명()으로 직접 호출할 수 있습니다.
Android 통신 흐름 웹 → 네이티브: window.AndroidBridge.postMessage(json) 네이티브 → 웹: webView.evaluateJavascript("window.__callback(json)", null)
Android 웹 측 코드 class AndroidBridgeClient { constructor ( ) { this .pendingRequests = new Map (); } call (action, params = {} ) { return new Promise ((resolve, reject ) => { const id = crypto.randomUUID (); window [`__bridge_${id} ` ] = (jsonStr ) => { delete window [`__bridge_${id} ` ]; const { result, error } = JSON .parse (jsonStr); error ? reject (new Error (error)) : resolve (result); }; if (!window .AndroidBridge ) { reject (new Error ('AndroidBridge is not available' )); return ; } window .AndroidBridge .postMessage ( JSON .stringify ({ id, action, params }) ); }); } } const bridge = new AndroidBridgeClient ();bridge.call ('getDeviceInfo' ) .then ((info ) => console .log ('디바이스 정보:' , info)); bridge.call ('openCamera' , { quality : 'high' }) .then ((uri ) => console .log ('사진 경로:' , uri));
네이티브 측 코드 (Kotlin) class AndroidBridge ( private val context: Context, private val webView: WebView ) { @JavascriptInterface fun postMessage (jsonStr: String ) { val json = JSONObject(jsonStr) val id = json.getString("id" ) val action = json.getString("action" ) val params = json.optJSONObject("params" ) ?: JSONObject() CoroutineScope(Dispatchers.IO).launch { val (result, error) = try { handleAction(action, params) to null } catch (e: Exception) { null to e.message } val response = JSONObject().apply { put("result" , result) put("error" , error) } withContext(Dispatchers.Main) { webView.evaluateJavascript( "window.__bridge_${id} ('${response} ')" , null ) } } } private fun handleAction (action: String , params: JSONObject ) : Any? { return when (action) { "getDeviceInfo" -> JSONObject().apply { put("os" , "Android" ) put("version" , Build.VERSION.RELEASE) put("model" , Build.MODEL) } "openCamera" -> { "/storage/emulated/0/DCIM/photo.jpg" } else -> throw IllegalArgumentException("Unknown action: $action " ) } } }
class MainActivity : AppCompatActivity () { private lateinit var webView: WebView override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) webView = WebView(this ).apply { settings.javaScriptEnabled = true addJavascriptInterface( AndroidBridge(this @MainActivity , this ), "AndroidBridge" ) loadUrl("https://my-webapp.com" ) } setContentView(webView) } }
보안 주의 : @JavascriptInterface는 앱 내 신뢰된 URL에만 사용해야 합니다. Android 4.2(API 17) 미만에서는 모든 public 메서드가 노출되는 취약점이 있었으므로 최소 minSdk = 17을 설정하세요.
iOS WKWebView 브릿지 iOS에서는 webkit.messageHandlers를 통해 네이티브로 메시지를 보냅니다. Android와 달리 JavaScript 객체를 직접 주입하는 방식이 아니라 메시지 핸들러 방식으로 동작합니다.
iOS 통신 흐름 웹 → 네이티브: webkit.messageHandlers.bridge.postMessage(json) 네이티브 → 웹: webView.evaluateJavaScript("window.__callback(json)")
iOS 웹 측 코드 class IOSBridgeClient { call (action, params = {} ) { return new Promise ((resolve, reject ) => { const id = crypto.randomUUID (); window [`__bridge_${id} ` ] = (jsonStr ) => { delete window [`__bridge_${id} ` ]; const { result, error } = JSON .parse (jsonStr); error ? reject (new Error (error)) : resolve (result); }; if (!window .webkit ?.messageHandlers ?.bridge ) { reject (new Error ('iOS WebKit bridge is not available' )); return ; } webkit.messageHandlers .bridge .postMessage ( JSON .stringify ({ id, action, params }) ); }); } } const bridge = new IOSBridgeClient ();bridge.call ('getDeviceInfo' ) .then ((info ) => console .log ('디바이스 정보:' , info)); bridge.call ('getLocation' ) .then (({ lat, lng } ) => console .log ('현재 위치:' , lat, lng));
네이티브 측 코드 (Swift) import WebKitclass BridgeHandler : NSObject , WKScriptMessageHandler { weak var webView: WKWebView ? func userContentController ( _ userContentController : WKUserContentController , didReceive message : WKScriptMessage ) { guard let body = message.body as? String , let data = body.data(using: .utf8), let json = try? JSONSerialization .jsonObject(with: data) as? [String : Any ], let id = json["id" ] as? String , let action = json["action" ] as? String else { return } let params = json["params" ] as? [String : Any ] ?? [:] Task { let (result, error): (Any ? , String ?) do { result = try await handleAction(action, params: params) error = nil } catch { result = nil error = error.localizedDescription } let response: [String : Any ] = [ "result" : result as Any , "error" : error as Any ] let responseData = try! JSONSerialization .data(withJSONObject: response) let responseStr = String (data: responseData, encoding: .utf8)! await MainActor .run { webView? .evaluateJavaScript( "window.__bridge_\(id) ('\(responseStr) ')" ) } } } private func handleAction (_ action : String , params : [String : Any ]) async throws -> Any ? { switch action { case "getDeviceInfo" : return [ "os" : "iOS" , "version" : UIDevice .current.systemVersion, "model" : UIDevice .current.model ] case "getLocation" : return ["lat" : 37.5665 , "lng" : 126.9780 ] default : throw NSError ( domain: "BridgeError" , code: - 1 , userInfo: [NSLocalizedDescriptionKey: "Unknown action: \(action) " ] ) } } }
class ViewController : UIViewController { var webView: WKWebView ! let bridgeHandler = BridgeHandler () override func viewDidLoad () { super .viewDidLoad() let config = WKWebViewConfiguration () let contentController = WKUserContentController () contentController.add(bridgeHandler, name: "bridge" ) config.userContentController = contentController webView = WKWebView (frame: view.bounds, configuration: config) bridgeHandler.webView = webView view.addSubview(webView) if let url = URL (string: "https://my-webapp.com" ) { webView.load(URLRequest (url: url)) } } deinit { webView.configuration.userContentController.removeScriptMessageHandler(forName: "bridge" ) } }
주의 : deinit에서 removeScriptMessageHandler를 호출하지 않으면 WKUserContentController가 BridgeHandler를 strong reference로 유지해 메모리 누수가 발생합니다.
Android vs iOS 브릿지 비교
항목
Android
iOS
웹 → 네이티브
window.AndroidBridge.postMessage()
webkit.messageHandlers.bridge.postMessage()
네이티브 → 웹
webView.evaluateJavascript()
webView.evaluateJavaScript()
주입 방식
JS Interface 객체 직접 주입
메시지 핸들러 등록
스레드
@JavascriptInterface 호출은 별도 스레드
기본 메인 스레드에서 처리
보안 설정
minSdk >= 17 필수
deinit에서 핸들러 제거 필수
인터페이스 이름
addJavascriptInterface(obj, "이름")
add(handler, name: "이름")
3. Web Worker 브릿지 CPU 집약적인 작업을 Web Worker로 분리할 때도 동일한 브릿지 패턴을 적용할 수 있습니다.
class WorkerBridge { constructor (workerPath ) { this .worker = new Worker (workerPath); this .pendingRequests = new Map (); this .worker .addEventListener ('message' , ({ data } ) => { const { id, result, error } = data; if (!this .pendingRequests .has (id)) return ; const { resolve, reject } = this .pendingRequests .get (id); this .pendingRequests .delete (id); error ? reject (new Error (error)) : resolve (result); }); } call (action, payload ) { return new Promise ((resolve, reject ) => { const id = crypto.randomUUID (); this .pendingRequests .set (id, { resolve, reject }); this .worker .postMessage ({ id, action, payload }); }); } terminate ( ) { this .worker .terminate (); } } const bridge = new WorkerBridge ('./worker.js' );bridge.call ('heavyCompute' , { data : largeArray }) .then ((result ) => console .log ('계산 완료:' , result));
self.addEventListener ('message' , async ({ data }) => { const { id, action, payload } = data; try { let result; if (action === 'heavyCompute' ) { result = payload.data .reduce ((acc, val ) => acc + val, 0 ); } self.postMessage ({ id, result }); } catch (e) { self.postMessage ({ id, error : e.message }); } });
4. TypeScript로 타입 안전한 브릿지 만들기 메시지 타입을 명시적으로 정의하면 실수를 컴파일 타임에 잡을 수 있습니다.
type BridgeMessageMap = { GET_USER_INFO : { request : { userId : number }; response : { id : number ; name : string ; email : string }; }; CART_UPDATED : { request : { itemCount : number }; response : void ; }; OPEN_CAMERA : { request : { quality : 'low' | 'high' }; response : { uri : string }; }; }; type BridgeMessage <T extends keyof BridgeMessageMap > = { id : string ; type : T; payload : BridgeMessageMap [T]['request' ]; isResponse?: false ; }; type BridgeResponse <T extends keyof BridgeMessageMap > = { id : string ; type : T; payload : BridgeMessageMap [T]['response' ]; isResponse : true ; error?: string ; }; class TypedBridge { request<T extends keyof BridgeMessageMap >( type : T, payload : BridgeMessageMap [T]['request' ] ): Promise <BridgeMessageMap [T]['response' ]> { } on<T extends keyof BridgeMessageMap >( type : T, handler : (payload: BridgeMessageMap[T]['request' ] ) => Promise <BridgeMessageMap [T]['response' ]> ): void { } } const bridge = new TypedBridge ();bridge.request ('GET_USER_INFO' , { userId : 1 }); bridge.request ('GET_USER_INFO' , { userId : 'wrong' });
5. 브릿지 설계 시 고려사항
항목
권장 사항
메시지 식별
요청마다 고유 id (UUID) 부여하여 응답 매핑
타임아웃
응답 없는 요청에 타임아웃 설정 (메모리 누수 방지)
출처 검증
event.origin 또는 호출 환경 검증 필수
직렬화
메시지는 반드시 JSON 직렬화 가능한 값만 사용
에러 전파
핸들러 예외를 catch하여 요청 측에 error 필드로 전달
타입 정의
TypeScript로 메시지 타입을 명시적으로 정의
메모리 관리
이벤트 리스너 등록/해제를 명확히 관리
요약
브릿지는 postMessage와 같은 저수준 통신 API 위에 요청-응답 추상화 레이어를 제공한다.
요청마다 고유 id를 부여하고 Map으로 pending 요청을 관리하면 Promise 기반 API로 구현할 수 있다.
iframe 브릿지는 event.origin 검증, WebView 브릿지는 플랫폼별 API(ReactNativeWebView, webkit.messageHandlers)를 사용한다.
TypeScript의 제네릭 타입 맵으로 메시지 타입을 정의하면 컴파일 타임에 타입 안전성을 확보할 수 있다.
타임아웃 처리와 에러 전파를 반드시 구현해 메모리 누수와 무한 대기를 방지한다.