Android WebView 브릿지 통신 완벽 정리

개요

Android 하이브리드 앱은 네이티브 레이어(Kotlin/Java)와 웹 레이어(JavaScript)가 공존합니다. 이 두 레이어는 서로 다른 실행 환경에서 동작하기 때문에, 직접 함수를 호출하거나 변수를 공유할 수 없습니다. 브릿지(Bridge) 는 이 둘을 연결하는 통신 채널입니다.

예를 들어 웹에서 “카메라를 열어줘”라고 요청하면 네이티브가 처리하고 결과를 돌려주는 방식입니다. 브라우저의 fetch처럼 비동기 Promise 형태로 쓸 수 있게 추상화하는 것이 목표입니다.

통신 흐름

Android WebView 브릿지는 두 방향으로 통신합니다.

┌─────────────────────────────────────────────────────────┐
│ Android App │
│ │
│ ┌─────────────┐ ┌──────────────────────┐ │
│ │ WebView │ │ Native (Kotlin) │ │
│ │ │ ──────────▶ │ │ │
│ │ JavaScript │ postMessage │ @JavascriptInterface│ │
│ │ │ │ │ │
│ │ │ ◀────────── │ evaluateJavascript │ │
│ └─────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────┘

웹 → 네이티브: window.AndroidBridge.postMessage(json)
네이티브 → 웹: webView.evaluateJavascript("window.__cb(json)", null)
  • 웹 → 네이티브: Android가 addJavascriptInterface()로 주입한 객체를 JS에서 직접 호출합니다.
  • 네이티브 → 웹: 네이티브가 evaluateJavascript()로 JS 코드를 문자열로 실행합니다.

두 방향 모두 JSON 문자열을 메시지 포맷으로 사용하며, 복잡한 객체는 직렬화/역직렬화가 필요합니다.


1. 기본 구조

네이티브 측: JavascriptInterface 등록

네이티브 객체를 WebView에 주입하면, 웹에서 window.AndroidBridge.메서드명() 형태로 호출할 수 있게 됩니다. 두 번째 인자로 넘기는 문자열이 window 아래의 객체 이름이 됩니다.

// MainActivity.kt
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

webView = findViewById(R.id.webView)

webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
}

// "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(new Error('NativeBridge is not available'));
return;
}

window.NativeBridge.postMessage(JSON.stringify({ id, action, params }));
변경 대상 JS 노출 이름에 영향
addJavascriptInterface(obj, "이름") 두 번째 인자 영향 있음 — 이 문자열이 window.이름으로 노출됨
Kotlin 클래스명 (AndroidBridge) 영향 없음 — 내부 구현 이름일 뿐

보안 섹션의 removeJavascriptInterface("AndroidBridge") 같은 호출도 등록 시 사용한 이름과 일치해야 합니다.

네이티브 측: 브릿지 클래스

@JavascriptInterface 어노테이션이 붙은 메서드만 웹에서 호출할 수 있습니다. 어노테이션이 없는 메서드는 API 17+에서 보안상 자동으로 차단됩니다.

주의할 점은 @JavascriptInterface 메서드가 백그라운드 스레드에서 호출된다는 것입니다. 따라서 UI 조작이나 webView.evaluateJavascript() 같은 메인 스레드 작업은 반드시 withContext(Dispatchers.Main)으로 전환해야 합니다.

// 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()

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
)
}
}
}

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)
}
else -> throw IllegalArgumentException("Unknown action: $action")
}
}
}

웹 측: 기본 클라이언트

웹이 네이티브에 요청을 보낼 때의 문제는 응답이 언제 올지 모른다는 것입니다. postMessage()는 반환값이 없고, 응답은 별도 시점에 네이티브가 evaluateJavascript()로 JS를 실행해서 전달합니다.

이를 Promise로 감싸려면 요청마다 고유한 id를 만들고, 그 id를 이름으로 하는 전역 콜백 함수를 등록해두어야 합니다. 네이티브는 응답 시 해당 id의 콜백을 호출하고, 콜백 안에서 Promise를 resolve/reject합니다.

