Android 하이브리드 앱은 네이티브 레이어(Kotlin/Java)와 웹 레이어(JavaScript)가 공존합니다. 이 두 레이어는 서로 다른 실행 환경에서 동작하기 때문에, 직접 함수를 호출하거나 변수를 공유할 수 없습니다. 브릿지(Bridge) 는 이 둘을 연결하는 통신 채널입니다.
예를 들어 웹에서 “카메라를 열어줘”라고 요청하면 네이티브가 처리하고 결과를 돌려주는 방식입니다. 브라우저의 fetch처럼 비동기 Promise 형태로 쓸 수 있게 추상화하는 것이 목표입니다.
// "AndroidBridge"라는 이름으로 JS 인터페이스 등록 // 웹에서 window.AndroidBridge.xxx() 로 접근 가능 webView.addJavascriptInterface( AndroidBridge(this, webView), "AndroidBridge" )
webView.loadUrl("https://my-webapp.com") } }
브릿지 이름 커스터마이징
안드로이드 앱에서 브릿지 등록
addJavascriptInterface()의 두 번째 인자 문자열이 JS에서 window 아래 객체 이름을 결정합니다. Kotlin 클래스명과는 무관하며, 이 문자열만 바꾸면 됩니다.
// "NativeBridge"라는 이름으로 등록하면 JS에서 window.NativeBridge로 접근 webView.addJavascriptInterface( AndroidBridge(this, webView), // 클래스명은 자유롭게 "NativeBridge"// ← 이 문자열이 window 아래 이름이 됨 )
웹에서 안드로이드 앱에 등록된 브릿지 이름 사용
JS 쪽에서도 등록한 이름과 일치시켜야 합니다.
// window.NativeBridge로 접근 if (!window.NativeBridge) { reject(newError('NativeBridge is not available')); return; }
보안 섹션의 removeJavascriptInterface("AndroidBridge") 같은 호출도 등록 시 사용한 이름과 일치해야 합니다.
네이티브 측: 브릿지 클래스
@JavascriptInterface 어노테이션이 붙은 메서드만 웹에서 호출할 수 있습니다. 어노테이션이 없는 메서드는 API 17+에서 보안상 자동으로 차단됩니다.
주의할 점은 @JavascriptInterface 메서드가 백그라운드 스레드에서 호출된다는 것입니다. 따라서 UI 조작이나 webView.evaluateJavascript() 같은 메인 스레드 작업은 반드시 withContext(Dispatchers.Main)으로 전환해야 합니다.
@JavascriptInterface funpostMessage(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 response = JSONObject() try { val result = handleAction(action, params) response.put("result", result) } catch (e: Exception) { response.put("error", e.message) }
// evaluateJavascript는 반드시 Main 스레드에서 호출해야 함 withContext(Dispatchers.Main) { webView.evaluateJavascript( "window.__bridge_${id}('${response}')", null ) } } }
// 페이지 이탈 등으로 모든 pending 요청을 일괄 정리할 때 사용 cancelAll() { for (const { timerId } ofthis.#pendingRequests.values()) { clearTimeout(timerId); } this.#pendingRequests.clear();
// window에 남아있는 콜백 함수들도 모두 제거 for (const key ofObject.keys(window)) { if (key.startsWith('__bridge_')) deletewindow[key]; } } }
프로덕션 코드에서는 CoroutineScope를 매번 새로 만들지 않고, 클래스 레벨에서 관리합니다. SupervisorJob()을 사용하면 하나의 액션이 실패해도 다른 요청들에 영향을 주지 않습니다.
// AndroidBridge.kt classAndroidBridge( privateval context: Context, privateval webView: WebView ) { // SupervisorJob: 자식 코루틴 하나가 실패해도 다른 코루틴은 계속 실행 privateval scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@JavascriptInterface funpostMessage(jsonStr: String) { scope.launch { val response = JSONObject() var id = ""
try { val json = JSONObject(jsonStr) id = json.getString("id") val action = json.getString("action") val params = json.optJSONObject("params") ?: JSONObject()
val result = handleAction(action, params) response.put("result", result) } catch (e: Exception) { response.put("error", e.message ?: "Unknown error") }
if (id.isNotEmpty()) { sendToWeb(id, response) } } }
privatesuspendfunsendToWeb(id: String, response: JSONObject) { withContext(Dispatchers.Main) { // response 내용에 작은따옴표가 있으면 JS 파싱 오류가 발생하므로 이스케이프 val escaped = response.toString().replace("'", "\\'") webView.evaluateJavascript( "window.__bridge_${id}('$escaped')", null ) } }
privatefungetStorageItem(key: String): String? { // 웹의 localStorage는 WebView가 초기화될 때마다 초기화될 수 있으므로 // 중요한 데이터는 네이티브 SharedPreferences에 저장하는 것이 안전함 val prefs = context.getSharedPreferences("WebBridgeStore", Context.MODE_PRIVATE) return prefs.getString(key, null) }
// 네이티브가 evaluateJavascript로 이 함수를 호출함 // window에 직접 노출해서 네이티브가 접근할 수 있게 함 _dispatchEvent(eventName, jsonStr) { const payload = JSON.parse(jsonStr); const handlers = this.#eventHandlers.get(eventName); if (handlers) { handlers.forEach((handler) =>handler(payload)); } } }
const bridge = newAndroidBridge();
// 네이티브가 window.__nativeEvent('이벤트명', json)을 실행하면 // 해당 이벤트의 모든 구독자에게 전달됨 window.__nativeEvent = (eventName, jsonStr) => { bridge._dispatchEvent(eventName, jsonStr); };
// 사용 예시 ---
// 네트워크 상태 감지 const unsubscribe = bridge.on('networkStatus', ({ isConnected, type }) => { console.log('네트워크 상태:', isConnected ? `연결됨 (${type})` : '끊김'); });
// 푸시 알림 수신 bridge.on('pushNotification', ({ title, body, data }) => { showNotificationBanner(title, body); });
} catch (err) { // 사용자가 선택 화면을 취소한 경우는 에러 처리 불필요 if (err.message.includes('cancelled')) return; showErrorToast('사진 업로드에 실패했습니다.'); console.error(err); } finally { uploadBtn.disabled = false; } }
Kotlin 쪽 readFileAsBase64 액션 처리 예시입니다.
"readFileAsBase64" -> { val path = params.getString("path") val bytes = File(path).readBytes() val base64 = Base64.encodeToString(bytes, Base64.DEFAULT) JSONObject().apply { put("base64", base64) put("size", bytes.size) } }
예제 3: 위치 기반 서비스
위치 정보는 두 가지 패턴으로 사용합니다.
1회 조회: call('getCurrentLocation') → 현재 위치를 한 번만 가져옴
실시간 추적: on('locationUpdate', handler) → 이동할 때마다 네이티브가 이벤트 푸시
classLocationService { #watchId = null;
// 현재 위치 1회 조회 (배달 주소 자동완성, 주변 가게 검색 등) asyncgetCurrentPosition() { return bridge.call('getCurrentLocation', { accuracy: 'high', // 'low' | 'balanced' | 'high' → GPS 정밀도 vs 배터리 소모 timeout: 15000, }); }
// 실시간 위치 추적 시작 (내비게이션, 라이브 배달 추적 등) // minDistance: 최소 이동 거리(m)마다 이벤트 발생 → 너무 자주 발생 방지 startWatching(callback) { const unsubscribe = bridge.on('locationUpdate', callback); bridge.call('startLocationWatch', { minDistance: 10 }); this.#watchId = unsubscribe; }
// webView.post(): 웹뷰가 아직 로드 중일 수 있으므로 // 메인 루퍼 큐에 넣어 로드 완료 후 실행되도록 함 webView.post { emitter.emit("deepLink", JSONObject().apply { put("path", path) put("params", params) put("scheme", data.scheme) }) } } }
overridefunshouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { val host = request.url.host ?: returntrue returnif (host in allowedHosts) { false// WebView가 직접 로드 허용 } else { // 허용되지 않은 URL은 외부 브라우저로 열기 startActivity(Intent(Intent.ACTION_VIEW, request.url)) true } }
overridefunonPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) { val host = Uri.parse(url).host if (host !in allowedHosts) { // 비허용 URL이 로드되기 시작하면 즉시 브릿지 제거 view.removeJavascriptInterface("AndroidBridge") } } }
evaluateJavascript 이스케이프
네이티브가 웹으로 응답을 내려보낼 때, 사용자 입력이 포함된 문자열에 작은따옴표(')나 역슬래시가 있으면 JS 문법 오류 또는 XSS가 발생합니다.
// 위험: response에 {"name": "O'Brien"} 같은 값이 있으면 JS 파싱 실패 webView.evaluateJavascript("window.__bridge_${id}('${response}')", null)
// 안전: Base64로 인코딩하면 특수문자 문제를 완전히 피할 수 있음 val encoded = Base64.encodeToString(response.toString().toByteArray(), Base64.NO_WRAP) webView.evaluateJavascript("window.__bridge_${id}(atob('$encoded'))", null)
8. 디버깅
Chrome DevTools로 WebView 디버깅
Android WebView는 Chrome DevTools와 연결해서 일반 웹페이지처럼 디버깅할 수 있습니다. 개발 빌드에서만 활성화해야 합니다.
if (BuildConfig.DEBUG) { WebView.setWebContentsDebuggingEnabled(true) }
활성화 후 PC Chrome에서 chrome://inspect 에 접속하면 연결된 Android 기기의 WebView 목록이 표시됩니다. inspect 버튼을 클릭하면 일반 웹사이트와 동일하게 콘솔, 네트워크, 소스 탭을 사용할 수 있습니다.
브릿지 메시지 로깅
브릿지를 통해 오가는 모든 메시지를 Logcat에 기록해두면 어느 시점에 어떤 메시지가 오갔는지 추적하기 쉽습니다.
@JavascriptInterface funpostMessage(jsonStr: String) { if (BuildConfig.DEBUG) { Log.d("AndroidBridge", "← Web: $jsonStr") } // ... 처리 }
privatesuspendfunsendToWeb(id: String, response: JSONObject) { if (BuildConfig.DEBUG) { Log.d("AndroidBridge", "→ Web [$id]: $response") } withContext(Dispatchers.Main) { webView.evaluateJavascript("window.__bridge_${id}('$response')", null) } }