
기본적인 웹 프론트엔드 지식과 안드로이드 웹뷰 사용법을 숙지하고 있다면 이해가 쉽습니다.
웹뷰 기반의 앱을 사용하다 보면, 네이티브 로딩 바는 이미 사라졌는데 정작 화면은 비어 있거나 웹 자체의 로딩 화면이 다시 나타나는 당혹스러운 순간을 마주하곤 합니다. 이러한 간극은 사용자에게 불연속적인 경험을 줄 뿐만 아니라, 앱의 완성도가 떨어진다는 인상을 심어줄 수 있습니다.
이 글에서는 해당 문제의 근본 원인을 분석하고, 해결책이 될 수 있는 JavaScript Interface를 활용한 방법을 살펴보겠습니다.
기본적으로 호스트 앱에서는 HTML이 다운로드되었다는 것만 알 수 있고, 웹 내부에서 무슨 일이 벌어지는지 알 수 없습니다.
WebView의 로딩 상태를 추적하기 위해 웹뷰 객체에 WebViewClient를 설정할 수 있습니다. 이때 아래와 같은 콜백을 활용할 수 있습니다.
webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
// 페이지 로딩 시작
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
// 페이지 로딩 완료
}
}
공식 문서에 따르면 해당 콜백들은 다음과 같은 상황에서 호출됩니다.
얼핏 보면 간단해 보입니다. 화면 진입 시 로딩 인디케이터를 표시하고, onPageFinished에서 이를 숨기면 될 것처럼 보입니다.
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
progressBar.visibility = View.GONE
}
하지만 이는 웹 페이지가 어떻게 구현되어 있느냐에 따라 문제가 될 수 있습니다. 그 이유를 이해하려면 먼저 onPageFinished가 정확히 언제 호출되는지 알아야 합니다.
onPageFinished 콜백은 HTML 문서의 다운로드와 파싱이 완료되었을 때 호출됩니다. 전체 페이지 로딩 과정을 살펴보면 다음과 같습니다.
핵심은 onPageFinished가 호출된다고 해서 사용자가 볼 수 있는 콘텐츠가 모두 렌더링 되었다는 것을 보장할 수가 없다는 것입니다. 이어질 내용에서 렌더링 방식의 차이를 살펴보며 그 이유를 살펴보겠습니다.
웹 페이지를 생성하고 표시하는 방식에는 대표적으로 SSR(Server Side Rendering)과 CSR(Client Side Rendering)이 있습니다. 이 방식의 차이를 고려하지 않고 로딩 화면을 구현하면 사용자 경험에 부정적 영향을 미칠 수 있습니다.
SSR은 서버에서 완성된 HTML을 만들어 클라이언트로 전송하는 방식입니다. 서버는 요청받은 페이지에 필요한 모든 데이터를 미리 조회하고, 이를 기반으로 완전한 HTML 문서를 생성합니다. 클라이언트는 이 HTML을 받아 곧바로 화면에 표시할 수 있습니다.

SSR 방식에서는 초기 렌더링 시점과 onPageFinished 호출 시점이 거의 일치합니다. 따라서 onPageFinished가 호출되는 시점에 이미 의미 있는 콘텐츠를 사용자에게 보여줄 수 있어, 앞서 살펴본 간단한 구현만으로도 대부분 문제가 없습니다.
CSR은 서버로부터 최소한의 HTML 구조와 JavaScript 파일을 전달받은 뒤, 브라우저에서 JavaScript를 실행해 동적으로 콘텐츠를 생성하는 방식입니다.