// bridge.js
class AndroidBridgeClient {
call(action, params = {}) {
return new Promise((resolve, reject) => {
// 1. 요청마다 고유 id 생성
const id = crypto.randomUUID();

// 2. 네이티브가 응답할 때 호출할 전역 콜백 등록
// window.__bridge_xxxxxxxx 형태로 등록됨
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;
}

// 3. 네이티브로 요청 전송
window.AndroidBridge.postMessage(
JSON.stringify({ id, action, params })
);
});
}
}

// 사용 예시
const bridge = new AndroidBridgeClient();

bridge.call('getDeviceInfo')
.then((info) => console.log('디바이스 정보:', info));

2. 프로덕션 수준 브릿지

기본 구조만으로는 실제 서비스에서 다음과 같은 문제가 생길 수 있습니다.

  • 타임아웃 없음: 네이티브가 응답하지 않으면 Promise가 영원히 pending 상태로 남아 메모리 누수 발생
  • 준비 시점 문제: 웹 코드가 실행될 때 window.AndroidBridge가 아직 주입되지 않았을 수 있음
  • 취소 불가: 페이지 이탈 시 pending 중인 요청들을 정리할 방법이 없음

아래 구현은 이 세 가지를 모두 처리합니다.

웹 측 (JavaScript)

class AndroidBridge {
#pendingRequests = new Map(); // id → { action, timerId }
#timeout;
#isReady = false;
#queue = []; // 브릿지 준비 전에 들어온 요청을 임시 저장

constructor({ timeout = 10000 } = {}) {
this.#timeout = timeout;
this.#checkReady();
}

// window.AndroidBridge가 주입되었는지 확인
// 웹뷰 초기화 타이밍에 따라 DOMContentLoaded 이후에 주입될 수도 있음
#checkReady() {
if (window.AndroidBridge) {
this.#isReady = true;
this.#flushQueue();
} else {
window.addEventListener('DOMContentLoaded', () => {
if (window.AndroidBridge) {
this.#isReady = true;
this.#flushQueue();
}
}, { once: true });
}
}

// 브릿지가 준비되면 쌓인 요청들을 순서대로 처리
#flushQueue() {
while (this.#queue.length > 0) {
const { action, params, resolve, reject, id } = this.#queue.shift();
this.#send(id, action, params, resolve, reject);
}
}

#send(id, action, params, resolve, reject) {
window[`__bridge_${id}`] = (jsonStr) => {
delete window[`__bridge_${id}`];
clearTimeout(timerId); // 응답이 왔으므로 타임아웃 취소
this.#pendingRequests.delete(id);

try {
const { result, error } = JSON.parse(jsonStr);
error ? reject(new Error(error)) : resolve(result);
} catch (e) {
reject(new Error(`Response parse error: ${e.message}`));
}
};

// 설정된 시간 안에 응답이 없으면 자동으로 reject
const timerId = setTimeout(() => {
if (window[`__bridge_${id}`]) {
delete window[`__bridge_${id}`];
this.#pendingRequests.delete(id);
reject(new Error(`Bridge timeout: ${action} (${this.#timeout}ms)`));
}
}, this.#timeout);

this.#pendingRequests.set(id, { action, timerId });

window.AndroidBridge.postMessage(
JSON.stringify({ id, action, params })
);
}

call(action, params = {}) {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();

if (!this.#isReady) {
// 브릿지가 아직 준비되지 않았으면 큐에 넣고 대기
this.#queue.push({ action, params, resolve, reject, id });
return;
}

if (!window.AndroidBridge) {
reject(new Error('AndroidBridge is not available'));
return;
}

this.#send(id, action, params, resolve, reject);
});
}

get pendingCount() {
return this.#pendingRequests.size;
}

