배경
최근에 웹뷰 앱을 만들게 되면서 앱과 웹 간 통신을 구현해야하는 일들이 있었습니다. 하지만 웹과 앱 통신은 window.ReactNativeWebView 라는 객체를 통신하는데 다음과 같은 문제점들이 있었습니다.
기존 통신 방식의 문제점
1. 반복적이고 명시적인 코드
- 웹에서 앱으로 메시지를 전송할 때마다 다음과 같은 코드를 작성해야 했습니다
window.ReactNativeWebView.postMessage( JSON.stringify({ type: MESSAGE_TYPES.OPEN_EXTERNAL_BROWSER, url, }) );
2. 단일 채널에서의 모든 이벤트 처리
- 또한 메세지들을 웹에서 한 채널에서 모든 이벤트들을 핸들링 해야합니다.
const handleWebViewMessage = useCallback( (event: MessageEvent) => { const { type, data } = JSON.parse(event.data); if (type === MESSAGE_TYPES.AUTH_SUCCESS) { handleOAuthCallback() } else if (type === MESSAGE_TYPES.AUTH_ERROR) { ... } }, [handleOAuthCallback] ); window.addEventListener('message', handleWebViewMessage)
기존 방식의 한계
이는 다음과 같은 문제들이 있었습니다.
명시적이고 반복적인 코드
- window.ReactNativeWebView와 같은 플랫폼별 객체 직접 사용
- 동일한 패턴의 코드가 여러 컴포넌트에서 중복 발생
타입 추론의 어려움
- 이벤트 이름 오타 시 컴파일 단계에서 감지 불가능
- 전달받는 payload의 타입을 런타임에서만 확인 가능
설계 목표
그래서 이러한 문제들을 해결하기 위해 다음과 같은 목표를 가진 bridge 패키지를 만들고자 하였습니다.
- 추상화: postMessage와 addEventListener 로직을 캡슐화
- 타입 안전성: TypeScript를 활용한 payload 타입 자동 추론
- 이벤트 기반 아키텍처: 이벤트 이름에 따른 콜백 함수 자동 실행
- 컴파일 시점 검증: 잘못된 이벤트명 사전 차단
- 중앙 집중 관리: 싱글톤 패턴을 통한 이벤트 핸들러 통합 관리
핵심은 추상화를 통한 DX 개선입니다. 복잡한 통신 로직을 숨기고 간단한 API를 제공하여 팀 전체의 개발 효율성을 높이는 것이 주요 목표였습니다.
아키텍쳐 아이디어
이때 이벤트 처리 메커니즘은 OS의 ISR Vector Table에 영감을 받았습니다

동작원리는 다음과 같습니다.
- 프로그램에서 인터럽트 발생
- Vector Table에서 해당 인터럽트에 대응하는 ISR 탐색
- ISR 주소로 이동하여 인터럽트 처리 루틴 실행
이와 비슷하게 저희 bridge 패키지도 다음과 같은 방식으로 만들게 되었습니다.

