본문 바로가기

Android/Trouble Shoot

로컬 Asset 으로 WebView 최초 진입 최적화하기

Unsplash, Stefan Steinbauer.

동기

회사에서 개발한 WebView 기반 앱에 조치 요청이 들어왔습니다. 스플래시 화면 표시 이후 메인 화면 진입 시 네트워크 속도에 의해 빈 화면이 다소 긴 시간 동안 표시되다 보니, 이에 대한 조치가 필요하다는 내용이었습니다. 기본적인 네트워크 오류 처리는 해두었고, 타임 아웃도 처리해두었으나, 타임 아웃에 의한 알림이 표시되기 전에 발생할 유저 이탈이 우려되었습니다. 


문제 파악

특이한 점은, 해당 문제가 매번 발생하는 것이 아니라 간헐적으로 발생하는 문제였다는 것입니다. 관련하여 로그를 살펴보니, 평일 중 정해진 시간 내에서만 네트워크 응답이 느려지고 있었습니다. 서버에 문제가 있겠다 싶어 확인해봤는데, 사내 AI 팀에서 매일 해당 시간에 해당 서버로 크롤링을 진행하고 있었으며, 크롤링이 끝나면 다시 네트워크 응답 속도가 돌아왔습니다.

 

크롤링을 중단해달라고 할 수는 없으니, 문제 해결을 위한 논의를 진행했습니다. 유저로 하여금 '현재 로딩 중이다'라고 인식할 수 있게끔 프로그레스를 띄우자는 방향으로 결정이 되어 구현하였으나, 다소 어색함이 있어 스켈레톤을 구현하는 방향으로 변경하였습니다.

 

반응성을 생각하면 네이티브로 작업을 하는게 맞는데, 이는 적절하지 않았습니다. 대시민 서비스 앱들은 대부분 WebView 기반이며, 다양한 이해관계자들에 의해 UI 가 다소 잦은 빈도로 변경되기 때문입니다. 또한 Jetpack Compose, Android View System 을 막론하고, 네이티브에서 스켈레톤을 실제 웹 화면 레이아웃과 일치하도록 구현하는 것은 아주 큰 공수가 들기에, 다양한 이유로 웹에서 스켈레톤을 구현하는 것이 경제적이었습니다.

 

하지만, 해당 프로젝트의 웹은 JSP 로 작성되어 있어, 서버가 HTML 을 다 구성하고 나서야 페이지가 로드되기 때문에 웹에서 스켈레톤을 표시하는 것 역시 부적절한 상황이었습니다.

 

오늘은 해당 문제를 해결하기 위해, JSP 가 생성할 HTML 의 구성에 맞게 미리 HTML 을 구현해두고, 이를 불러오는 방식을 사용하였습니다.


문제 해결

Box(
    modifier = Modifier
        .fillMaxSize()
        .statusBarsPadding()
        .navigationBarsPadding()
        .imePadding()
) {
    AndroidView({ webView })

    if (!uiState.isFirstPageLoaded) AndroidView({ skeletonWebView })

    ...
}

 