CSR은 첫 로딩 이후 페이지 전환이 매우 빠르고 부드럽다는 장점이 있지만, 초기 구동 시 사용자가 실제 콘텐츠를 마주하기까지 일정 시간이 소요된다는 특징이 있습니다.
안드로이드의 onPageFinished가 호출되는 시점을 다시 떠올려보면, 문제가 명확해집니다. 사용자가 웹뷰 화면에 진입하면 HTML 문서 파싱이 끝나는 즉시 콜백이 실행되지만, CSR 환경에서 이 시점은 콘텐츠가 없는 빈 도화지 상태에 불과합니다. 정작 중요한 데이터를 불러오는 과정과 화면 렌더링은 onPageFinished가 호출된 이후, JavaScript가 본격적으로 실행되면서 비로소 시작되기 때문입니다.
결국 CSR 방식으로 구현된 웹을 웹뷰에 올릴 때 이러한 렌더링 메커니즘을 고려하지 않으면, 네이티브 로딩은 끝났는데 화면은 여전히 비어 있는 상황을 마주하게 됩니다. 이는 앱의 완성도가 낮다는 인상을 주어 사용자 경험에 부정적인 영향을 미칩니다.
WebView 객체에 단순히 loadUrl() 함수만 호출하고, 사용자에게 로딩에 관한 어떤 정보도 제공하지 않는 경우입니다.
webView.loadUrl("https://example.com")
사용자는 페이지를 불러오는 동안 빈 화면만 보게 되고, 앱이 멈춘 것인지 로딩 중인지 알 수 없습니다. 이는 불안감을 조성하고 이탈로 이어질 수 있습니다. 웹 페이지가 SSR, CSR 중 어느 방식으로 구현되었든 상관없이 지양해야 하는 구현입니다.
CSR로 구현된 웹 페이지를 웹뷰에 불러올 때, onPageFinished에서 네이티브 로딩 인디케이터를 숨기도록 구현한 경우입니다.
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
progressBar.visibility = View.GONE
}
}
이 구현에서는 다음과 같은 흐름이 발생합니다.
onPageFinished 호출 → 네이티브 로딩 인디케이터 숨김2번과 3번 사이에서 로딩 경험이 끊기면서, 사용자는 네이티브 로딩이 끝났음에도 다시 로딩 화면을 보거나 빈 화면을 마주하게 됩니다.
호스트 앱 측에서 로딩을 구현할 때 콜백에만 의존하는 것으로는 한계가 있습니다. 이때 JavaScript Interface를 활용할 수 있습니다.
JavaScript Interface는 안드로이드의 addJavascriptInterface() 메서드를 사용하여 네이티브 객체를 자바스크립트 엔진의 글로벌 window 객체에 바인딩하는 기술입니다.
쉽게 말해, 네이티브와 웹 페이지 간 실시간 통신을 위한 수단입니다. 이를 통해 네이티브와 웹이 서로의 상태를 실시간으로 공유하며 하나의 앱처럼 움직이도록 할 수 있습니다.