// 페이지 이탈 등으로 모든 pending 요청을 일괄 정리할 때 사용
cancelAll() {
for (const { timerId } of this.#pendingRequests.values()) {
clearTimeout(timerId);
}
this.#pendingRequests.clear();

// window에 남아있는 콜백 함수들도 모두 제거
for (const key of Object.keys(window)) {
if (key.startsWith('__bridge_')) delete window[key];
}
}
}

export const bridge = new AndroidBridge({ timeout: 10000 });

네이티브 측 (Kotlin)

프로덕션 코드에서는 CoroutineScope를 매번 새로 만들지 않고, 클래스 레벨에서 관리합니다. SupervisorJob()을 사용하면 하나의 액션이 실패해도 다른 요청들에 영향을 주지 않습니다.

// AndroidBridge.kt
class AndroidBridge(
private val context: Context,
private val webView: WebView
) {
// SupervisorJob: 자식 코루틴 하나가 실패해도 다른 코루틴은 계속 실행
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

@JavascriptInterface
fun postMessage(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)
}
}
}

private suspend fun sendToWeb(id: String, response: JSONObject) {
withContext(Dispatchers.Main) {
// response 내용에 작은따옴표가 있으면 JS 파싱 오류가 발생하므로 이스케이프
val escaped = response.toString().replace("'", "\\'")
webView.evaluateJavascript(
"window.__bridge_${id}('$escaped')",
null
)
}
}

private suspend fun handleAction(action: String, params: JSONObject): Any? {
return when (action) {
"getDeviceInfo" -> getDeviceInfo()
"getStorageItem" -> getStorageItem(params.getString("key"))
"setStorageItem" -> setStorageItem(params.getString("key"), params.getString("value"))
"removeStorageItem" -> removeStorageItem(params.getString("key"))
"openCamera" -> openCamera(params)
"getCurrentLocation" -> getCurrentLocation()
"vibrate" -> vibrate(params.optLong("duration", 100))
"closeWebView" -> closeWebView()
else -> throw IllegalArgumentException("Unknown action: $action")
}
}

private fun getDeviceInfo(): JSONObject {
return JSONObject().apply {
put("os", "Android")
put("osVersion", Build.VERSION.RELEASE)
put("sdkVersion", Build.VERSION.SDK_INT)
put("model", Build.MODEL)
put("brand", Build.BRAND)
put("manufacturer", Build.MANUFACTURER)
put("appVersion", context.packageManager
.getPackageInfo(context.packageName, 0).versionName)
}
}

private fun getStorageItem(key: String): String? {
// 웹의 localStorage는 WebView가 초기화될 때마다 초기화될 수 있으므로
// 중요한 데이터는 네이티브 SharedPreferences에 저장하는 것이 안전함
val prefs = context.getSharedPreferences("WebBridgeStore", Context.MODE_PRIVATE)
return prefs.getString(key, null)
}

private fun setStorageItem(key: String, value: String): Boolean {
val prefs = context.getSharedPreferences("WebBridgeStore", Context.MODE_PRIVATE)
prefs.edit().putString(key, value).apply()
return true
}

private fun removeStorageItem(key: String): Boolean {
val prefs = context.getSharedPreferences("WebBridgeStore", Context.MODE_PRIVATE)
prefs.edit().remove(key).apply()
return true
}

private suspend fun openCamera(params: JSONObject): String {
// 실제 구현에서는 ActivityResultLauncher를 사용하고
// 결과를 Continuation이나 Channel로 코루틴에 전달해야 함
return withContext(Dispatchers.Main) {
"/storage/emulated/0/DCIM/photo_${System.currentTimeMillis()}.jpg"
}
}

private suspend fun getCurrentLocation(): JSONObject {
// 실제로는 FusedLocationProviderClient.getCurrentLocation()을
// suspendCancellableCoroutine으로 감싸서 사용
return JSONObject().apply {
put("lat", 37.5665)
put("lng", 126.9780)
put("accuracy", 10.0)
}
}

