저는 자동차 인포테인먼트에서 동작하는 어플리케이션의 웹뷰 기반 웹 어플리케이션을 개발하고 있습니다. 인포테인먼트(Infotainment)란 정보(Information)와 오락(Entertainment)의 합성어 이며, 아직 생소하게 느껴질 수 있지만 요즘 차량에서는 필수로 볼 수 있는 디스플레이 이자 콘텐츠를 제공하는 시스템입니다.

초기에는 내비게이션만 제공하던 것과 달리, 이제는 영상, 게임, 소셜 미디어까지 콘텐츠가 확장되면서 시장의 요구에 맞춰 다양한 플랫폼을 지원하게 되었습니다.
여기서 웹뷰는 왜 등장했을까요? 예전의 인포테인먼트는 각각의 차량제조사에서 시스템과 SW를 만들었습니다. 그 당시에는 제공하는 기능이 많지 않았고, 성능의 한계로 인터랙티브한 UI를 구현하기 어려웠으며, 펌웨어에 탑재되어 업데이트하려면 정비소에 방문해야 했습니다. 현재에는 하드웨어의 상향 평준화, 하드웨어와 애플리케이션 레이어를 분리함으로써 범용 OS(Android OS, Android Automotive OS, 리눅스 등)를 도입해 개발 생태계를 크게 확장했습니다. 이 흐름 속에서 웹뷰가 주목받기 시작했습니다. 웹 기술은 이미 성숙한 생태계와 풍부한 개발 인력을 갖추고 있었고, 네이티브 앱 대비 빠른 업데이트와 크로스플랫폼 대응이 가능했기 때문입니다.
웹뷰 개발에서 중요한 포인트는 Native App에서 제공하는 기능을 사용하기 위한 통신입니다. 일반적인 웹 애플리케이션과 달리, 브라우저 API만으로는 접근할 수 없는 네이티브 기능이나 정보가 필요한 경우가 있기 때문입니다. 예를 들어 갤러리 앱을 열어 이미지를 가져오는 것, 현재 설치된 앱의 버전 정보를 조회하는 것, 시스템에서 제공하는 차속, 연료 잔량 같은 차량 정보를 가져오는 것 등 여러가지 케이스가 있습니다.
물론 Native와 통신하기 위해서 브릿지 방식만 있는 것은 아닙니다. 브라우저 표준인 window.postMessage도 있고, 안드로이드의 경우 URL Scheme 인터셉트를 통해서 특정 URL로 나가는 redirect 를 catch 하여 기능을 실행할 수도 있습니다. 다만 postMessage 방식은 네이티브에서 이를 수신하기 위한 addWebMessageListener API가 Android OS 버전이 같아도 디바이스에 설치된 WebView 버전에 따라 지원 여부가 달라지기 때문에, 원활한 크로스플랫폼 대응과 구버전 지원을 위해 브릿지 통신을 선택하였습니다.
안드로이드에서 JS와 Native간의 브릿지 통신은 이벤트 기반의 비동기 통신으로 구현하였습니다. 안드로이드쪽 Native 코드를 이해하기 쉽게 typescript로 대략적인 예시 코드를 구현하자면 다음과 같습니다.
class AndroidBridge {
// Web -> Native 시 window.AndroidBridge.sendMessage("...") 를 호출합니다.
private sendMessage(payload: string): void {
const data = JSON.parse(payload)
this.handleAction(data.action)
}
// Web으로 부터 받은 action 에 따라 실행될 콜백함수
private handleAction(action: string): void {
if(action === "GET_VOLUME") {
const volume = getVolume()
this.sendEventToWeb("RESPONSE_VOLUME", JSON.stringify({ level: volume }))
}
}
// Native → Web 방향 응답 시 window에서 발생한 CustomEvent를 받게됩니다.
private sendEventToWeb(eventName: string, payload: string): void {
window.dispatchEvent(new CustomEvent(eventName, {
detail: payload
}))
}
}
앱 초기화 시점에 웹뷰를 생성할 때, window 객체에 Bridge 인스턴스를 주입합니다. 그렇게 되면 다음과 같이 웹뷰에서 js를 통해 메세지를 주고 받을 수 있게 됩니다.
웹뷰에서 사용 시
// Web → Native 요청
window.AndroidBridge.sendMessage(JSON.stringify({
action: 'GET_VOLUME'
}))
// Native → Web 응답 수신
window.addEventListener('RESPONSE_GET_VOLUME', (e: CustomEvent) => {
const data = JSON.parse(e.detail)
console.log(data.level) // 50
})
안드로이드OS에서는 표준으로 제공하는 WebView Class가 있는 것과는 달리 리눅스에서는 다양한 방법으로 웹뷰 구축이 가능합니다. 그로 인하여 구현체마다 브릿지 통신 방식이 달라집니다.
대표적인 구현체는 CEF(Chromium Embedded Framework), WPE WebKit, QtWebEngine 가 있으며, 이 글에서는 리눅스 IVI 환경에서 가장 많이 사용되는 CEF 를 기준으로 설명합니다.
CEF는 Chromium 기반이기 때문에 렌더러 프로세스와 브라우저 프로세스가 분리되어 있습니다. JS는 렌더러 프로세스에서 실행되지만 IPC(Inter-Process Communication)를 거쳐 네이티브에 전달됩니다. 안드로이드의 addJavascriptInterface처럼 window에 객체를 직접 주입하는 방식이 아니라, cefQuery라는 함수를 통해 메시지를 라우팅합니다.
// Web → Native
window.cefQuery({
request: JSON.stringify({ action: 'GET_VOLUME' }),
onSuccess: (response) => {
const data = JSON.parse(response)
console.log(data.level)
},
onFailure: (code, message) => {
console.error(code, message)
}
})
// Native → Web
// sendMessage의 응답을 받기 위한 게 아닌,
// 네이티브가 먼저 푸시하는 이벤트를 구독하기 위한 용도
window.addEventListener('RESPONSE_GET_VOLUME', (e) => {
const data = JSON.parse(e.detail)
console.log(data.level)
})
첫번째로 공통 인터페이스를 정의합니다. message를 send 하는 기능이 있어야하며, 이벤트 기반으로 동작하기 때문에 listener를 추가하고 삭제하는 기능이 있어야합니다.
// bridge/BridgeInterface.ts
export interface BridgeResponse {
[key: string]: unknown
}
export interface BridgeMessage {
action: string
data?: unknown
}
export abstract class BridgeInterface {
abstract sendMessage(action: string, data?: unknown): Promise<BridgeResponse>
abstract on(eventName: string, callback: (e: CustomEvent) => void): void
abstract off(eventName: string, callback: (e: CustomEvent) => void): void
}
안드로이드와 리눅스에서 브릿지 구현을 보셨듯이, 구현 방식이 조금씩 다릅니다. 특히 리눅스에서는 CEF가 아닌 다른 웹뷰 구현체를 사용한다면 또 달라질 수 있습니다. 그렇기 때문에 Adapter 패턴을 사용하여 BridgeInterface 를 상속하는 Adapter들을 구현합니다.
// bridge/adapters/AndroidBridgeAdapter.ts
import { BridgeInterface, BridgeResponse } from '../BridgeInterface'
declare global {
interface Window {
AndroidBridge: {
sendMessage: (payload: string) => void
}
}
}
// 예외처리가 되지않은 간략하된 코드입니다. 흐름만 참고해주시기 바랍니다.
export class AndroidBridgeAdapter extends BridgeInterface {
sendMessage(action: string, data?: unknown): Promise<BridgeResponse> {
return new Promise((resolve) => {
const responseEvent = `RESPONSE_${action}`
const handler = (e: CustomEvent) => {
this.off(responseEvent, handler)
resolve(e.detail)
}
this.on(responseEvent, handler)
window.AndroidBridge.sendMessage(JSON.stringify({ action, data }))
})
}
on(eventName: string, callback: (e: CustomEvent) => void): void {
window.addEventListener(eventName, callback as EventListener)
}
off(eventName: string, callback: (e: CustomEvent) => void): void {
window.removeEventListener(eventName, callback as EventListener)
}
}
// bridge/adapters/CEFBridgeAdapter.ts
import { BridgeInterface, BridgeResponse } from '../BridgeInterface'
declare global {
interface Window {
cefQuery: (params: {
request: string
onSuccess: (response: string) => void
onFailure: (code: number, message: string) => void
}) => void
}
}
export class CEFBridgeAdapter extends BridgeInterface {
sendMessage(action: string, data?: unknown): Promise<BridgeResponse> {
return new Promise((resolve, reject) => {
window.cefQuery({
request: JSON.stringify({ action, data }),
onSuccess: (response) => resolve(JSON.parse(response)),
onFailure: (code, message) => reject({ code, message })
})
})
}
on(eventName: string, callback: (e: CustomEvent) => void): void {
window.addEventListener(eventName, callback as EventListener)
}
off(eventName: string, callback: (e: CustomEvent) => void): void {
window.removeEventListener(eventName, callback as EventListener)
}
}
이후 Factory 패턴을 사용하여 런타임에서 각 브릿지를 체크해 알맞은 Adapter를 반환합니다. 개발환경 지원을 위해서 MockBridge를 생성하는 것도 좋은 방법입니다.
// bridge/BridgeFactory.ts
class BridgeFactory {
static create(): BridgeInterface {
if (window.AndroidBridge) {
return new AndroidBridgeAdapter()
}
if (window.cefQuery) {
return new CEFBridgeAdapter()
}
console.warn('[BridgeFactory] No native bridge detected. Using MockBridge.')
return new MockBridgeAdapter()
}
}
사용 측면에서는 초기화 시점에 BridgeFactory.create() 를 통해서 런타임 상황에 맞는 bridge를 반환하고, bridge를 import하여 사용하면 됩니다.
어떤 구현체를 사용하는지 사용자는 알필요가 없기 때문에 플랫폼에 관계없이 항상 동일한 코드로 사용할 수 있습니다.
// bridge/index.ts
import { BridgeFactory } from './BridgeFactory'
export const bridge = BridgeFactory.create()
import { bridge } from '@/bridge'
// 전송
const volume = await bridge.sendMessage('GET_VOLUME')
console.log(volume.level) // 50
// 구독
const handleVolumeChange = (e: CustomEvent) => {
console.log(e.detail.level)
}
bridge.on('VOLUME_CHANGED', handleVolumeChange)
// 구독 해제
bridge.off('VOLUME_CHANGED', handleVolumeChange)
이벤트의 응답값이 너무 오래 걸리거나, 이벤트 실패를 대비해 timeout 기능을 추가하여 Blocking 을 막고 리스너가 쌓이지 않도록 cleanup 함수를 추가하였습니다.
sendMessage(action: string, data?: unknown, timeout = 5000): Promise<BridgeResponse> {
return new Promise((resolve, reject) => {
const responseEvent = `RESPONSE_${action}`
const cleanup = () => {
this.off(responseEvent, handler)
clearTimeout(timer)
}
const handler = (e: CustomEvent) => {
cleanup()
resolve(e.detail)
}
const timer = setTimeout(() => {
cleanup()
reject(new Error(`[Bridge] timeout: ${action}`))
}, timeout)
this.on(responseEvent, handler)
window.AndroidBridge.sendMessage(JSON.stringify({ action, data }))
})
}
단, 지금 구조라면 동일 action을 동시에 호출하는 경우 두 핸들러가 호출될 수 있습니다. 순서 제어를 사용하는 측에서 맡긴다는 전제하에 단순한 구조를 채택하였습니다. 필요하다면 Queue를 활용하거나 Native측과 협의하여 RequestId 필드를 추가하여 제어하는것을 권장드립니다.
React에서 해당 모듈을 사용하다 보면 페이지 이동 시 이전에 송신했던 이벤트의 응답이 뒤늦게 처리되는 문제가 있었습니다." 그로 인해 AndroidBridgeAdapter에 AbortController을 추가하여 모듈 외부에서 abort 할 수 있게 하였으며, 자연스럽게 초기화 시점에 BridgeFactory.create() 을 통해서 export 하기 때문에 싱글톤의 목적 또한 이룰 수 있었습니다.
// bridge/adapters/AndroidBridgeAdapter.ts
export class AndroidBridgeAdapter extends BridgeInterface {
private controller: AbortController = new AbortController()
sendMessage(action: string, data?: unknown, timeout = 5000): Promise<BridgeResponse> {
return new Promise((resolve, reject) => {
const responseEvent = `RESPONSE_${action}`
const signal = this.controller.signal
const cleanup = () => {
this.off(responseEvent, handler)
clearTimeout(timer)
signal.removeEventListener('abort', onAbort)
}
const handler = (e: CustomEvent) => {
cleanup()
resolve(e.detail)
}
const onAbort = () => {
cleanup()
reject(new DOMException(`[Bridge] aborted: ${action}`, 'AbortError'))
}
const timer = setTimeout(() => {
cleanup()
reject(new Error(`[Bridge] timeout: ${action}`))
}, timeout)
if (signal.aborted) {
cleanup()
reject(new DOMException(`[Bridge] aborted: ${action}`, 'AbortError'))
return
}
signal.addEventListener('abort', onAbort, { once: true })
this.on(responseEvent, handler)
window.AndroidBridge.sendMessage(JSON.stringify({ action, data }))
})
}
abort(): void {
this.controller.abort()
this.controller = new AbortController()
}
on(eventName: string, callback: (e: CustomEvent) => void): void {
window.addEventListener(eventName, callback as EventListener)
}
off(eventName: string, callback: (e: CustomEvent) => void): void {
window.removeEventListener(eventName, callback as EventListener)
}
}
// SomeComponent.tsx
useEffect(() => {
bridge.sendMessage('GET_VEHICLE_SPEED')
return () => bridge.abort() // 언마운트 시 자동 취소
}, [])
ES 모듈 특성상 BridgeFactory.create()를 통해 export한 인스턴스는 자동으로 공유되기 때문에, 별도의 싱글톤 구현 없이도 동일한 효과를 얻을 수 있었습니다.
// bridge/index.ts export const bridge = BridgeFactory.create()
비즈니스 로직 캡슐화를 통한 서비스 레이어 도입하였습니다. 현재에는 BridgeService로 명명하였지만 domain별로 service module을 생성하여 중복되는 코드를 줄이고, 각각의 비즈니스 로직을 추가할 수 있도록 분리하였습니다. 사용자는 상세한 이벤트명을 알 필요없이 추상화된 함수명과 리턴타입만 알면 사용할 수 있게 되었습니다.
class BridgeService {
async getVolume(): Promise<number> {
const res = await bridge.sendMessage('GET_VOLUME')
return res.level
}
async getMediaInfo(): Promise<MediaInfo> {
const res = await bridge.sendMessage('GET_MEDIA_INFO')
return res
}
}
이번 BridgeAdapter 를 통해서 JS Interface는 추상화 할 수 있었지만, 브릿지 통신에서 가장 중요한 것은 결국 네이티브 개발자와의 인터페이스 합의입니다. 요청 이벤트명, 응답 이벤트명, payload 구조, 모두 양측에서 일치해야 하기 때문에, 코드보다 커뮤니케이션이 먼저입니다.
그래도 추상화 레이어를 미리 구축해두면, 다음 프로젝트에서는 브릿지 통신 구조를 처음부터 고민할 필요 없이 가져갈 수 있고, 새로운 플랫폼을 지원해야한다면, 어댑터 하나만 추가해도 된다는 점 또한 장점이라고 할 수 있겠습니다.
끝으로, 기존에 React Hook과 Bridge 로직이 결합되어 있던 코드를 분리하면서 자연스럽게 순서 제어와 크로스 플랫폼 추상화를 고민하게 되었고, 그 과정을 이번 포스팅으로 정리해보았습니다. 부족한 설명이 많았음에도 끝까지 읽어주셔서 감사합니다.