JavaScript Interface는 양방향 통신을 지원하며, 다음 두 가지 방향의 통신이 가능합니다.
웹에서 발생한 이벤트(로딩 완료, 버튼 클릭 등)를 네이티브에게 신호로 보내는 과정입니다. @JavascriptInterface 어노테이션을 사용하여 구현합니다.
class WebAppInterface(private val context: Context) {
@JavascriptInterface
fun onContentReady() {
// 웹에서 콘텐츠 렌더링이 완료되었음을 알림
(context as? Activity)?.runOnUiThread {
progressBar.visibility = View.GONE
}
}
@JavascriptInterface
fun onUserAction(actionType: String, data: String) {
// 웹에서 발생한 사용자 이벤트 처리
Log.d("WebBridge", "Action: $actionType, Data: $data")
}
}
// WebView 설정
webView.addJavascriptInterface(WebAppInterface(this), "AndroidBridge")
웹에서는 다음과 같이 호출할 수 있습니다.
// 콘텐츠 렌더링 완료 시
window.AndroidBridge.onContentReady();
// 사용자 액션 발생 시
window.AndroidBridge.onUserAction("button_click", "login");
네이티브가 웹 측의 특정 함수를 호출하거나 데이터 변경을 지시하는 과정입니다. evaluateJavascript를 활용합니다.
// 웹 측 함수 호출
webView.evaluateJavascript("javascript:updateUI()") { result ->
Log.d("WebBridge", "Function returned: $result")
}
// 데이터 전달
val userData = """{"name": "John", "age": 30}"""
webView.evaluateJavascript("javascript:setUserData('$userData')") { result ->
// 실행 결과 처리
}
// 결과값 받아서 처리
webView.evaluateJavascript("javascript:getUserPreference()") { result ->
// result: JSON string 형태로 반환됨
val preference = parseJson(result)
}
웹 측이 자신의 렌더링 완료 시점을 정확히 알고 있으니, 웹이 직접 네이티브에게 준비 완료 신호를 보내면 되지 않을까?
JavaScript Interface를 활용하면 앞서 마주했던 문제를 해결할 수 있습니다.
안드로이드에서 JavaScript Interface를 설정합니다.
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
private lateinit var progressBar: ProgressBar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
webView = findViewById(R.id.webView)
progressBar = findViewById(R.id.progressBar)
setupWebView()
}
private fun setupWebView() {
webView.settings.apply {
javaScriptEnabled = true // JavaScript 활성화 필수
}
// JavaScript Interface 등록
webView.addJavascriptInterface(
WebAppInterface(this, progressBar),
"AndroidBridge"
)
// 초기 로딩 시작
progressBar.visibility = View.VISIBLE
webView.loadUrl("https://example.com")
}
class WebAppInterface(
private val context: Context,
private val progressBar: ProgressBar
) {
@JavascriptInterface
fun onPageReady() {
// 웹 콘텐츠 렌더링 완료 시 호출됨
(context as? Activity)?.runOnUiThread {
progressBar.visibility = View.GONE
}
}
}
}
웹 페이지에서는 실제 콘텐츠가 렌더링되었을 때 네이티브에게 신호를 보냅니다.
// React 예시
useEffect(() => {
// 모든 데이터 로딩 및 렌더링 완료 후
if (window.AndroidBridge) {
window.AndroidBridge.onPageReady();
}
}, [isDataLoaded, isComponentMounted]);
// Vanilla JavaScript 예시
window.addEventListener('load', () => {
// API 호출 등 비동기 작업 완료 후
Promise.all([fetchUserData(), fetchContent()])
.then(() => {
// 실제 DOM 렌더링 완료 확인
requestAnimationFrame(() => {
if (window.AndroidBridge) {
window.AndroidBridge.onPageReady();
}
});
});
});
이렇게 구현하면 웹이 스스로 렌더링 완료 시점을 판단하여 네이티브에게 알리므로, CSR 환경에서도 끊김 없는 로딩 경험을 제공할 수 있습니다.
개선된 구현의 전체 흐름은 다음과 같습니다.
window.AndroidBridge.onPageReady() 호출핵심은 onPageFinished에 의존하지 않고, 웹이 직접 준비 완료 시점을 알려준다는 점입니다.
JavaScript Interface를 사용할 때 반드시 알아야 할 보안 및 기술적 고려사항이 있습니다.
Android 4.2 (API 17) 이상에서는 반드시 @JavascriptInterface 어노테이션을 사용해야 합니다. 이 어노테이션이 없는 메서드는 웹에서 접근할 수 없으며, 이는 악의적인 JavaScript 코드로부터 네이티브 메서드를 보호하는 중요한 보안 장치입니다.
class WebAppInterface(private val context: Context) {
@JavascriptInterface // 필수!
fun safeMethod() {
// 웹에서 호출 가능
}
// @JavascriptInterface가 없는 메서드
fun privateMethod() {
// 웹에서 호출 불가능 - 보안상 안전
}
}
외부 악의적인 사이트가 로드되는 것을 방지하기 위해 도메인 검증을 추가하는 것이 좋습니다.
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
val url = request?.url?.toString() ?: return false
// 신뢰할 수 있는 도메인만 허용
return if (url.startsWith("https://trusted-domain.com")) {
false // WebView에서 로드 허용
} else {
true // 다른 도메인 차단
}
}
}
JavaScript Interface로 호출되는 메서드는 백그라운드 스레드에서 실행됩니다. UI 작업이 필요한 경우 명시적으로 UI 스레드로 전환해야 합니다.
@JavascriptInterface
fun updateProgressBar(progress: Int) {
// ❌ 잘못된 방법 - 백그라운드 스레드에서 UI 조작 시도
// progressBar.progress = progress // 크래시 발생!
// ✅ 올바른 방법 - UI 스레드로 전환
(context as? Activity)?.runOnUiThread {
progressBar.progress = progress
}
}
이를 간과하면 CalledFromWrongThreadException이 발생하므로 주의해야 합니다.
JavaScript Interface로 전달할 수 있는 타입은 제한적입니다. 기본 타입(String, Int, Boolean 등)만 직접 전달할 수 있으며, 복잡한 객체나 배열은 JSON 문자열로 직렬화해야 합니다.
// ✅ 가능한 타입들
@JavascriptInterface
fun validMethod(
str: String,
num: Int,
bool: Boolean
) { }
// ❌ 불가능한 타입들
@JavascriptInterface
fun invalidMethod(
obj: CustomObject, // 커스텀 객체 불가
arr: Array<String> // 배열 직접 전달 불가
) { }
// ✅ 해결 방법: JSON 문자열로 직렬화
@JavascriptInterface
fun sendComplexData(jsonString: String) {
try {
val data = JSONObject(jsonString)
val name = data.getString("name")
val items = data.getJSONArray("items")
// 파싱 후 사용
} catch (e: JSONException) {
Log.e("WebBridge", "JSON parsing error", e)
}
}
웹에서는 다음과 같이 호출합니다.
const data = {
name: "John",
items: ["item1", "item2"]
};
window.AndroidBridge.sendComplexData(JSON.stringify(data));
JavaScript Interface를 사용하려면 WebView에서 JavaScript가 활성화되어 있어야 합니다.
webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(WebAppInterface(this), "AndroidBridge")
javaScriptEnabled를 true로 설정하지 않으면 Interface가 등록되어 있어도 웹에서 호출할 수 없습니다.
Activity나 Fragment를 Context로 직접 전달하면 메모리 누수가 발생할 수 있습니다. WeakReference를 사용하거나 필요에 따라 ApplicationContext를 사용하는 것이 좋습니다.
class WebAppInterface(context: Context) {
private val contextRef = WeakReference(context)
@JavascriptInterface
fun doSomething() {
contextRef.get()?.let { context ->
(context as? Activity)?.runOnUiThread {
// UI 작업
}
} ?: run {
// Context가 이미 정리된 경우
Log.w("WebBridge", "Context is null, activity might be destroyed")
}
}
}
Activity가 종료되었을 때 WeakReference는 자동으로 null을 반환하므로, 메모리 누수를 방지할 수 있습니다.
JavaScript Interface 호출 시 예상치 못한 오류가 발생할 수 있으므로, 적절한 예외 처리를 추가하는 것이 좋습니다.
@JavascriptInterface
fun processData(jsonString: String) {
try {
val data = JSONObject(jsonString)
// 데이터 처리
} catch (e: JSONException) {
Log.e("WebBridge", "Invalid JSON format", e)
// 웹에 에러 전달 (선택사항)
(context as? Activity)?.runOnUiThread {
webView.evaluateJavascript(
"javascript:onNativeError('Invalid data format')",
null
)
}
} catch (e: Exception) {
Log.e("WebBridge", "Unexpected error", e)
}
}
웹뷰 기반 앱에서 끊김 없는 로딩 경험을 제공하기 위해서는 단순히 onPageFinished 콜백에만 의존해서는 안 됩니다. 특히 CSR 방식으로 구현된 웹 페이지의 경우, HTML 파싱 완료와 실제 콘텐츠 렌더링 사이에 시간차가 존재하기 때문입니다.
JavaScript Interface를 활용하면 웹이 직접 자신의 렌더링 완료 시점을 네이티브에게 알릴 수 있어, 사용자에게 훨씬 더 자연스럽고 일관된 로딩 경험을 제공할 수 있습니다. 다만 보안, 스레드 처리, 타입 제한, 메모리 관리 등의 주의사항을 반드시 숙지하고 적용해야 안정적인 앱을 만들 수 있습니다.
이러한 접근 방식을 통해 네이티브와 웹이 유기적으로 협력하는, 진정한 의미의 하이브리드 앱을 구현할 수 있을 것입니다.