private fun vibrate(duration: Long): Boolean {
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(duration)
}
return true
}

private fun closeWebView(): Boolean {
(context as? Activity)?.finish()
return true
}

// Activity가 종료될 때 호출해서 코루틴 누수 방지
fun destroy() {
scope.cancel()
}
}

3. 네이티브 → 웹 단방향 이벤트 (Push)

지금까지 본 패턴은 웹이 먼저 요청하고 네이티브가 응답하는 요청-응답(Request-Response) 구조입니다.

하지만 다음과 같은 경우는 네이티브가 먼저 알림을 보내야 합니다.

  • 네트워크 연결이 끊어짐/복구됨
  • 푸시 알림을 수신함
  • 다른 앱에서 딥링크로 진입함
  • 백그라운드에서 작업이 완료됨

이런 경우 id 기반의 콜백 매핑 대신, 이벤트 이름으로 구독하는 pub/sub 패턴을 사용합니다.

웹 측: 이벤트 리스너 등록

on(eventName, handler)으로 이벤트를 구독하고, 반환된 함수를 호출하면 구독이 해제됩니다. 이 패턴은 React의 useEffect cleanup 함수와 자연스럽게 연결됩니다.

class AndroidBridge {
#eventHandlers = new Map(); // eventName → Set<handler>

// ... call() 메서드는 동일 (2절 참고)

on(eventName, handler) {
if (!this.#eventHandlers.has(eventName)) {
this.#eventHandlers.set(eventName, new Set());
}
this.#eventHandlers.get(eventName).add(handler);

// 구독 해제 함수를 반환 → const unsubscribe = bridge.on(...); unsubscribe();
return () => this.off(eventName, handler);
}

off(eventName, handler) {
this.#eventHandlers.get(eventName)?.delete(handler);
}

// 네이티브가 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 = new AndroidBridge();

// 네이티브가 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);
});

// 딥링크 진입
bridge.on('deepLink', ({ path, params }) => {
router.push(path, params);
});

// 특정 화면에서만 구독하고 나갈 때 해제
unsubscribe();

네이티브 측: 이벤트 푸시

네이티브에서 이벤트를 발생시킬 때는 evaluateJavascript()를 사용합니다. 이 메서드는 메인 스레드에서만 호출할 수 있으므로, 백그라운드 스레드에서 호출할 경우 Handler나 코루틴으로 스레드를 전환해야 합니다.

// BridgeEventEmitter.kt
// 네이티브 여러 곳에서 웹으로 이벤트를 보낼 때 사용하는 유틸 클래스
class BridgeEventEmitter(private val webView: WebView) {

fun emit(eventName: String, payload: JSONObject) {
val escaped = payload.toString().replace("'", "\\'")
val script = "window.__nativeEvent('$eventName', '$escaped')"

// 호출 스레드에 따라 처리 방법이 다름
if (Looper.myLooper() == Looper.getMainLooper()) {
webView.evaluateJavascript(script, null)
} else {
Handler(Looper.getMainLooper()).post {
webView.evaluateJavascript(script, null)
}
}
}
}

네트워크 상태 변경을 감지해서 웹에 푸시하는 예시입니다. ConnectivityManager.NetworkCallback은 연결/해제 시점에 자동으로 호출됩니다.

// NetworkMonitor.kt
class NetworkMonitor(
private val context: Context,
private val emitter: BridgeEventEmitter
) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val capabilities = connectivityManager.getNetworkCapabilities(network)
val type = when {
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "wifi"
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "cellular"
else -> "other"
}
emitter.emit("networkStatus", JSONObject().apply {
put("isConnected", true)
put("type", type)
})
}

override fun onLost(network: Network) {
emitter.emit("networkStatus", JSONObject().apply {
put("isConnected", false)
put("type", "none")
})
}
}

fun start() {
connectivityManager.registerDefaultNetworkCallback(callback)
}

fun stop() {
connectivityManager.unregisterNetworkCallback(callback)
}
}

