최근에 WebView 위주로 구성된 앱을 구현하면서, 아무래도 네이티브 앱 구현 위주로 여태 개발을 해왔기에 모르는 부분이 많아 여러 트러블 슈팅을 경험했다.
이제 어느정도 구현을 마무리하고 출시를 앞둔 시점에서 한번 쯤 관련 내용들을 총정리 해보면 좋을 것 같아 글을 작성해보았다.
WebView 내 JavaScript와 네이티브(Android/iOS) 코드가 서로 통신할 수 있게 해주는 인터페이스이다.
웹 (JS) ←── 브릿지 ──→ 네이티브 (Kotlin/Swift)
당연한 말이지만 웹뷰 앱을 개발할때, 웹의 기능만으로 못하는 것들이 있기 때문이다.
등등...
네이티브 고유의 기능이 필요할 때, 앱과 웹간의 통신(요청 및 응답, 명령 실행, 데이터 전달)을 가능하게 하는 브릿지를 만들어 연결해 사용한다.
현재 재직 중인 회사가 헬스케어 도메인이다 보니 삼성 헬스/헬스 커넥트같은 건강 앱 연동 작업이나 혈당계, 혈압계 등등 여러 BLE 기기들을 앱에 연동하는 케이스가 많아 이러한 WebView 브릿지를 적극 활용 해야했다.
이럴거면 네이티브 앱으로 하지
| REST API | WebView 브릿지 | |
|---|---|---|
| 통신 경로 | 앱 → 네트워크 → 서버 | JS ↔ Native (메모리 직접) |
| 네트워크 | 필수 | 불필요 |
| 지연 | 비교적 느림 | 빠름 |
| 용도 | 서버 데이터 CRUD, 비즈니스 로직 | 디바이스 고유 기능 |
| 시작 주체 | 클라이언트(→ 서버) | 양쪽 모두 먼저 시작 가능 (웹 ↔ 앱) |
| 명세/테스트 | Swagger, Postman 등 | 없음 😢 |
REST API는 클라이언트가 요청하고 서버가 응답하는 단방향 구조지만, 브릿지는 웹이 앱에게, 앱이 웹에게 양방향으로 요청 및 응답을 보낼 수 있다.
// Android
웹 → 앱: window.myBridge.postMessage(...)
앱 → 웹: webView.evaluateJavascript(...)
// iOS
웹 → 앱: window.webkit.messageHandlers.{name}.postMessage(...)
앱 → 웹: webView.evaluateJavaScript(...)
name은 웹 Javascript에서 네이티브 함수를 호출할때 사용할 브릿지 객체 이름으로 이를 네이티브에선 통신이 이뤄지기 이전에 미리 등록해두어야 한다.
name을 "myBridge"로 설정하였다면 아래 표의 각 플랫폼의 메소드를 호출하여 등록ㅇㅇ
| Android | iOS | |
|---|---|---|
| 등록 대상 | WebView | WKUserContentController |
| 메소드 | addJavascriptInterface(bridge, "myBridge") | add(self, name: "myBridge") |
| 웹 호출 | window.myBridge.xxx() | window.webkit.messageHandlers.myBridge.postMessage(...) |
[REST API] Web ─── Network ─── Server (외부)
[브릿지] JS ←───── 앱 내부 ─────→ Native (로컬)
브릿지는 앱 내부 로컬 통신이라 네트워크가 연결되지 않은 오프라인 환경에서도 동작한다.
Binder/XPC 기반 IPC(Inter-Process Communication) 방식을 통해 통신하기 때문이다.
Binder는 Android가 채택한 리눅스 커널 레벨에서 제공하는 IPC 메커니즘으로, 공유 메모리를 통해 데이터를 전달한다.
Apple이 만든 IPC 프레임워크인 XPC도 마찬가지로 커널을 통해 프로세스 간 통신이 이뤄진다.
| 플랫폼 | 프로세스 구조 | 통신 방식 |
|---|---|---|
| Android WebView (API 26+) | 멀티 프로세스 | Binder IPC |
| iOS WKWebView | 멀티 프로세스 | XPC IPC |
둘다 같은 기기 내에서 OS 커널이 직접 중개하기 때문에 네트워크 연결과 무관하게 동작한다.
커널이 뭔지는 이 글을 보면 대강 이해가 될 듯하다.
Android Binder는 트랜잭션 버퍼가 프로세스당 약 1MB로 제한되어 있으며, 이는 해당 프로세스의 모든 진행 중인 트랜잭션이 공유하는 크기다. 따라서 대용량 이미지나 파일 데이터를 브릿지로 직접 전달하려 하면
TransactionTooLargeException이 발생할 수 있다. iOS의 XPC는 Binder와 달리 공식 문서에서 명확한 크기 제한을 찾기 어렵지만, 마찬가지로 무제한은 아니다.
다만, 표에서도 언급되었듯, REST API엔 Swagger 같은 명세 + 테스트 툴이 잘 갖춰져 있는 반면, 브릿지는 그런 표준화된 도구가 없다... 결국 웹-네이티브 간 브릿지 메소드 문서를 노션이나 스프레드 시트 등으로 수기로 작성하여 관리, 앱을 직접 설치해서 테스트 해야 하는 게 상당히 아쉬운 부분이다.
AI에 도움을 받아 웹뷰를 통해 간단한 테스트 페이지를 띄워 버튼을 클릭, 전달할 데이터 포맷 작성 -> 요청 방식으로 개발된 브릿지 메소드의 동작을 테스트 해볼 순 있었다.
네트워크 연결이 없이 호출해서 사용이 가능하더라도, 항상 호출한다고 반드시 성공하는 것은 아니다.
@JavascriptInterface 함수의 호출 실패 케이스를 정리해보았다.
JavascriptInterface가 아직 등록되지 않은 상태에서 호출// ❌ 로드 전 호출 → undefined
webView.loadUrl("https://example.com")
webView.evaluateJavascript("window.myBridge.getAppVersion()") { result ->
// result = "null" 또는 에러
}
// ✅ 페이지 로드 완료 후 호출
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
view.evaluateJavascript("window.myBridge.getAppVersion()") { result ->
// 정상 동작
}
}
}
이외에도 푸시알림을 통해 앱 진입시, 외부 url에서 다시 복귀했을 때 웹뷰로 결과를 전달해야할 경우, 백그라운드에서 포그라운드로 진입 등의 상황에서 웹뷰 쪽의 브릿지가 아직 초기화가 안되었을 수도 있기에 pending 처리를 해야할 수도 있다.
delay를 거는 트리키한 해결책도 있겠지만, 좋은 방식은 아니고...
웹에서 앱으로 '이제 웹뷰 브릿지 초기화 되었으니 요청 보내도 됨' 이라는 신호를 브릿지 메소드로 전달 해주는 등의 방법으로 통신 성공을 보장하는 방법이 있다.
@JavascriptInterface 어노테이션이 붙은 메소드는 기본적으로 백그라운드 스레드에서 실행됨evaluateJavascript는 메인 스레드에서만 실행 가능@JavascriptInterface
fun showToast(message: String) {
// ❌ 백그라운드 스레드에서 UI 작업 → 크래시
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
// ✅ 메인 스레드로 전환
Handler(Looper.getMainLooper()).post {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
// ✅ Coroutine으로 메인 스레드 전환(추천)
CoroutineScope(Dispatchers.Main).launch {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
@JavascriptInterface
fun sendMessageToWeb(data: String) {
// ❌ 백그라운드 스레드에서 evaluateJavascript → 크래시
webView.evaluateJavascript("window.onMessage('$data')", null)
// ✅ 메인 스레드로 전환
webView.post {
webView.evaluateJavascript("window.onMessage('$data')", null)
}
// ✅ Coroutine으로 메인 스레드 전환
CoroutineScope(Dispatchers.Main).launch {
webView.evaluateJavascript("window.onMessage('$data')", null)
}
}
@JavascriptInterface | evaluateJavascript | |
|---|---|---|
| 방향 | 웹 → 앱 | 앱 → 웹 |
| 호출 주체 | JavaScript | Native (Kotlin/Java) |
| 실행 스레드 | 백그라운드 | 메인 스레드 필수 |
| 결과 반환 | 동기 return 가능 (but 잘 안 씀) | 비동기 (콜백) |
| 용도 | 웹에서 네이티브 기능 요청 | 앱에서 웹에 데이터 전달/함수 실행 |
오래 걸리는 작업 시 웹 UI가 블로킹 되기도하고, 권한 요청 등 비동기 작업은 return으로 처리 불가하다.
따라서 왠만한 경우 @JavascriptInterface 어노테이션이 붙은 함수 내부에서 동기 return을 하기보단, response(응답)을 evaluateJavascript로 비동기로 전달하는게 일반적인 패턴이다.
@JavascriptInterface 지원 타입 제한으로 복잡한 객체나 배열 직접 전달 불가 → JSON 문자열로 변환 필요IPC 통신 특성상 프로세스 간 데이터 전달시 직렬화가 필요하기에 JSON 문자열과 같은 범용적인 직렬화 포맷을 채택한것으로 추측한다.
// 웹에서 호출
window.myBridge.sendData(JSON.stringify({ name: "홍길동", age: 30 }))
// 앱에서 받음
@JavascriptInterface
fun sendData(jsonString: String) {
val data = Json.decodeFromString<MyObject>(jsonString)
}
Web (JavaScript)
↕ postMessage
WebViewBridge (@JavascriptInterface)
↕
Handler Map (Hilt Multibinding)
↕
각 Handler 구현체
기능별 독립적인 Handler 분리 - 클래스로 관리
Hilt Multibinding으로 Handler 자동 등록
- Key: handlerName (String)
- Value: BridgeHandler 구현체 클래스
→ Map<String, BridgeHandler> 형태로 자동 주입
Hilt Multibinding이란?
여러 구현체를 하나의 Map으로 묶어서 주입하는 방식으로, 웹에서 handlerName으로 요청하면 Map에서 해당 Handler를 찾아 실행하는 구조이다.
"getNativeAppVersion" -> GetNativeAppVersionHandler
"requestCameraPermission" -> RequestCameraPermissionHandler
"requestBiometric" -> RequestBiometricHandler
@MapKey
annotation class HandlerKey(val value: String)
@Module
@InstallIn(ActivityComponent::class)
abstract class BridgeHandlerModule {
@Binds @IntoMap
@HandlerKey("getNativeAppVersion")
abstract fun bindVersionHandler(handler: GetNativeAppVersionHandler): BridgeHandler
// 새 Handler = 한 줄 추가
}
// 각각의 UseCase와 유사하게 독립적인 클래스 형태로 관리
class GetNativeAppVersionHandler @Inject constructor() : BridgeHandler {
override val handlerName = "getNativeAppVersion"
override suspend fun handle(request: BridgeRequest, context: BridgeContext): BridgeResult {
val versionName = context.activity.packageManager
.getPackageInfo(context.activity.packageName, 0).versionName
return BridgeResult.Success(buildJsonObject {
put("os", "Android")
put("version", versionName)
})
}
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
// Kotlin → Java 변환 시 제네릭 타입 문제 때문에, 타입 정확히 매칭을 위한 @JvmSuppressWildcards 어노테이션 추가
@Inject
lateinit var handlers: Map<String, @JvmSuppressWildcards BridgeHandler>
private fun setupWebView() {
val bridge = WebViewBridge(this, bridgeHandlers)
webView.addJavascriptInterface(bridge, "Android")
}
}
// 요청
@Serializable
data class BridgeRequest(
val type: String = "request",
val handlerName: String,
val requestId: String,
val data: JsonElement? = null
)
// 컨텍스트
data class BridgeContext(
val activity: MainActivity,
val webView: WebView?
)
// 결과 (Sealed Class)
sealed class BridgeResult {
data class Success(val result: JsonObject) : BridgeResult()
data class Error(val message: String) : BridgeResult()
// 즉시 응답과 사용자 동의 등 지연 응답을 구분
data class PendingPermission(val handlerName: String, val requestId: String) : BridgeResult()
// ...
}
class WebViewBridge(
private val activity: MainActivity,
private val handlers: Map<String, BridgeHandler>
) {
private var webView: WebView? = null
private val scope = CoroutineScope(Dispatchers.Main)
@JavascriptInterface
fun postMessage(jsonString: String) {
val request = json.decodeFromString<BridgeRequest>(jsonString)
val handler = handlers[request.handlerName]
?: return sendError(request, "Unknown handler")
val context = BridgeContext(activity, webView)
scope.launch {
when (val result = handler.handle(request, context)) {
is BridgeResult.Success -> sendResponse(request, true, result.result)
is BridgeResult.Error -> sendResponse(request, false, errorJson(result.message))
is BridgeResult.PendingPermission -> {
// 권한 요청 pending 저장
pendingRequests[result.permissionType] = PendingRequest.Permission(
handlerName = result.handlerName,
requestId = result.requestId
)
// Activity를 통해 권한 요청
activity.requestPermission(result.permissionType)
}
}
}
}
fun onPermissionResult(permissionType: String, isGranted: Boolean) {
val pending = pendingRequests.remove(permissionType) ?: return
sendResponse(
handlerName = pending.handlerName,
requestId = pending.requestId,
isSuccess = true,
result = buildJsonObject { put("isGranted", isGranted) }
)
}
// Activity에서 권한 결과 받은 후 호출
private fun sendResponse(request: BridgeRequest, isSuccess: Boolean, result: JsonObject) {
val response = buildJsonObject {
put("type", "response")
put("handlerName", request.handlerName)
put("requestId", request.requestId)
put("isSuccess", isSuccess)
put("result", result)
}
scope.launch(Dispatchers.Main) {
webView?.evaluateJavascript(
"window.BridgeHandler.postMessage('${json.encodeToString(response)}')",
null
)
}
}
}
현재는 권한 요청, 화면 전환 등의 구현 상의 필요성으로 WebBiewBridge 클래스에 context가 아닌 activity를 직접 주입해주고 있는데, 아무래도 생명주기가 있는 컴포넌트를 직접 참조하다보니 메모리 누수, 크래시 가능성을 배재할 순 없다.
Activity에서의 작업은 Activity내에서 전부 수행하면 또 너무 GodActivity가 되어버리고, 별도의 WebViewBridege 클래스로 분리하더라도 WebView 구조 특성상 Activity와 강결합이 불가피하고...
이후 콜백 인터페이스나 이벤트 기반 구조 등으로 보다 결합을 느슨하게 만들어 Activity 직접 참조를 줄이는 방향으로 개선할 계획이다.
WeakReference를 사용하는 방법도 있겠으나, 궁극적인 해결법은 아닌듯
즉시 응답 불가능한 비동기 작업이라 Activity까지 끌고 가서 처리
1. 웹 → 브릿지: "카메라 권한 줘"
2. Handler → 권한 없음 → PendingPermission 반환
3. Bridge → pendingRequests Map에 저장
4. Activity → 시스템 권한 다이얼로그 표시
(사용자 선택 대기... ⏳)
5. Activity → onPermissionResult 콜백 받음
6. Bridge → pendingRequests에서 꺼내서 웹에 응답
// 3. Pending 저장
is BridgeResult.PendingPermission -> {
pendingRequests[permissionType] = PendingRequest.Permission(...)
// 여기선 응답 안 보냄
}
// 5~6. Activity에서 결과 받은 후
fun onPermissionResult(permissionType: String, isGranted: Boolean) {
val pending = pendingRequests.remove(permissionType)
sendResponseToWeb(pending.handlerName, pending.requestId, isGranted) // 이때 응답
}
WebView 브릿지가 필요한 이유, REST API 통신과의 차이점, 구현시 주의해야할 점 그리고 내가 구현했던 방법을 다시 정리해볼 수 있었다.
Dialog 등이 화면에 올라와 뒷 배경이 dim 처리가 될때 EdgeToEdge를 활성화하지 않게되면 SystemBar 영역은 dim 처리가 적용이 안되는 등 미관상 매우 이쁘지 않는 화면이 구성된다. 그런 웹뷰 앱이 많다.
Android 15+ 이상을 지원하는 앱에서는 EdgeToEdge가 디폴트로 적용되기 때문에, WebView 내 컨텐츠가 더 넓은 영역으로 확대가 되더라도 SystemBar, SafeArea에 침범하지 않기 위한 조치가 필요하다.
Web
/* CSS */
body {
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
}
Android WebView
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.ui.unit.Dp
...
@Composable
fun MainScreen() {
Box(modifier = Modifier.fillMaxSize()) {
// Retrieve insets as raw pixels
val insets = WindowInsets.safeDrawing
WebViewInCompose(
initialUrl = "file:///android_asset/example.html",
insets = insets
)
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebViewInCompose(
initialUrl: String,
insets: WindowInsets,
density: Density = LocalDensity.current,
layoutDirection: LayoutDirection = LocalLayoutDirection.current,
myWebViewClient: CustomWebViewClient = remember { CustomWebViewClient(
insets, density, layoutDirection
) }
) {
// Don't apply insets to the container
AndroidView(
factory = { context ->
WebView(context).apply {
webViewClient = myWebViewClient
settings.javaScriptEnabled = true
loadUrl(initialUrl)
}
}, update = { view ->
// Updates webpage when software keyboard expands or collapses.
// If your webpage doesn't have an input field that opens the
// software keyboard, remove this line.
applySafeAreaInsetsToWebView(insets, density, layoutDirection, view)
}
)
}
class CustomWebViewClient(
private val insets: WindowInsets,
private val density: Density,
private val layoutDirection: LayoutDirection
) : WebViewClient(){
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
// Inject insets into the webpage once the page has fully loaded
applySafeAreaInsetsToWebView(insets, density, layoutDirection, view)
}
}
private fun applySafeAreaInsetsToWebView(
insets: WindowInsets,
density: Density,
layoutDirection: LayoutDirection,
webView: WebView?){
// Convert raw pixels to density independent pixels
val top = insets.getTop(density).toDp(density).value
val right = insets.getRight(density, layoutDirection).toDp(density).value
val bottom = insets.getBottom(density).toDp(density).value
val left = insets.getLeft(density, layoutDirection).toDp(density).value
val safeAreaJs = """
document.documentElement.style.setProperty('--safe-area-inset-top', '${top}px');
document.documentElement.style.setProperty('--safe-area-inset-right', '${right}px');
document.documentElement.style.setProperty('--safe-area-inset-bottom', '${bottom}px');
document.documentElement.style.setProperty('--safe-area-inset-left', '${left}px');
"""
// Inject the density independent pixels into the CSS variables as CSS pixels
webView?.evaluateJavascript(safeAreaJs, null)
}
private fun Int.toDp(density: Density): Dp = with(density) { this@toDp.toDp() }
더 자세한 내용은 아래 글을 참고해보면 도움이 될 듯하다. 나도 아래 글을 통해 대응을 성공적으로 완료할 수 있었다.
Make WebViews edge-to-edge
키보드가 올라왔을때, 네비게이션 바 3버튼/제스쳐 모드 모두 해당 블로그글에 소개된 내용으로 대응이 가능하다. 키보드 관련 Inset 처리를 프론트에서 직접 처리하는 경우,update {}부부만 주석 처리하면 된다.
네이티브 개발자로서 통 WebVie 앱 은 본능적으로 거부감이 들 수밖에 없다. 나도 그랬다.
하지만 WebView 브릿지 통신 환경을 구축하고, 각각의 브릿지 함수들에 대한 Spec을 정의 및 request(요청), response(응답) 문서를 정리하면서 뭔가 서버 개발자가 된 것 같은 느낌을 받을 수 있었다.
항상 서버 개발자분들한테 '해줘~' 하며 요구, 요청만 해오던 Consumer(소비자) 입장에서의 클라이언트 개발만 지속해오다가, WebView 브릿지 통신을 통해 프론트에 필요한 정보를 제공하는 Producer(생산자) 역할을 처음 수행해보았다.
어떻게 데이터를 전달해야 프론트 개발자분들이 재가공 하지않고 편하게 데이터를 그대로 사용할 수 있을지, 하나의 기능을 위한 플로우를 구현하기 위해 여러 브릿지 함수를 연달아 호출하는게 아닌, 하나의 브릿지로 플랫하게 처리할 수 있을지를 고민해볼 수 있었다.
evaluateJavascript 함수를 보고 있으면, 함수의 argument로 문자열 형태로 입력한 값을 실행시킬 수 가 있기에 필연적으로 Javascript 생태계의 eval() 함수가 떠오른다.

https://dev.to/amitkhonde/eval-is-evil-why-we-should-not-use-eval-in-javascript-1lbh

https://www.youtube.com/shorts/mgdCHjJjR4M
JS eval() | Android evaluateJavascript() | |
|---|---|---|
| 실행 위치 | 같은 JS 런타임 | WebView의 JS 런타임 |
| 호출 주체 | JavaScript | Native (Kotlin/Java) |
| 프로세스 | 동일 | 다를 수 있음 (IPC) |
| 결과 반환 | 동기 | 비동기 (콜백) |
| 위험도 | 높음 | 조건부 |
| 이유 | 웹에서 사용자 입력 흔함 | 네이티브에서 제어 가능 |
| 권고 | 사용 금지 | 입력 검증 필수 |
evaluateJavascript는 개발자가 직접 작성한 코드만을 실행하기에 상대적으로 안전하긴 하지만, 웹에서 받은 데이터를 검증 없이 그대로 실행하면 eval과 같은 위험성이 존재하기에 주의가 필요하다.
// ❌ 위험: 웹에서 받은 데이터를 검증 없이 실행
@JavascriptInterface
fun executeScript(script: String) {
webView.evaluateJavascript(script, null) // 💀 코드 인젝션
}
// ❌ 위험: 사용자 입력 그대로 삽입
fun sendMessage(userInput: String) {
webView.evaluateJavascript("window.onMessage('$userInput')", null)
// userInput = "'); alert('hacked'); ('" → 인젝션
}
// ✅ 안전: 개발자가 제어하는 코드만 실행
// 개발자가 작성한 코드만 실행하고, 외부 입력은 반드시 직렬화/이스케이프 처리.
fun sendMessage(data: JsonObject) {
val sanitized = Json.encodeToString(data) // JSON 직렬화로 이스케이프
webView.evaluateJavascript("window.onMessage($sanitized)", null)
}
앱은 Javascript 환경이 아니기에 웹의 HttpOnly Cookie에도 접근이 가능하다.
httpOnly는 브라우저/JS 엔진의 보안 정책이지, OS 레벨 제약이 아니기 때문이다.
CookieManager 클래스를 통해 웹 브라우저 내에 Cookie에 저장한 authToken을 앱에서 사용하거나 데이터 공유가 가능하다는 것을 알고 넘어가도록 하자. 언젠간 쓸 일이 있을 유용한 정보
// ❌ JavaScript 환경 접근 불가
document.cookie // httpOnly 쿠키 안 보임
// Android - ✅ 접근 가능
val cookieManager = CookieManager.getInstance()
val cookies = cookieManager.getCookie("https://example.com")
// httpOnly 쿠키도 포함됨
httpOnly Cookie의 경우 웹에서 접근을 할 수 없기에, 위 방식처럼 앱에서 웹과 어떤 소통없이 빼가는(?) 느낌의 코드를 작성할 수 밖에 없다.
다른 케이스에서 앱에 필요한 값을 웹이 전달해야할 경우엔 브릿지 통신을 통해 전달하는게 서로의 구현 방식에 대한 결합도도 낮고, 더 명확하기에 더 나은 방식이라 생각한다.
| Cookie 직접 접근 | 브릿지로 전달 | |
|---|---|---|
| 방식 | CookieManager.getCookie() | 웹에서 명시적으로 전달 |
| httpOnly | ✅ 접근 가능 | ❌ 웹에서 접근 불가 |
| 의존성 | 쿠키 이름/구조 알아야 함 | 명시적 계약 |
| 결합도 | 높음 (웹 구현에 의존) | 낮음 |
| 전달 주체 | 앱이 직접 가져감 | 웹이 의도적으로 전달 |
끝!
reference)
WebView – Native bridges 공식 문서
Make WebViews edge-to-edge
WebView Java Bridge (WebView#addJavascriptInterface())
Messaging Between WKWebView and Native Application in SwiftUI
[OS] 커널(Kernel)이란
Eval is evil - Why we should not use eval in JavaScript
안녕하세요~ 블로그 항상 잘 보고있습니다!
양방햔 통신 내용 중 Android 예시 표에서, 등록하신 이름("myBridge")과 웹 호출부의 객체 이름(window.Android)이 서로 일치하지 않는 것 같아 제보 드립니다!