[Android] WebView 체감 시간을 0으로 줄이는 프리로딩 기법과 리스크

Kame·2026년 9월 1일

Android

목록 보기
21/21
post-thumbnail

들어가며

이전 글에서 WebView의 Native Time을 줄이는 방법과 그 부작용을 다뤘습니다.

ANR의 위험이 따르긴 하지만, WebView 초기화 전에 미리 준비 작업을 수행하는 방식으로 사용자 체감 웹뷰 생성 시간을 개선할 수 있었습니다.

이번 글에서는 Web Time을 줄이는 방법을 소개하고자 합니다. Native Time이 WebView 인스턴스를 생성하는데 걸리는 시간이라면, Web Time은 실제 웹 페이지를 네트워크에서 가져와 렌더링하는 데 걸리는 시간을 가리킵니다.

특히 3G, 불안정한 WiFi 등 네트워크 환경이 좋지 않은 경우 정상적인 Web Time을 보장할 수 없게 됩니다. 사용자가 버튼을 누르고 몇 초간 빈 화면을 바라보는 상황은 피해야 할텐데, 이런 상황에서 고려해볼 수 있는 방식이 바로 Web Time을 미리 소비하여 웹 콘텐츠를 준비시켜놓는 것입니다.


loadUrl()을 미리 호출한다면?

기본 아이디어

가장 직관적인 방법은 별도의 WebView 인스턴스를 미리 생성하고, 사용자가 화면을 열기 전에 loadUrl()을 미리 호출하는 것입니다. 사용자가 실제로 화면을 열 때는 이미 로드된 WebView를 화면에 붙이기만 하면 됩니다.

class WebViewPreloader(
    private val coroutineScope: CoroutineScope,
) {
    private var preloadedWebView: WebView? = null

    fun preload(context: Context, url: String) {
        coroutineScope.launch(Dispatchers.Main) {
            preloadedWebView = WebView(context).apply {
                settings.javaScriptEnabled = true
            }

            // WebView가 렌더링을 수행하려면 Window에 attach되어야 합니다.
            // WindowManager를 통해 0x0 크기의 뷰로 임시 attach합니다.
            val params = WindowManager.LayoutParams(
                0, 0,
                WindowManager.LayoutParams.TYPE_APPLICATION,
                WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
                PixelFormat.TRANSLUCENT
            )
            val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
            windowManager.addView(preloadedWebView, params)

            preloadedWebView?.loadUrl(url)
        }
    }

    fun attachToContainer(container: ViewGroup): WebView? {
        return preloadedWebView?.also { webView ->
            // WindowManager에서 제거 후 실제 컨테이너에 추가
            val windowManager = webView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
            windowManager.removeView(webView)
            container.addView(webView)
            preloadedWebView = null
        }
    }
}

기본적으로 WebView 생성과 loadUrl() 트리거는 모두 메인 스레드에서 동작하도록 해야 합니다.

참고) 왜 Window에 attach해야 하는가?

WebView가 loadUrl()을 호출한 후 실제로 렌더링을 수행하려면 View 계층에 attach되어 있어야 합니다. attach되지 않은 상태에서는 layout pass가 발생하지 않고, 결과적으로 페이지 렌더링이 지연됩니다.

이와 관련하여 고려할 수 있는 방식들은 다음과 같습니다.

  1. WindowManager 사용: 0x0 크기의 뷰로 추가 (위의 예시)
  2. 투명 FrameLayout 사용: 루트 뷰에 GONE 상태로 추가
  3. MutableContextWrapper 활용: Context를 나중에 교체할 수 있도록 래핑

안전한 방식인가?

기본적으로 웹뷰 객체를 미리 만들어놓아야 하기 때문에, 이전 글에서 언급하였던 것과 일맥상통하는 위험 요소들을 고려해야 합니다.

ANR 위험

이전 글들에서 수 차례 설명한 바 있습니다.

메모리 관리

백그라운드에 WebView를 계속 들고 있으면 메모리 사용량이 증가합니다. 저사양 디바이스에서는 OOM(Out Of Memory) 위험이 있고, 메모리 회수 로직을 직접 구현해야 합니다.

Chromium 메일링 리스트에서도 다음과 같이 언급하고 있습니다.

"it does take memory though"

JavaScript 부작용

사용자가 보지 않는 상태에서 페이지가 완전히 로드되고 JavaScript가 실행된다는 것을 고려해야 합니다. 이를 고려하지 않으면 미리 돌아가지 말아야 할 동작들이 실행됨으로써 발생하는 부작용들에 대응할 수 없게 됩니다.

인증/쿠키 관련 이슈

프리로드 시점과 실제 화면 오픈 시점 사이에 로그인 세션이 바뀌면 문제가 됩니다. 이미 로드된 페이지가 이전 사용자 상태를 보여줄 위험이 있습니다.