4. 실전 사용 예제

예제 1: 로그인 토큰 관리

웹뷰 앱에서 인증 토큰을 브라우저 localStorage에 저장하면, WebView가 재생성되거나 캐시가 지워질 때 사라질 수 있습니다. 네이티브 SharedPreferences에 저장하면 앱이 재시작되어도 유지됩니다.

// 앱 진입 시 네이티브에서 토큰을 가져와 인증 초기화
async function initAuth() {
try {
const token = await bridge.call('getStorageItem', { key: 'authToken' });

if (token) {
// 서버에서 토큰 유효성 검증 후 갱신
await refreshAndStoreToken(token);
} else {
redirectToLogin();
}
} catch (err) {
console.error('토큰 조회 실패:', err);
redirectToLogin();
}
}

async function logout() {
// 토큰 두 개를 병렬로 삭제
await Promise.all([
bridge.call('removeStorageItem', { key: 'authToken' }),
bridge.call('removeStorageItem', { key: 'refreshToken' }),
]);
redirectToLogin();
}

async function saveTokens(accessToken, refreshToken) {
await Promise.all([
bridge.call('setStorageItem', { key: 'authToken', value: accessToken }),
bridge.call('setStorageItem', { key: 'refreshToken', value: refreshToken }),
]);
}

예제 2: 사진 업로드 플로우

웹에서 <input type="file">로 사진을 선택하면 기능이 제한적입니다. 네이티브 카메라/갤러리를 열고 결과를 받아 업로드하는 흐름입니다.

async function uploadProfilePhoto() {
const uploadBtn = document.getElementById('upload-btn');
uploadBtn.disabled = true;

try {
// 1단계: 네이티브 갤러리/카메라 실행 → 사용자가 선택한 파일 경로 반환
const { uri, mimeType } = await bridge.call('openCamera', {
source: 'gallery', // 'camera' | 'gallery'
quality: 0.8, // 0~1, 이미지 압축 품질
maxWidth: 1024,
maxHeight: 1024,
});

// 2단계: 네이티브 파일 시스템에서 Base64로 읽기
// 웹에서는 네이티브 파일 경로(/storage/...)에 직접 접근 불가
const { base64 } = await bridge.call('readFileAsBase64', { path: uri });

// 3단계: Base64 → Blob 변환 후 서버 업로드
const formData = new FormData();
const blob = base64ToBlob(base64, mimeType);
formData.append('photo', blob, 'profile.jpg');

const response = await fetch('/api/profile/photo', {
method: 'POST',
body: formData,
});

const { photoUrl } = await response.json();
document.getElementById('profile-img').src = photoUrl;

} 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) → 이동할 때마다 네이티브가 이벤트 푸시
class LocationService {
#watchId = null;

// 현재 위치 1회 조회 (배달 주소 자동완성, 주변 가게 검색 등)
async getCurrentPosition() {
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;
}

stopWatching() {
if (this.#watchId) {
this.#watchId(); // 이벤트 구독 해제
bridge.call('stopLocationWatch'); // 네이티브 GPS 추적 중단 (배터리 절약)
this.#watchId = null;
}
}
}

const locationService = new LocationService();

// 현재 위치 기반 주변 가게 로드
const { lat, lng } = await locationService.getCurrentPosition();
loadNearbyStores(lat, lng);

// 배달 추적 화면에서 실시간 마커 업데이트
locationService.startWatching(({ lat, lng, accuracy }) => {
updateMapMarker(lat, lng);
});

// 추적 화면을 나갈 때 반드시 중단 (GPS는 배터리를 많이 사용)
window.addEventListener('beforeunload', () => {
locationService.stopWatching();
});

예제 4: 딥링크 처리

