
Android에서 WebView 기반 하이브리드 앱을 개발할 때, 네이티브와 웹 간의 통신은 JavaScript Bridge를 통해 이루어진다. 이 브릿지 통신은 앱의 핵심 기능을 담당하는 경우가 많지만, 디버깅 환경은 HTTP 통신에 비해 상당히 열악한 것이 현실이다.
HTTP 통신의 경우, Chucker와 같은 도구가 OkHttp Interceptor를 통해 모든 네트워크 트래픽을 자동으로 캡처하고, IDE 없이도 기기에서 직접 확인할 수 있는 UI를 제공한다. 반면 WebView 브릿지 통신은 이러한 전용 디버깅 도구가 존재하지 않아, 개발자가 Logcat에서 로그를 하나하나 검색해가며 확인해야 하는 불편함이 있었다.
실제 업무에서 하이브리드 앱을 개발하면서 이 문제를 반복적으로 겪었고, 특히 다음과 같은 상황에서 디버깅 효율이 크게 떨어졌다.
'Chucker가 HTTP 트래픽을 보여주듯, WebView 브릿지 통신도 기기에서 바로 확인할 수 있으면 좋겠다'는 생각에서 출발하여 Dari를 개발하게 되었다.
Dari(다리)는 WebView의 JavaScript Bridge 통신을 실시간으로 인스펙션하기 위한 Android 라이브러리이다.
왜 이름이 Dari인지는 한국인이라면 별도의 설명이 필요 없을 것이라 생각한다.
Chucker가 OkHttp Interceptor를 통해 HTTP 트래픽을 자동으로 캡처하고 시각화하듯, Dari는 브릿지 레이어에 인터셉터를 연결하여 Web ↔ App 간의 브릿지 메시지를 기록하고 시각화해준다.