Context/생명주기 관리

별도의 WebView 인스턴스를 들고 있다가 나중에 Activity/Fragment에 붙이는 구조이므로, 잠재적인 Context leak 위험이 있습니다. 사실 이전 글의 예시처럼 MutableContextWrapper 등으로 회피할 수 있었지만, 개발 자체의 복잡도가 증가함을 확인할 수 있었습니다.


얼마나 빨라지는가?

직접 loadUrl 프리로드 방식이 실제로 얼마나 효과적인지 실기기로 측정을 진행해보았습니다.

  • 측정 기기: Samsung Galaxy A13 (SM-A135U)
  • OS 버전: Android 14 (API 34)
  • 스펙: 8코어 2GHz, RAM 4GB
  • 측정 방식: Macrobenchmark + Perfetto Trace
  • 테스트 URL: https://www.google.com

측정 항목 정의

WebView 로딩 시간을 Native TimeWeb Time으로 분리하여 측정했습니다.

Total E2E = Native Time + Web Time

Native Time (네이티브 코드 실행 시간):
├── Create:    WebView 인스턴스 생성 (Chromium 엔진 초기화 포함)
├── Settings:  WebView 설정 적용 (JavaScript, DOM Storage 등)
├── Attach:    View hierarchy에 attach
└── LoadUrl:   loadUrl() 메서드 호출

Web Time (웹 페이지 로딩 시간):
├── NetStart:  loadUrl() → onPageStarted() (네트워크 연결 시작)
├── Content:   onPageStarted() → progress=100 (콘텐츠 다운로드)
└── Render:    progress=100 → onPageFinished() (렌더링 완료)

핵심 지표는 두 가지입니다.

메트릭설명
User E2E사용자가 버튼을 누른 시점부터 콘텐츠가 보이는 시점까지 (사용자 체감 시간)
Total E2E전체 작업 시간 (preload 단계 포함)

Baseline: 일반적인 WebView 사용 패턴

Cold Start에서 Chromium 엔진 초기화에 760ms가 소요되었습니다. 참고로 이는 WebView가 처음 생성될 때만 발생하는 일회성 비용입니다.

두 번째 이후 반복(Warm Start)에서는 Native Time이 30ms로 급감합니다. Chromium 엔진이 이미 초기화되어 있기 때문입니다.

여기서 주목할 점은 Cold Start와 Warm Start 모두 전체 로딩 시간에서 Web Time이 절대적인 비중을 차지한다는 것입니다. 즉, 웹 페이지를 로딩하는 데 오랜 시간이 걸리게 된다면 그것을 고스란히 사용자가 체감하게 됩니다.

Warm Start 시에는 HTTP 캐시 활용으로 Web Time 역시 대폭 감소할 것으로 기대했으나, 실제 측정에서는 캐시만으로 효과를 보지 못하였습니다. 물론 캐시 정책이나 네트워크 조건에 따른 상세 분석은 이 글의 본론이 아니므로, 여기서는 프리로딩 효과를 비교하기 위한 기준점(Baseline) 정도로만 참고하고 넘어가겠습니다.

메인 스레드 점유 분석: Perfetto Trace 기반

Perfetto trace 분석 결과, Native Time의 각 단계가 메인 스레드를 어떻게 점유하는지 확인했습니다.

단계메인 스레드 점유설명
Create블로킹WebView(context) 호출 시 Chromium 엔진 초기화. Cold Start에서 ~700ms 점유
Settings블로킹settings.javaScriptEnabled = true 등. 1ms 미만
Attach블로킹addView() 호출. View hierarchy 재구성. 2-4ms
LoadUrl즉시 반환loadUrl() 호출 후 즉시 반환. 실제 작업은 Chromium 프로세스에서 처리

loadUrl() 자체는 ANR의 원인이라고 보기 힘들다

Perfetto trace에서 Chromium 관련 스레드들을 분석한 결과, loadUrl() 이후의 실제 웹 로딩 작업은 앱 메인 스레드가 아닌 Chromium의 별도 프로세스/스레드에서 처리됩니다.

Perfetto에서 확인된 Chromium 스레드들:

스레드역할
Chrome_IOThread네트워크 I/O 처리
NetworkServiceHTTP 요청/응답
CrRendererMainHTML 파싱, JavaScript 실행, DOM 구성
Compositor레이어 합성, 페인팅
Chrome_InProcGp / GPU ThreadGPU 렌더링

loadUrl()은 Chromium 프로세스에 IPC 메시지를 보내고 즉시 반환합니다. 이후 네트워크 요청, HTML 파싱, JavaScript 실행, 렌더링은 모두 Chromium의 별도 스레드에서 병렬로 처리됩니다. 앱 메인 스레드는 onPageStarted(), onPageFinished() 등의 콜백만 받습니다.