- addEventListener로 이벤트이름과 콜백 함수를 전달
- WebBridge에서 이벤트를 globalEventListeners에 등록
- globalEventListener는 Vector Table 역할
- AppBridge의 send에서 이벤트이름에 해당하는곳에 payload전달
- 한 채널에서 수행하기 위한 globalMessageHandler에서 이벤트 이름에 해당하는 콜백 함수 실행
이런 역할을 수행하기 위한 곳은 Bridge라는 한 인터페이스에서 수행됩니다. 이 인터페이스는 웹에 모든 컴포넌트에서 동일한 인스턴스를 사용해야하기 때문에 싱글톤 패턴을 선택하게 되었습니다.
실제 구현
실제 구현은 다음과 같이 되어있습니다.
- Bridge 레이어: 중앙에서 관리하는 bridge 레이어 생성, 공통된 API를 사용
- 싱글톤 패턴: 모든 컴포넌트가 동일한 Bridge 인스턴스에 접근하여 eventHandlers 공유
- Map 기반 이벤트 테이블: Vector Table 역할을 수행하는 Map 객체로 이벤트 핸들러 관리
1. 싱글톤 패턴 구현
- 클로저를 이용한 싱글톤 패턴으로 전역에서 하나의 Bridge 인스턴스만 생성 보장
export const createWebBridge = (() => { let bridgeInstance: BridgeInstance | null = null let isGlobalListenerRegistered = false // 이벤트 리스너들을 저장할 Map (Vector Table 역할) const globalEventListeners = new Map< string, (( event: PostMessageSchemaObject[keyof PostMessageSchemaObject]['payload'], ) => void)[] >() return () => { if (bridgeInstance) { return bridgeInstance // 기존 인스턴스 반환 } bridgeInstance = createBridgeInstance() // 없으면 새 인스턴스 생성 return bridgeInstance } })()
2. 전역 메시지 핸들러
- 단일 채널에서 모든 이벤트를 처리하는 중앙 핸들러
const globalMessageHandler = (event: MessageEvent) => { const message = handleMessage(event.data) if (message) { // Vector Table에서 해당 이벤트의 핸들러들 찾기 const listeners = globalEventListeners.get(message.eventName) if (listeners) { // 해당하는 모든 핸들러 실행 listeners.forEach((listener) => listener(message.payload)) } } }
3. API 타입 안정성
- 제네릭 타입을 활용한 send, addEventListener 타입 안정성 확보
const send = <K extends keyof T>( eventName: K, payload: T[K]['payload'], ): void => { const message: PostMessageEvent<T> = { eventName, payload } window.ReactNativeWebView!.postMessage(JSON.stringify(message)) } // 콜백 함수의 매개변수 타입이 자동 추론됨 const addEventListener = <K extends keyof T>( eventName: K, callback: (payload: T[K]['payload']) => void, ) => { ...
사용 예시
1. addEventListener
- 사용시에도 간단하게 다음과 같이 사용할 수 있습니다.
export const useBridgeEvent = <T extends keyof BridgeMessageSchema>( eventType: T, callback: (payload: BridgeMessageSchema[T]['payload']) => void, deps: DependencyList = [], ) => { const { bridge } = useBridge() useEffect(() => { const unsubscribe = bridge.addEventListener(eventType, callback) return () => unsubscribe() }, [eventType, callback, ...deps]) }
- 전달된 핸들러 콜백 함수는 payload 타입을 추론합니다.
// 로그아웃 브릿지 이벤트 핸들러 useBridgeEvent( POST_MESSAGE_EVENT.IMAGE_SELECTED, (payload) => { // payload를 ImageData 타입으로 자동 추론 const newImages = data.imageDataList .filter( (imageData: BridgeImageData) => imageData.base64 && imageData.createdAt && imageData.fileName, ) .map((imageData: BridgeImageData) => ({ fileName: imageData.fileName, )
2. send
- 간단하게 postMessage를 추상화
bridge.send('OPEN_CAMERA', { message: 'Open camera' })
Before/After 비교
1. 이벤트 리스너 (Before/After)
// Before: 기존 방식 const handleMessage = useCallback((event: MessageEvent) => { const { type, data } = JSON.parse(event.data); // 타입 추론 안됨, if-else 체인 필요 if (type === 'AUTH_SUCCESS') { handleAuth(data); // data 타입 unknown } else if (type === 'CAMERA_RESULT') { handleCamera(data); // data 타입 unknown } }, []); // After: Bridge 사용 useBridgeEvent('CAMERA_SUCCESS', (payload) => { // payload 타입 자동 추론, 안전함 console.log(payload.imageList) // 자동완성 지원 })
2. 메시지 전송 (Before/After)
// Before: 매번 직접 JSON.stringify + 타입 정보 없음 window.ReactNativeWebView.postMessage( JSON.stringify({ type: 'CAMERA_OPEN', data: { quality: 0.8, allowsEditing: true } }) ); // 타입 실수 가능 window.ReactNativeWebView.postMessage( JSON.stringify({ type: 'CAMREA_OPEN', // 오타 발생 가능 data: { quality: "high" } // 잘못된 타입 }) ); // After: 타입 안전 + 간결함 bridge.send('CAMERA_OPEN', { quality: 0.8, allowsEditing: true }); // 컴파일 시점에 오류 발견 bridge.send('CAMREA_OPEN', payload); // 타입 에러 bridge.send('CAMERA_OPEN', { quality: "high" }); // 타입 에러
개선할 점과 회고
처음 개발부터 완벽했던것은 아니였습니다. 개발 과정에서 발견한 몇 가지 아쉬운 점들이 있습니다
- 웹, 앱 타입을 공유하기 위해 package안에 있는 타입 파일 수정 필요
- 이건 웹 bridge, 앱 bridge를 공유하기 위한 장치가 필요할 것 같다.
- WeakMap?
- 이벤트명을 문자열로 key로 두고싶어서 Map선택
- 사실 Symbol로 해결할 수 있지 않았을까?
- WeakMap으로 하면 자동 메모리 정리 가능
- 싱글톤의 유명한 동시성 문제 고려 X
- 메세지큐나 세마포어로 해결?
- any타입 사용
- 이런식으로 any 타입을 사용하여 이벤트 리스너에서 타입 추론 어려움
- 잘못 전달해도 에러가 없다보니 undefined였던 경우가 많았다.
const eventListeners = new Map<string, (event: any) => void>()
const globalEventListeners = new Map< string, (( event: PostMessageSchemaObject[keyof PostMessageSchemaObject]['payload'], ) => void)[] >()
결론
처음에는 단순히 명시적이던 코드들을 추상화하여 개선하려던 작업이었는데, 결과적으로는 팀 전체의 WebView 통신 패턴을 표준화할 수 있었습니다. 이제 복잡한 코드 없이 bridge.send()와 useBridgeEvent()만 알면 누구든 바로 WebView 통신을 구현할 수 있습니다.
이 bridge 패키지를 통해 기존 대비 약 70% 코드 감소, 약 2000줄 이상 절약을 할 수 있었고, 런타임에서 타입 에러를 통해 안정적인 통신을 가능하게 하였습니다.
물론 아직 개선할 점들이 많습니다. 특히 웹과 앱 간 타입 공유 부분은 더 우아한 방법이 있을 것 같고, WeakMap이나 Symbol 활용도 고민해볼 만합니다.
사실 구현 후에는 "동시성은 어떻게 처리하지?", "Symbol이랑 같이 WeakMap을 써서 메모리 관리를 더 효율적으로 할까?", " 같은 생각들이 많았어요. 하지만 YAGNI(You Aren't Gonna Need It)를 떠올리며 일단 가장 단순한 버전으로 시작했습니다.
결과적으로 Map과 문자열 키를 사용하는 현재 구조만으로도 팀의 모든 요구사항을 충분히 만족시킬 수 있었습니다.
이번 브릿지 패키지를 구현하면서 DX 개선이 생각보다 까다롭다는 걸 느꼈지만, 동시에 복잡했던 WebView 통신 코드들이 간결해지는 모습을 보며 큰 재미를 느낄 수 있었습니다. 특히 정확한 타입 추론을 위해 제네릭을 활용하면서 TypeScript의 매력을 다시 한번 체감할 수 있었습니다.