딥링크(myapp://products/123)로 앱을 열면 네이티브가 URL을 파싱해서 웹 라우터에 전달해야 합니다. 두 가지 시나리오가 있습니다.

  • 앱이 꺼진 상태에서 진입: onCreate()에서 intent.data로 딥링크 URL을 가져옴
  • 앱이 실행 중일 때 진입: onNewIntent()로 새 Intent를 받음
// MainActivity.kt
class MainActivity : AppCompatActivity() {
private lateinit var emitter: BridgeEventEmitter

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleDeepLink(intent) // 앱이 꺼진 상태에서 딥링크로 실행된 경우
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLink(intent) // 앱이 실행 중일 때 딥링크로 진입한 경우
}

private fun handleDeepLink(intent: Intent) {
val data = intent.data ?: return
val path = data.path ?: return

// 쿼리 파라미터를 JSONObject로 변환
// myapp://products/123?ref=push → path="/products/123", params={"ref":"push"}
val params = JSONObject()
data.queryParameterNames.forEach { key ->
params.put(key, data.getQueryParameter(key))
}

// webView.post(): 웹뷰가 아직 로드 중일 수 있으므로
// 메인 루퍼 큐에 넣어 로드 완료 후 실행되도록 함
webView.post {
emitter.emit("deepLink", JSONObject().apply {
put("path", path)
put("params", params)
put("scheme", data.scheme)
})
}
}
}
// 웹 측: Vue Router 연동 예시
// 네이티브에서 딥링크 이벤트가 오면 SPA 라우터로 페이지 이동
bridge.on('deepLink', ({ path, params }) => {
// /products/123 → ProductDetail 컴포넌트로 라우팅
router.push({ path, query: params });
});

5. TypeScript 타입 정의

브릿지에 TypeScript 타입을 적용하면 두 가지 이점이 있습니다.

  1. 오타/누락 방지: 존재하지 않는 action 이름이나 잘못된 파라미터를 컴파일 타임에 잡아냄
  2. 자동 완성: 액션 이름을 입력하면 IDE가 params와 result 타입을 자동으로 추론

BridgeActionMap에 액션 이름을 키로, 파라미터/결과 타입을 값으로 정의합니다. call<K>(action: K, params: ...)가 제네릭으로 매핑을 수행합니다.

// bridge.types.ts

// 웹 → 네이티브 요청/응답 타입 맵
// 새 액션을 추가할 때 여기에만 추가하면 call()의 타입이 자동으로 연결됨
interface BridgeActionMap {
getDeviceInfo: {
params: void;
result: {
os: 'Android';
osVersion: string;
sdkVersion: number;
model: string;
brand: string;
manufacturer: string;
appVersion: string;
};
};
getStorageItem: {
params: { key: string };
result: string | null;
};
setStorageItem: {
params: { key: string; value: string };
result: boolean;
};
removeStorageItem: {
params: { key: string };
result: boolean;
};
openCamera: {
params: {
source: 'camera' | 'gallery';
quality?: number;
maxWidth?: number;
maxHeight?: number;
};
result: { uri: string; mimeType: string; size: number };
};
getCurrentLocation: {
params: { accuracy?: 'low' | 'balanced' | 'high'; timeout?: number };
result: { lat: number; lng: number; accuracy: number };
};
vibrate: {
params: { duration?: number };
result: boolean;
};
closeWebView: {
params: void;
result: boolean;
};
}

// 네이티브 → 웹 이벤트 타입 맵
interface BridgeEventMap {
networkStatus: { isConnected: boolean; type: 'wifi' | 'cellular' | 'other' | 'none' };
pushNotification: { title: string; body: string; data: Record<string, string> };
deepLink: { path: string; params: Record<string, string>; scheme: string };
locationUpdate: { lat: number; lng: number; accuracy: number };
}

// 타입 안전한 브릿지 클래스
class AndroidBridge {
// K가 BridgeActionMap의 키로 제한되어 있으므로
// params와 반환 타입이 K에 따라 자동으로 결정됨
call<K extends keyof BridgeActionMap>(
action: K,
params: BridgeActionMap[K]['params']
): Promise<BridgeActionMap[K]['result']> {
// ... 구현
}

on<K extends keyof BridgeEventMap>(
eventName: K,
handler: (payload: BridgeEventMap[K]) => void
): () => void {
// ... 구현
}
}