Dari는 debug/release 빌드를 분리하여 의존성을 추가한다. Release 빌드에서는 dari-noop 모듈이 동일한 API surface를 제공하면서 실제 동작은 수행하지 않으므로, 프로덕션 앱에 어떠한 오버헤드도 발생하지 않는다.
dependencies {
debugImplementation("io.github.easyhooon:dari:<latest-version>")
releaseImplementation("io.github.easyhooon:dari-noop:<latest-version>")
}
초기화는 androidx.startup을 통해 앱 시작 시 자동으로 수행된다. 별도의 Application 클래스 수정이 필요하지 않다.
커스텀 설정이 필요한 경우에는 Application.onCreate()에서 직접 초기화할 수 있다.
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
Dari.init(
context = this,
config = DariConfig(
maxEntries = 1000, // 저장할 최대 메시지 수 (기본값: 500)
showNotification = true, // 알림 표시 여부 (기본값: true)
)
)
}
}
Dari의 핵심 동작 방식은 개발자가 브릿지 통신 코드에 인터셉터를 직접 주입하는 것이다. Chucker가 OkHttp의 표준화된 Interceptor 인터페이스 덕분에 한 줄 설정으로 자동 캡처가 가능한 반면, WebView 브릿지는 앱마다 구현 방식이 다르기 때문에 수동 주입이 불가피하다.
이 부분에 대한 개선 방향은 앞으로 해결해야할 문제 파트에서 후술
import com.easyhooon.dari.Dari
import com.easyhooon.dari.interceptor.DariInterceptor
// Debug 빌드에서는 DefaultDariInterceptor, Release 빌드(noop)에서는 null 반환
val interceptor: DariInterceptor? = Dari.createInterceptor()
@JavascriptInterface 메서드에서 요청 수신 시점과 응답 완료 시점에 인터셉터를 호출한다.
// JavaScript에서 요청이 들어왔을 때
interceptor?.onWebToAppRequest(handlerName, requestId, data)
// 처리 완료 후 응답 로깅
interceptor?.onWebToAppResponse(handlerName, requestId, responseData, isSuccess)
네이티브 코드에서 JavaScript로 메시지를 보내는 시점과 웹 응답 수신 시점에 호출한다.
// JavaScript로 메시지 전송 시
interceptor?.onAppToWebMessage(handlerName, requestId, data)
// 웹 응답 수신 시
interceptor?.onAppToWebResponse(requestId, isSuccess, responseData)
인터셉터가 nullable 타입이므로 safe call(?.)을 사용하면 되고, Release 빌드에서는 createInterceptor()가 null을 반환하기 때문에 호출 자체가 무시된다.
Dari는 현재 v1.1.1로, 핵심적인 브릿지 메시지 캡처 및 시각화 기능은 동작하지만, 실제 다양한 프로덕션 환경에 적용하기 위해서는 몇 가지 개선이 필요하다. 현재 GitHub Issues에 등록된 주요 과제는 다음과 같다.
현재 DariInterceptor의 모든 메서드는 requestId: String을 필수 파라미터로 요구한다.
requestId는 Request와 Response를 매칭하기 위한 식별자인데, 모든 브릿지 통신이 이러한 쌍을 이루는 것은 아니다. 예를 들어 fire-and-forget 방식의 단방향 메시지에는 requestId 자체가 불필요하다.
따라서 requestId 파라미터를 강제하는 것은 라이브러리 사용자에게 불필요한 제약을 부여하고 다양한 통신 패턴으로의 확장성을 저하시키므로, 선택적 파라미터로 변경할 예정이다.
다만, 이 변경이 단순하지 않은 이유는 requestId가 내부적으로 여러 곳에서 사용되고 있기 때문이다.
MessageRepository.updateEntry(requestId) — 기존 항목을 찾아 Response 데이터를 연결DariActivity의 LazyColumn — 리스트 아이템의 key로 사용DariDetailActivity — Intent extra를 통해 항목을 조회requestId를 optional로 변경할 경우, requestId가 null인 항목에 대해서는 내부적으로 UUID를 자동 생성하여 리스트 key와 항목 조회에 활용하고, Request-Response 매칭은 건너뛰어 독립적인 항목으로 처리하는 전략이 필요하다.
이는
DariInterceptor인터페이스의 4개 메서드 시그니처가 모두 변경되는 breaking change이므로, 1.2.0 버전에서 반영할 예정이다.
앞서 언급했듯, 현재 Dari는 브릿지 통신 코드의 각 지점에 인터셉터 호출을 수동으로 삽입해야 한다. Chucker가 OkHttp의 Interceptor 인터페이스 덕분에 한 줄 설정으로 모든 HTTP 트래픽을 자동 캡처할 수 있는 것과는 대조적이다.
// Chucker: 한 번의 설정으로 모든 HTTP 트래픽을 자동 캡처
val client = OkHttpClient.Builder()
.addInterceptor(ChuckerInterceptor(context))
.build()
// Dari: 각 브릿지 호출 지점마다 수동 주입 필요
interceptor?.onWebToAppRequest(handlerName, requestId, data)
// ... 처리 ...
interceptor?.onWebToAppResponse(handlerName, requestId, responseData, isSuccess)
WebView 브릿지에서 자동 인터셉션이 어려운 근본적인 이유는, HTTP와 달리 표준화된 프로토콜이 없다는 점이다.
@JavascriptInterface, shouldOverrideUrlLoading, onJsPrompt 등 다양한 메커니즘이 존재현재 검토 중인 부분 자동화 접근 방식은 다음과 같다.
evaluateJavascript를 오버라이드하여 App-to-Web 메시지를 자동 캡처하는 DariWebView를 제공하는 방법이다.
class DariWebView(context: Context) : WebView(context) {
override fun evaluateJavascript(script: String, callback: ValueCallback<String>?) {
// script는 raw JavaScript 문자열 — handlerName, requestId 등을 추출하려면 파싱이 필요하나
// 앱마다 JS 호출 형태가 달라 범용적인 파싱이 어려움
interceptor?.onAppToWebMessage(handlerName, requestId, data)
super.evaluateJavascript(script) { result ->
interceptor?.onAppToWebResponse(requestId, isSuccess, result)
callback?.onReceiveValue(result)
}
}
}
다만 임의의 JavaScript 문자열을 파싱하는 것은 신뢰성이 낮고, App-to-Web 방향만 커버할 수 있다는 한계가 있다.
무엇보다, 사용자 입장에서 Android SDK가 제공하는 표준 WebView 대신 서드파티 라이브러리의 커스텀 WebView(DariWebView)를 사용해야 한다는 점 자체가 상당한 진입 장벽이 될 수 있다.
디버깅 도구를 위해 앱의 핵심 컴포넌트를 교체하는 것은, 오픈소스 라이브러리 도입에 열려있는 개발자라 하더라도 쉽게 수용하기 어려운 부분이기 때문이다.
@JavascriptInterface 메서드에 대해 컴파일 타임에 인터셉터 호출 코드를 자동 생성하는 방법이다.
// 개발자가 작성하는 코드
@DariIntercept
class MyBridge {
@JavascriptInterface
fun getAppInfo(data: String): String { ... }
}
// KSP가 자동 생성하는 코드 (개념)
class MyBridge_DariProxy(private val original: MyBridge) {
@JavascriptInterface
fun getAppInfo(data: String): String {
interceptor?.onWebToAppRequest("getAppInfo", data)
val result = original.getAppInfo(data)
interceptor?.onWebToAppResponse("getAppInfo", result)
return result
}
}
다만 라이브러리 측에서의 구현 복잡도가 높고, @JavascriptInterface 패턴만 커버할 수 있다.
개발자의 @JavascriptInterface 객체를 동적 프록시로 감싸 모든 호출을 인터셉트하는 방법이다.
// 한 줄 설정으로 기존 브릿지 객체를 래핑
val bridge = Dari.wrap(MyBridge())
webView.addJavascriptInterface(bridge, "Android")
// 내부적으로 Proxy를 통해 모든 @JavascriptInterface 호출을 인터셉트
// Proxy.newProxyInstance(...)를 활용한 리플렉션 기반 구현
이는 메서드 시그니처에 대한 컨벤션이 필요하고 리플렉션 기반이라는 단점이 있다.
부분적인 자동화라도 가치가 있는지, 어떤 브릿지 패턴이 가장 보편적인지에 대한 피드백을 바탕으로 방향을 결정할 계획이다.
현재 Dari는 앱 프로세스 내 단일 MessageRepository 싱글톤에 모든 브릿지 메시지를 저장한다. Single Activity + Single WebView 구성에서는 문제가 없지만, 다음과 같은 멀티 브릿지 환경에서는 메시지의 출처를 구분할 수 없다.
ActivityA WebView → "getAppInfo" (req_1) ──┐
├──→ Dari.repository (모두 혼합)
ActivityB WebView → "getAppInfo" (req_2) ──┘
프로덕션 앱에서는 Multi-Activity 환경에서의 개별 WebView, Single Activity 내 다중 WebView(탭 기반 UI), Fragment 기반 ViewPager에서의 다중 WebView 등 멀티 브릿지 시나리오가 빈번하게 발생한다.
Dari의 인터셉터와 리포지토리는 정상적으로 동작하지만(인터셉터는 stateless, 리포지토리는 thread-safe singleton), 메시지 출처 구분이 불가능하면 디버깅 경험이 크게 저하된다.
이를 해결하기 위해 MessageEntry에 tag 필드를 추가하고, 인터셉터 생성 시점에 태그를 바인딩하는 방식을 검토하고 있다.
// 인터셉터 생성 시 tag 바인딩
val interceptor = Dari.createInterceptor(tag = "PaymentWebView")
이 방식은 기존 DariInterceptor 인터페이스의 메서드 시그니처를 변경하지 않으면서도, 각 인터셉터 인스턴스에 식별자를 부여할 수 있다.
tag는 optional(String?)이고 기본값이 null이므로 기존 사용자에게 breaking change가 발생하지 않아 minor 버전 업데이트로 대응이 가능하다.
UI에서는 각 메시지 항목에 tag를 chip 또는 badge 형태로 표시하고, tag 기반 필터링 기능을 추가할 예정이다.
Dari의 개발 배경과 주요 기능, 그리고 앞으로 해결해야 할 과제들에 대해 정리해보았다.
requestId optional화, 자동 인터셉션, 멀티 브릿지 환경 지원 등 아직 개선해야 할 부분이 남아있지만, 하나씩 해결해 나갈 예정이다.
아직 초기 버전이라 부족한 부분이 많을 수 있다. 개선사항이나 피드백은 GitHub Issues를 통해 언제든 환영합니다. ㅎㅅㅎ
reference)
https://github.com/easyhooon/dari
https://github.com/ChuckerTeam/chucker