다시 강조하자면, ANR의 주범은 loadUrl()이 아니라 WebView(context) 생성자입니다. Cold Start 시 Chromium 엔진 초기화가 메인 스레드를 오랫동안 블로킹합니다. loadUrl() 자체는 실험 결과 10-60ms 내에 반환되며, 이후 무거운 작업은 Chromium 프로세스에서 처리되므로 직접적인 ANR 위험이 적음을 알 수 있었습니다.

미리 로드하면?

글의 초반에서 소개했던 코드를 기반으로 웹뷰를 구현한다면, 꽤나 극적인 변화를 체감할 수 있습니다.

측정 결과 놀랍게도 User E2E가 4ms로 나타났습니다. 사용자가 버튼을 누르는 시점에 페이지가 이미 로드되어 있으므로, 사용자는 컨테이너로 웹뷰를 옮기는 시간만 체감하게 됩니다.

비교 요약

콜드 스타트 기준 사용자 체감 시간 99.8% 감소 (Baseline Cold 1,673ms → 직접 Preload 4ms)

시나리오User E2E (체감)Total E2ENative Time
Baseline #1 (Cold)1,673ms1,673ms760ms
Baseline #2 (Warm)1,041ms1,041ms30ms
Baseline #3 (Warm)1,024ms1,024ms30ms
직접 Preload #14ms1,975ms775ms
직접 Preload #25ms976ms52ms
직접 Preload #36ms1,018ms48ms

공식 API 활용하기

사실 이 모든 작업을 수동을 해줄 필요 없이, Android에서는 androidx.webkit 라이브러리에서 공식 API를 제공합니다.

필요에 따라 세 가지 종류의 API 중에 선택하여 활용해볼 수 있습니다.

구분PreconnectPrefetchPrerender
대상origin만정확한 URL정확한 URL
하는 일DNS+TCP+TLS 핸드셰이크HTML만 캐싱 (JS/CSS 제외)전체 페이지 렌더링 (JS 실행 포함)
범위Profile 단위 (여러 WebView 공유)Profile 단위 (공유)특정 WebView 인스턴스 단위
비용(상대적)낮음중간높음

공식 문서에서는 "Preconnect API is origin-based", "Prefetch and Prerender APIs are URL-based"라고 구분하고 있습니다.

Preconnect: 목적지 URL은 모르지만 도메인은 확실할 때

if (WebViewFeature.isFeatureSupported(WebViewFeature.PRECONNECT)) {
    profile.preconnect("https://help.example.com/index.html")
}

DNS 조회, TCP 연결, TLS 핸드셰이크를 미리 수행합니다. 실제 페이지 로드 시 이 과정을 건너뛸 수 있어 레이턴시가 감소합니다. 연결은 약 30초 동안 유지됩니다.

Prefetch: URL은 확실하지만 JavaScript 실행까지는 필요 없을 때

profile.prefetchUrlAsync(
    url, prefetchParams, cancellationSignal, executor,
    object : WebViewOutcomeReceiver<PrefetchResult, PrefetchException> {
        override fun onResult(result: PrefetchResult) {
            // HTML이 HTTP 캐시에 저장됨
        }
        override fun onError(error: PrefetchException) {
            // 실패 처리
        }
    }
)

HTML 문서만 미리 가져와 HTTP 캐시에 저장합니다. JavaScript나 CSS는 가져오지 않으므로 비용이 적습니다. 버전 1.15.0-alpha02부터 모든 스레드에서 호출 가능합니다.

Prerender: 진짜 즉시 전환이 필요할 때

WebViewCompat.prerenderUrlAsync(
    webView, url, cancellationSignal, executor, params,
    object : PrerenderOperationCallback {
        override fun onPrerenderActivated() {
            // 사용자가 navigate하면 숨겨진 페이지가 스왑됨
        }
        override fun onError(exception: Throwable) {
            // 메모리 부족 등으로 실패 가능
        }
    }
)

// 실제 열 때는 평소처럼 loadUrl 호출
// prerender된 게 있으면 자동으로 스왑됨
webView.loadUrl(url)

전체 페이지를 렌더링하고 JavaScript까지 실행합니다. 사용자가 실제로 navigate할 때 숨겨진 페이지가 스왑되어 즉각적인 전환이 가능합니다.

공식 문서에 따르면, prerender는 내부적으로 먼저 prefetch를 수행하여 중복 네트워크 요청을 방지합니다.

Prerendering relies on the same underlying infrastructure as prefetching. If an app initiates a prerender, WebView first performs a prefetch to serve the prerender navigation, avoiding redundant network activity.


직접 구현 vs Prerender API: 무엇이 다른가?

속도 측면

