JavaScript - 웹앱 브릿지 패턴 완벽 정리

브릿지(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하는 방식입니다.

// bridge.js (부모 페이지와 iframe 양쪽에서 공유)
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);
});
}

// 메시지 전송 후 응답을 Promise로 반환
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
);

// 타임아웃 처리 (5초)
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'
);

// iframe에 데이터 요청
bridge.request('GET_USER_INFO', { userId: 42 })
.then((user) => console.log('사용자 정보:', user))
.catch((err) => console.error(err));

// iframe에서 오는 이벤트 수신
bridge.on('CART_UPDATED', (payload) => {
console.log('장바구니 변경:', payload);
});
});

iframe 내부에서 사용

const bridge = new IframeBridge(window.parent, 'https://parent-domain.com');

// 부모가 요청한 'GET_USER_INFO' 처리
bridge.on('GET_USER_INFO', async ({ userId }) => {
const user = await fetchUser(userId); // 내부 API 호출
return user; // 반환값이 부모의 Promise로 전달됨
});

// 부모에게 이벤트 발송 (응답 불필요)
bridge.request('CART_UPDATED', { itemCount: 3 });

2. WebView 브릿지 (하이브리드 앱)

React Native, Cordova 등 하이브리드 앱에서는 네이티브 코드와 웹뷰(WebView) 간 통신에 브릿지를 사용합니다.
웹 측에서는 window.ReactNativeWebView.postMessage 또는 webkit.messageHandlers를 통해 네이티브로 메시지를 보냅니다.

React Native WebView 브릿지

// 웹 측 (WebView 안에서 실행되는 코드)
class ReactNativeBridge {
constructor() {
this.pendingRequests = new Map();

// 네이티브에서 웹으로 보내는 메시지 수신
window.addEventListener('message', (event) => {
try {
const message = JSON.parse(event.data);
this._handleNativeMessage(message);
} catch (e) {
// 파싱 실패 무시
}
});
}

// 네이티브로 메시지 전송 후 Promise 반환
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 });

// Android / iOS 공통 처리
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));
// 네이티브 측 (React Native)
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 웹 측 코드

// 웹 측 - Android WebView
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)

// AndroidBridge.kt
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()

// 백그라운드 스레드에서 실행되므로 Main 스레드로 전환
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)
}

// 웹으로 응답 전송 (반드시 Main 스레드에서 호출)
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")
}
}
}
// MainActivity.kt - WebView 설정
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

webView = WebView(this).apply {
settings.javaScriptEnabled = true

// JavaScript Interface 등록
// 두 번째 인자가 웹에서 접근할 window 객체 이름
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 웹 측 코드

// 웹 측 - iOS WKWebView
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)

// BridgeHandler.swift
import WebKit

class 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":
// CLLocationManager를 통해 위치 가져오는 로직
return ["lat": 37.5665, "lng": 126.9780]
default:
throw NSError(
domain: "BridgeError",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Unknown action: \(action)"]
)
}
}
}
// ViewController.swift - WKWebView 설정
class ViewController: UIViewController {

var webView: WKWebView!
let bridgeHandler = BridgeHandler()

override func viewDidLoad() {
super.viewDidLoad()

let config = WKWebViewConfiguration()
let contentController = WKUserContentController()

// 메시지 핸들러 등록 ("bridge" 이름으로 webkit.messageHandlers.bridge 접근 가능)
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를 호출하지 않으면 WKUserContentControllerBridgeHandler를 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로 분리할 때도 동일한 브릿지 패턴을 적용할 수 있습니다.

// main.js (메인 스레드)
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));
// worker.js (Worker 스레드)
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로 타입 안전한 브릿지 만들기

메시지 타입을 명시적으로 정의하면 실수를 컴파일 타임에 잡을 수 있습니다.

// bridge-types.ts
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 }); // OK
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의 제네릭 타입 맵으로 메시지 타입을 정의하면 컴파일 타임에 타입 안전성을 확보할 수 있다.
  • 타임아웃 처리와 에러 전파를 반드시 구현해 메모리 누수와 무한 대기를 방지한다.
Share