
이전 글에서 WebView의 Native Time을 줄이는 방법과 그 부작용을 다뤘습니다.
ANR의 위험이 따르긴 하지만, WebView 초기화 전에 미리 준비 작업을 수행하는 방식으로 사용자 체감 웹뷰 생성 시간을 개선할 수 있었습니다.
이번 글에서는 Web Time을 줄이는 방법을 소개하고자 합니다. Native Time이 WebView 인스턴스를 생성하는데 걸리는 시간이라면, Web Time은 실제 웹 페이지를 네트워크에서 가져와 렌더링하는 데 걸리는 시간을 가리킵니다.
특히 3G, 불안정한 WiFi 등 네트워크 환경이 좋지 않은 경우 정상적인 Web Time을 보장할 수 없게 됩니다. 사용자가 버튼을 누르고 몇 초간 빈 화면을 바라보는 상황은 피해야 할텐데, 이런 상황에서 고려해볼 수 있는 방식이 바로 Web Time을 미리 소비하여 웹 콘텐츠를 준비시켜놓는 것입니다.
가장 직관적인 방법은 별도의 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() 트리거는 모두 메인 스레드에서 동작하도록 해야 합니다.
WebView가 loadUrl()을 호출한 후 실제로 렌더링을 수행하려면 View 계층에 attach되어 있어야 합니다. attach되지 않은 상태에서는 layout pass가 발생하지 않고, 결과적으로 페이지 렌더링이 지연됩니다.
이와 관련하여 고려할 수 있는 방식들은 다음과 같습니다.
GONE 상태로 추가기본적으로 웹뷰 객체를 미리 만들어놓아야 하기 때문에, 이전 글에서 언급하였던 것과 일맥상통하는 위험 요소들을 고려해야 합니다.
이전 글들에서 수 차례 설명한 바 있습니다.
백그라운드에 WebView를 계속 들고 있으면 메모리 사용량이 증가합니다. 저사양 디바이스에서는 OOM(Out Of Memory) 위험이 있고, 메모리 회수 로직을 직접 구현해야 합니다.
Chromium 메일링 리스트에서도 다음과 같이 언급하고 있습니다.
"it does take memory though"
사용자가 보지 않는 상태에서 페이지가 완전히 로드되고 JavaScript가 실행된다는 것을 고려해야 합니다. 이를 고려하지 않으면 미리 돌아가지 말아야 할 동작들이 실행됨으로써 발생하는 부작용들에 대응할 수 없게 됩니다.
프리로드 시점과 실제 화면 오픈 시점 사이에 로그인 세션이 바뀌면 문제가 됩니다. 이미 로드된 페이지가 이전 사용자 상태를 보여줄 위험이 있습니다.
별도의 WebView 인스턴스를 들고 있다가 나중에 Activity/Fragment에 붙이는 구조이므로, 잠재적인 Context leak 위험이 있습니다. 사실 이전 글의 예시처럼 MutableContextWrapper 등으로 회피할 수 있었지만, 개발 자체의 복잡도가 증가함을 확인할 수 있었습니다.
직접 loadUrl 프리로드 방식이 실제로 얼마나 효과적인지 실기기로 측정을 진행해보았습니다.
https://www.google.comWebView 로딩 시간을 Native Time과 Web 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 단계 포함) |
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 분석 결과, Native Time의 각 단계가 메인 스레드를 어떻게 점유하는지 확인했습니다.
| 단계 | 메인 스레드 점유 | 설명 |
|---|---|---|
| Create | 블로킹 | WebView(context) 호출 시 Chromium 엔진 초기화. Cold Start에서 ~700ms 점유 |
| Settings | 블로킹 | settings.javaScriptEnabled = true 등. 1ms 미만 |
| Attach | 블로킹 | addView() 호출. View hierarchy 재구성. 2-4ms |
| LoadUrl | 즉시 반환 | loadUrl() 호출 후 즉시 반환. 실제 작업은 Chromium 프로세스에서 처리 |
Perfetto trace에서 Chromium 관련 스레드들을 분석한 결과, loadUrl() 이후의 실제 웹 로딩 작업은 앱 메인 스레드가 아닌 Chromium의 별도 프로세스/스레드에서 처리됩니다.
Perfetto에서 확인된 Chromium 스레드들:
| 스레드 | 역할 |
|---|---|
Chrome_IOThread | 네트워크 I/O 처리 |
NetworkService | HTTP 요청/응답 |
CrRendererMain | HTML 파싱, JavaScript 실행, DOM 구성 |
Compositor | 레이어 합성, 페인팅 |
Chrome_InProcGp / GPU Thread | GPU 렌더링 |
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 E2E | Native Time |
|---|---|---|---|
| Baseline #1 (Cold) | 1,673ms | 1,673ms | 760ms |
| Baseline #2 (Warm) | 1,041ms | 1,041ms | 30ms |
| Baseline #3 (Warm) | 1,024ms | 1,024ms | 30ms |
| 직접 Preload #1 | 4ms | 1,975ms | 775ms |
| 직접 Preload #2 | 5ms | 976ms | 52ms |
| 직접 Preload #3 | 6ms | 1,018ms | 48ms |
사실 이 모든 작업을 수동을 해줄 필요 없이, Android에서는 androidx.webkit 라이브러리에서 공식 API를 제공합니다.
필요에 따라 세 가지 종류의 API 중에 선택하여 활용해볼 수 있습니다.
| 구분 | Preconnect | Prefetch | Prerender |
|---|---|---|---|
| 대상 | 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"라고 구분하고 있습니다.
if (WebViewFeature.isFeatureSupported(WebViewFeature.PRECONNECT)) {
profile.preconnect("https://help.example.com/index.html")
}
DNS 조회, TCP 연결, TLS 핸드셰이크를 미리 수행합니다. 실제 페이지 로드 시 이 과정을 건너뛸 수 있어 레이턴시가 감소합니다. 연결은 약 30초 동안 유지됩니다.
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부터 모든 스레드에서 호출 가능합니다.
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.
| 항목 | 직접 loadUrl | Prerender API |
|---|---|---|
| 최초 렌더링 시간 | 일반 페이지 로드와 동일 (타이밍만 앞당김) | 동일 |
| 중복 네트워크 요청 | 방지 로직 없음, 직접 구현 필요 | 내부적으로 prefetch 수행하여 재사용 |
| 실제 전환 순간 | addView()로 뷰 계층 재구성 필요 | hidden web contents 스왑으로 뷰 트리 재구성 없음 |
| 실질 체감 속도 | 조건이 완벽하면 비슷 | 조건이 나빠져도 시스템이 자동 fallback |
| 위험 요소 | 직접 loadUrl | Prerender 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.
Preconnect: 사용자가 여러 경로 중 하나를 선택할 것 같은데, 모두 같은 도메인일 때
Prefetch: 여러 후보 URL 중 하나를 열 가능성이 높을 때 (HTML만 캐싱)
Prerender: 다음 화면이 거의 확실할 때 (전체 렌더링)
공식 문서에서도 "Prefer prefetching for several likely candidates and reserve prerendering for the single most probable navigation"이라고 권장합니다.
Prefetch와 Prerender 모두 HTTPS만 지원합니다. HTTP URL을 전달하면 IllegalArgumentException이 발생합니다.
Prefetch 단계에서는 shouldInterceptRequest()가 호출되지 않습니다. 인터셉터의 커스텀 로직, 인증 토큰, 헤더 주입이 적용되지 않으므로 주의가 필요합니다.
공식 문서에서 이 동작을 명확히 설명하고 있습니다.
Prefetch requests bypass shouldInterceptRequest() completely. Your interceptor's custom logic, authentication tokens, and header injections don't apply.
이미 Service Worker가 URL 범위를 제어하면 prefetch가 방해받을 수 있습니다.
앞서 비교표에서 본 것처럼, 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를 사용하는 것을 권장하고 싶습니다. 안전성 측면에서 시스템이 자동으로 처리해주는 부분이 많아 버그를 줄일 수 있기 때문입니다.