// --- 타입 추론 예시 ---

const bridge = new AndroidBridge();

// result 타입이 자동 추론됨
bridge.call('getDeviceInfo', undefined);
// → Promise<{ os: 'Android'; osVersion: string; ... }>

bridge.call('openCamera', { source: 'gallery', quality: 0.8 });
// → Promise<{ uri: string; mimeType: string; size: number }>

// 컴파일 에러: 'invalid'는 'camera' | 'gallery' 에 해당하지 않음
bridge.call('openCamera', { source: 'invalid' });

// handler의 파라미터 타입도 자동 추론
bridge.on('networkStatus', ({ isConnected, type }) => {
// isConnected: boolean, type: 'wifi' | 'cellular' | 'other' | 'none'
});

6. React 연동 패턴

React 컴포넌트에서 브릿지를 직접 사용하면 두 가지 문제가 생깁니다.

  • on()으로 등록한 이벤트 리스너가 컴포넌트 언마운트 시 해제되지 않으면 메모리 누수 발생
  • call()이 비동기이므로 컴포넌트가 언마운트된 후 setState가 호출될 수 있음

커스텀 훅으로 감싸면 이런 문제를 한 곳에서 처리할 수 있습니다.

// useAndroidBridge.ts
import { useEffect, useCallback } from 'react';
import { bridge } from './bridge';

// bridge.call()을 useCallback으로 감싼 훅
// 컴포넌트 리렌더링 시 함수 참조가 바뀌지 않음
export function useBridgeCall() {
return useCallback(
<K extends keyof BridgeActionMap>(
action: K,
params: BridgeActionMap[K]['params']
) => bridge.call(action, params),
[]
);
}

// bridge.on()을 useEffect로 감싼 훅
// 컴포넌트 마운트 시 구독, 언마운트 시 자동 해제
export function useBridgeEvent<K extends keyof BridgeEventMap>(
eventName: K,
handler: (payload: BridgeEventMap[K]) => void
) {
useEffect(() => {
const unsubscribe = bridge.on(eventName, handler);
return unsubscribe; // useEffect cleanup = 구독 해제
}, [eventName, handler]);
}

실제 컴포넌트에서 사용하는 예시입니다.

// 오프라인 배너: 네트워크 상태 변경 시 자동으로 UI 업데이트
function NetworkStatusBar() {
const [isOnline, setIsOnline] = useState(true);

// 컴포넌트가 화면에 있는 동안만 이벤트 수신
// 언마운트되면 자동으로 bridge.off() 호출
useBridgeEvent('networkStatus', ({ isConnected }) => {
setIsOnline(isConnected);
});

if (isOnline) return null;
return <div className="offline-banner">오프라인 상태입니다</div>;
}

// 프로필 페이지: 마운트 시 디바이스 정보 로드
function ProfilePage() {
const call = useBridgeCall();
const [deviceInfo, setDeviceInfo] = useState(null);

useEffect(() => {
call('getDeviceInfo', undefined).then(setDeviceInfo);
}, [call]);

return <div>{deviceInfo?.model}</div>;
}

7. 보안 및 주의사항

핵심 보안 규칙

항목 내용
minSdk 반드시 minSdk >= 17 설정. API 16 이하에서는 모든 public 메서드가 JS에 노출되는 취약점 존재
URL 검증 WebView에 로드되는 URL이 신뢰할 수 있는 도메인인지 확인
입력 검증 JS에서 전달된 파라미터는 서버 레벨로 검증. 악성 웹사이트가 postMessage를 호출할 수 있음
민감 정보 개인정보, 암호화 키 등을 evaluateJavascript의 응답에 포함하지 않기

URL 검증