먼저, UI 구성입니다. Box 내에 두 개의 WebView 를 겹쳐 배치합니다. 하나는 실제로 웹 화면이 보여지는 WebView 이고, 나머지 하나는 스켈레톤만 보여주는 WebView 입니다. 굳이 두 개의 WebView 를 사용하는 것은, 스켈레톤에서 웹 화면으로 넘어갈 때의 깜빡임을 방지하기 위해서입니다. uiState 에는 isFirstPageLoaded 라는 Boolean 프로퍼티를 두어, 최초 페이지가 로드되면 스켈레톤 WebView 를 렌더링하지 않도록 합니다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <style>
        * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
        body { margin: 0; padding: 0; background-color: #fff; font-family: sans-serif; overflow: hidden; }

        @keyframes shimmer {
            0% { background-position: -200% 0; }
            100% { background-position: 200% 0; }
        }
        .shimmer {
            background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 50%, #f2f2f2 75%);
            background-size: 200% 100%;
            animation: shimmer 1.5s infinite linear;
            border-radius: 4px;
        }

        .main_header {
            height: 56px; display: flex; align-items: center;
            justify-content: space-between; padding: 0 16px; border-bottom: 1px solid #f5f5f5;
        }
        .main_header h1 { width: 80px; height: 24px; margin: 0; }
        .alarm_wrap { width: 24px; height: 24px; }

        .main_wrap { padding: 0 16px; }

        .main_notice_wrap { height: 60px; margin: 15px 0; border-radius: 8px; }

        .main_card_view_wrap { margin-bottom: 24px; }
        .lg_title { width: 100%; height: 48px; margin: 20px 0 12px 0; }
        .card_wrap { width: 100%; height: 210px; border-radius: 12px; }

        .main_banner_wrap { width: 100%; height: 90px; border-radius: 12px; margin-bottom: 24px; }

        .main_service_wrap .lg_title_flex { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
        .tab_menu { display: flex; gap: 8px; margin-bottom: 16px; }
        .btn_tab { width: 56px; height: 32px; border-radius: 16px; }

        .service_item_wrap { display: flex; flex-direction: column; gap: 12px; }
        .service_item { width: 100%; height: 74px; border-radius: 12px; }

    </style>
</head>
<body>

<header class="main_header">
    <div class="shimmer" style="width: 80px; height: 24px;"></div> <div class="shimmer alarm_wrap"></div> </header>

<div class="main_wrap">
    <div class="shimmer main_notice_wrap"></div>

    <div class="main_card_view_wrap">
        <div class="shimmer lg_title"></div> <div class="shimmer card_wrap"></div> </div>

    <div class="main_banner_wrap shimmer"></div>

    <div class="main_service_wrap">
        <div class="lg_title_flex">
            <div class="shimmer" style="width: 130px; height: 22px;"></div> <div class="shimmer" style="width: 45px; height: 16px;"></div> </div>

        <div class="tab_menu">
            <div class="shimmer btn_tab"></div> <div class="shimmer btn_tab"></div> </div>

        <div class="service_item_wrap">
            <div class="shimmer service_item"></div>
            <div class="shimmer service_item"></div>
            <div class="shimmer service_item"></div>
        </div>
    </div>
</div>

</body>
</html>

 

Shimmer 기능이 포함된 HTML 파일입니다. API 를 통해 HTML 을 불러와 사용하는 편이 유지보수 측면에서 유리하겠으나, 동료 서버 개발자의 업무 과중으로 인하여, 우선은 직접 이를 구성하여 안드로이드 프로젝트 로컬 Asset 으로 관리합니다.

val skeletonWebView = remember {
    WebView(context).apply {
        layoutParams = ViewGroup.LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.MATCH_PARENT
        )

        val assetLoader = WebViewAssetLoader.Builder()
            .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(context))
            .build()

        webViewClient = object : WebViewClient() {
            override fun shouldInterceptRequest(
                view: WebView,
                request: WebResourceRequest
            ): WebResourceResponse? {
                return assetLoader.shouldInterceptRequest(request.url)
            }
        }

        loadUrl("https://appassets.androidplatform.net/assets/www/skeleton.html")
    }
}

 

이후 androidx.webkit 라이브러리의 WebViewAssetLoader 를 통해 로컬 에셋을 일반적인 HTTPS URL 형식으로 로드합니다. 이는 CORS(Cross-Origin Resource Sharing) 문제를 방지하기 위함입니다.

 

실제로 구글 플레이 심사나 보안 취약점 점검 시, 로컬 파일 접근을 위해 setAllowFileAccessFromFileURLs 같은 옵션을 활성화하는 것은 주요 보안 위반 사항으로 지적되곤 합니다. 또한, 최신 안드로이드 OS(API 30 이상)에서는 file:// 프로토콜을 통한 접근에 엄격한 제약을 두고 있습니다.

 

현재의 단순 스켈레톤 구조에서는 문제가 없더라도, 추후 HTML 내에서 외부 API 를 호출하거나 복잡한 리소스를 로드할 때 발생할 수 있는 잠재적인 차단 가능성을 고려하여 가상 도메인을 활용하는 표준 방식을 채택하였습니다.

webViewClient = object : WebViewClient() {
            override fun onPageFinished(view: WebView?, url: String?) {
                super.onPageFinished(view, url)
                if (!uiState.isFirstPageLoaded && url == URL_HOME) onEvent(MainEvent.Callback.OnFirstPageLoaded)
            }
        }

 

기존 WebView 의 WebViewClient 는 최초 페이지 로드에 대한 마킹을 진행하기 위해 onPageFinished() 메서드를 재정의 해줍니다. 이 때, 최적화를 위해 uiStateisFirstPageLoaded 에 대한 검증을 먼저 진행해줍니다. 이렇게 처리하면 Boolean 값 하나만 비교하고, 조건에 부합하지 않는 경우 이후 작업을 생략할 수 있습니다.


결과

 

before. 앱 명칭이 표시되는 부분은 마스킹 처리.

 

after. 앱 명칭이 표시되는 부분은 마스킹 처리.

후기

그다지 복잡하지 않은 방식으로 문제를 해결할 수 있었습니다. 실제로 이탈률이 감소하였는지는 추후에 확인해봐야 하겠지만, 최소한 유저로 하여금 '앱에 문제가 있나?' 라는 생각은 들지 않게끔 할 수 있었다고 생각합니다.

 

해당 방식은, 결국 JSP 가 생성하는 HTML 과 같은 구성의 HTML 파일이 별도로 필요합니다. 메인 화면의 구성이 바뀌지 않는다는 약속도 없고, 실제 컨텐츠와 스켈레톤 간의 괴리가 있는 것도 좋지 않다고 생각해서, 이와 같은 방식으로 구현하였습니다. 공수가 아주 적은 방식은 아니지만, 사용성을 위해서 이 정도의 공수는 감당해야 한다고 판단했습니다.

 

혹시 저와 같은 상황을 마주하셨다면, 이와 같은 방식으로 사용성을 개선해보는 건 어떨까요.

긴 글 읽어주셔서 감사합니다.