항목직접 loadUrlPrerender API
최초 렌더링 시간일반 페이지 로드와 동일 (타이밍만 앞당김)동일
중복 네트워크 요청방지 로직 없음, 직접 구현 필요내부적으로 prefetch 수행하여 재사용
실제 전환 순간addView()로 뷰 계층 재구성 필요hidden web contents 스왑으로 뷰 트리 재구성 없음
실질 체감 속도조건이 완벽하면 비슷조건이 나빠져도 시스템이 자동 fallback

위험 측면

위험 요소직접 loadUrlPrerender API
ANR 위험저사양 디바이스에서 발생 가능동일 (아래 설명 참조)
메모리 관리직접 회수 로직 구현 필요시스템이 메모리 부족 시 자동 취소
JavaScript 부작용오디오, alert 등 미리 실행Disallowed APIs로 시도 시 즉시 종료
인증/쿠키 staleness옛날 상태 노출 위험시스템이 자동으로 prefetch 스킵
리소스 상한직접 정책 구현 필요profile.setMaxPrerenders(2) 내장
Context 누수별도 View/Context 관리로 위험하나의 WebView 인스턴스에서 동작

공식 문서에서는 다음과 같이 설명합니다.

Memory pressure: The system cancels a prerendered URL automatically when the device is low on RAM.

Disallowed API access: A prerender fails immediately when the page attempts certain APIs that are not allowed in a background context, such as audio playback or alerts.


언제 어떤 API를 사용해야 하는가?

Preconnect: 사용자가 여러 경로 중 하나를 선택할 것 같은데, 모두 같은 도메인일 때

  • 예: 도움말 섹션의 여러 페이지 중 하나를 열 가능성이 높을 때

Prefetch: 여러 후보 URL 중 하나를 열 가능성이 높을 때 (HTML만 캐싱)

  • 예: 검색 결과에서 상위 몇 개 링크를 미리 가져오기

Prerender: 다음 화면이 거의 확실할 때 (전체 렌더링)

  • 예: 주문 완료 후 주문 상세 화면으로 이동이 확실한 경우

공식 문서에서도 "Prefer prefetching for several likely candidates and reserve prerendering for the single most probable navigation"이라고 권장합니다.


주의사항과 한계

HTTPS만 지원

Prefetch와 Prerender 모두 HTTPS만 지원합니다. HTTP URL을 전달하면 IllegalArgumentException이 발생합니다.

shouldInterceptRequest()와의 상호작용

Prefetch 단계에서는 shouldInterceptRequest()가 호출되지 않습니다. 인터셉터의 커스텀 로직, 인증 토큰, 헤더 주입이 적용되지 않으므로 주의가 필요합니다.

공식 문서에서 이 동작을 명확히 설명하고 있습니다.

Prefetch requests bypass shouldInterceptRequest() completely. Your interceptor's custom logic, authentication tokens, and header injections don't apply.

Service Worker

이미 Service Worker가 URL 범위를 제어하면 prefetch가 방해받을 수 있습니다.


ANR과 성능에 대한 오해

앞서 비교표에서 본 것처럼, prerenderUrlAsync와 직접 loadUrl 방식은 ANR 위험 측면에서 동일합니다.

Chromium 메일링 리스트에서 Chromium 엔지니어는 다음과 같이 설명합니다.

The pre-render feature in Chrome is implemented above the "content" layer, which is roughly where webview sits; pre-render is basically equivalent to creating a webview and calling loadUrl before displaying it.

결국 Main thread의 부하는 동일하다는 것을 알 수 있습니다.

추가적으로 startUpWebView API(androidx.webkit 1.16.0+)는 WebView 초기화 작업을 백그라운드로 이동시켜 초기화 시점의 ANR을 줄여주지만, 렌더링 성능은 최적화하지 않습니다. 공식 문서의 제목 자체가 "Optimize WebView startup"이며, 내용에서도 "While optimizing WebView startup reduces the risk of ANR errors during app launch"라고 명시합니다.

실제로 한 프로젝트에서 startUpWebView를 적용했음에도 저사양 디바이스에서 페이지 렌더링 중 ANR이 발생한 사례가 있었습니다. 이는 WebView의 근본적인 아키텍처 한계로, Main thread에서 처리해야 하는 작업들이 무거우면 ANR을 피할 수 없습니다.

따라서 prerenderUrlAsync의 진짜 가치는 속도나 ANR 해결이 아니라, 웹뷰 자체의 안전성을 더 확보할 수 있다는 이점에 있습니다.


마치며

이 글에서 소개한 방식들은 특히 저사양 디바이스에 ANR 위험이 있다는 것을 알고 사용해야 합니다.

개인적으로는 프로덕션 환경이라면 공식 API를 사용하는 것을 권장하고 싶습니다. 안전성 측면에서 시스템이 자동으로 처리해주는 부분이 많아 버그를 줄일 수 있기 때문입니다.


참고 자료

profile
Software Engineer

0개의 댓글