악성 URL이 WebView에 로드되면 해당 페이지의 JavaScript도 window.AndroidBridge에 접근할 수 있습니다. WebViewClient에서 허용된 도메인만 로드하도록 제한하고, 허용되지 않은 URL이 로드될 경우 브릿지를 비활성화해야 합니다.

webView.webViewClient = object : WebViewClient() {
private val allowedHosts = setOf("my-webapp.com", "www.my-webapp.com")

override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val host = request.url.host ?: return true
return if (host in allowedHosts) {
false // WebView가 직접 로드 허용
} else {
// 허용되지 않은 URL은 외부 브라우저로 열기
startActivity(Intent(Intent.ACTION_VIEW, request.url))
true
}
}

override fun onPageStarted(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
fun postMessage(jsonStr: String) {
if (BuildConfig.DEBUG) {
Log.d("AndroidBridge", "← Web: $jsonStr")
}
// ... 처리
}

private suspend fun sendToWeb(id: String, response: JSONObject) {
if (BuildConfig.DEBUG) {
Log.d("AndroidBridge", "→ Web [$id]: $response")
}
withContext(Dispatchers.Main) {
webView.evaluateJavascript("window.__bridge_${id}('$response')", null)
}
}

웹 측에서도 Proxy 패턴으로 모든 call() 호출을 콘솔에 기록할 수 있습니다.

const bridge = new AndroidBridge();

if (process.env.NODE_ENV === 'development') {
const originalCall = bridge.call.bind(bridge);
bridge.call = async (action, params) => {
console.group(`[Bridge] ${action}`);
console.log('params:', params);
try {
const result = await originalCall(action, params);
console.log('result:', result);
return result;
} catch (err) {
console.error('error:', err);
throw err;
} finally {
console.groupEnd();
}
};
}

브라우저 환경 목업 (개발/테스트용)

웹 개발 중에는 Android 기기 없이 브라우저에서만 작업하는 경우가 많습니다. window.AndroidBridge가 없을 때 Mock 객체를 주입하면 실제 기기 없이도 브릿지 연동 코드를 개발하고 테스트할 수 있습니다.

// bridge.mock.js - 개발/테스트 환경에서만 로드
if (!window.AndroidBridge) {
window.AndroidBridge = {
postMessage(jsonStr) {
const { id, action, params } = JSON.parse(jsonStr);
console.log(`[MockBridge] ${action}`, params);

// 실제 네이티브처럼 비동기로 응답 (50ms 딜레이)
setTimeout(() => {
let result = null;
let error = null;

switch (action) {
case 'getDeviceInfo':
result = {
os: 'Android',
osVersion: '14',
sdkVersion: 34,
model: 'Pixel 8 (Mock)',
brand: 'Google',
manufacturer: 'Google',
appVersion: '1.0.0',
};
break;
case 'getStorageItem':
// localStorage를 SharedPreferences 대신 사용
result = localStorage.getItem(params.key);
break;
case 'setStorageItem':
localStorage.setItem(params.key, params.value);
result = true;
break;
default:
error = `[Mock] Unknown action: ${action}`;
}

const response = JSON.stringify({ result, error });
window[`__bridge_${id}`]?.(response);
}, 50);
},
};

console.info('[MockBridge] Android bridge mocked for browser dev environment');
}

요약

구분 방법 주의사항
웹 → 네이티브 window.AndroidBridge.postMessage(json) @JavascriptInterface 어노테이션 필수
네이티브 → 웹 (응답) webView.evaluateJavascript(...) 반드시 Main 스레드에서 호출
네이티브 → 웹 (이벤트) window.__nativeEvent(name, json) 호출 웹에서 구독/해제 관리 필요
요청-응답 매핑 UUID 기반 전역 콜백 window.__bridge_{id} 타임아웃 설정으로 메모리 누수 방지
보안 minSdk >= 17, 허용 URL 검증 이스케이프 처리로 XSS 방지
스레드 @JavascriptInterface는 백그라운드 스레드 실행 UI 작업은 withContext(Dispatchers.Main)
Share