웹뷰의 캐싱 방식을 다룬 이전 게시글들을 읽고 오시면 이해가 쉽습니다.
1편에서는 HTTP 캐시와 DOM Storage를, 2편에서는 Service Worker Cache와 V8 코드 캐시를 다뤘습니다.
이번 글에서는 shouldInterceptRequest를 다룹니다. 해당 콜백의 구현이 필요한 상황과 잘못 사용하면 어떤 문제가 발생하는지 자세히 알아보도록 하겠습니다.
shouldInterceptRequest는 WebViewClient의 메서드입니다. 이 클래스를 상속해 오버라이드한 뒤, WebView에 등록해야 실제로 호출됩니다.
class MyWebViewClient : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
// 여기서 응답을 결정
return null
}
}
// 등록해야 실제로 동작함
webView.webViewClient = MyWebViewClient()
WebView가 리소스를 요청해야 하는 시점에(HTML을 파싱하다 <script src="...">를 만났을 때, JS가 fetch()를 호출했을 때) 이 함수가 먼저 호출되어, 해당 요청을 앱이 대신 처리하고 싶은지를 물어봅니다.
null 반환
원래 하려던 대로 처리한다는 의미입니다. WebView는 평소처럼 네트워크 스택을 통해 리소스를 받아옵니다. 즉 이 콜백을 오버라이드했다는 사실 자체가 모든 요청에 개입한다는 뜻은 아닙니다.
WebResourceResponse 반환
이 요청의 응답을 직접 만든 것을 대체한다는 의미입니다. 이 순간부터 그 요청에 한해서는 네트워크 스택이 아예 호출되지 않고, 반환한 응답이 곧바로 Blink에게 전달됩니다.
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
val url = request.url.toString()
if (url.endsWith("app.js")) {
val inputStream = context.assets.open("app.js")
return WebResourceResponse(
"application/javascript", // MIME 타입을 직접 지정해야 함
"UTF-8", // 인코딩을 직접 지정해야 함
inputStream // 실제 응답 본문
)
}
return null // 나머지 요청은 평소처럼 네트워크 스택을 타게 둠
}
구현 시, 아래 사항들에 유의해야 합니다.
null을 반환해 원래 흐름을 그대로 타게 할 수도 있습니다.loadUrl(), evaluateJavascript()처럼 WebView 자체를 건드리는 코드를 호출하면 예외가 발생하고, InputStream을 여는 것과 같은 동기 작업이 이 스레드를 오래 붙잡아 둘 수 있습니다. 이 특성이 왜 중요한지는 4장에서 다시 다룹니다.Chromium은 리소스 하나하나의 요청을 URLRequestJob이라는 추상화로 처리합니다. 평소에는 URLRequestHttpJob이라는 구현체가 실제 DNS/TCP/TLS/HTTP 통신을 수행하고, 그 결과를 1편에서 다룬 HttpCache(디스크 캐시)에 기록합니다.
WebView가 shouldInterceptRequest에서 응답을 반환하면, Chromium은 이 자리에 URLRequestHttpJob 대신 AndroidStreamReaderURLRequestJob이라는 다른 구현체를 끼워 넣습니다. 이 Job은 서버에 요청을 보내는 대신 Java 쪽 콜백을 호출해서, 앱이 반환한 InputStream을 그대로 Blink에게 흘려보내는 역할만 합니다. 같은 URLRequestJob이라는 틀 안에 있을 뿐, 실제 통신과 캐시 기록을 담당하는 구현체 자체가 통째로 바뀌는 것입니다.
이 사실은 2편에서 다룬 V8 코드 캐시와 직결됩니다. V8 코드 캐시는 독자적인 저장소가 아니라 HttpCache 디스크 항목에 얹혀서 저장되는 값입니다. 그런데 AndroidStreamReaderURLRequestJob은 HttpCache를 아예 거치지 않기 때문에, V8이 컴파일한 바이트코드를 얹어둘 자리 자체가 생기지 않습니다.

정리하면, shouldInterceptRequest는 페이지의 메인 리소스(HTML)부터 이미지, CSS, JS, 페이지 안 JS가 만드는 fetch/XHR 요청까지 대부분의 리소스 요청에 대해 호출되지만, WebSocket이나 이미 등록된 Service Worker가 자체적으로 처리하는 요청은 거치지 않습니다.
웹 단에서 해결할 수 없는 문제를 앱이 중간에서 개입해 처리해야 할 때
shouldInterceptRequest 공식 문서의 설명을 먼저 살펴보도록 하겠습니다.
웹뷰를 호스팅하는 앱에 리소스 요청을 알리고, 애플리케이션 측에서 리소스를 반환할 수 있도록 해준다.
이 문장만 보면 단순히 요청을 가로채서 응답을 대신 주기 위한 기능처럼 들립니다. 하지만 이 콜백은 단순히 웹-앱 간 요청을 가로채는 기능이 아님에 유의해야 합니다.
사실 이 콜백은 네트워크 요청을 앱 안에서 중간에 가로채서 직접 수정을 해야만 기능이 정상적으로 동작할 수 있는 상황에서 사용합니다.
이해를 위해 아래 예시들을 살펴봅시다.
앱 안에 타사의 결제창, 본인인증, SNS 로그인 같은 외부 웹을 띄웠다고 가정해보겠습니다. 그런데 해당 웹 페이지 안에서 불러오는 특정 폰트나 JS 파일의 URL에 문제가 생기면, 페이지를 정상적으로 열 수 없습니다. 해당 팀에 수정을 요청할 수는 있겠지만, 빠른 대응을 위해 앱이 그 요청만 가로채서 정상적인 파일로 바꿔치기해 응답을 대신하는 방식을 고려해볼 수 있습니다.
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
val url = request.url.toString()
// 1. 장애가 자주 나는 외부 JS URL 감지
if (url == "https://auth.external.com/static/sdk.js") {
return try {
// 2. 원래 서버 호출 (1.5초 내 응답 없거나 에러 시 catch로 이동)
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 1500
if (conn.responseCode == 200) {
WebResourceResponse("text/javascript", "UTF-8", conn.inputStream)
} else {
throw Exception("Server Error")
}
} catch (e: Exception) {
// 3. 서버 장애 발생 시 앱 내부(assets) 백업 파일로 바꿔치기
val backupStream = view.context.assets.open("sdk_backup.js")
WebResourceResponse("text/javascript", "UTF-8", backupStream)
}
}
return null // 그 외 일반 요청은 웹뷰 기본 로직대로 처리
}
이렇게 대응을 해놓는다면, 서버 장애 시 웹 페이지 코드를 단 한 줄도 안 고쳐도 자체적으로 대응할 수 있게 됩니다.
타사 서비스의 뉴스나 콘텐츠 웹페이지를 웹뷰로 보여주는 기능이 있다고 가정해보겠습니다. 해당 페이지에는 각종 무거운 타사 광고 스크립트, 사용자 추적 스크립트, 광고 등이 많이 붙어 있다면, 웹뷰 로딩 속도가 느려지고 데이터 사용량이 불필요하게 늘어날 수 있습니다.
이때 shouldInterceptRequest를 활용하면 불필요한 네트워크 요청만 핀셋으로 차단할 수 있습니다.
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
val url = request.url.toString()
// 차단하고 싶은 광고/트래킹 도메인 패턴 조건문
if (url.contains("ad-server.com") || url.contains("tracker.js")) {
// 네트워크로 요청을 보내지 않고 즉시 빈 응답(200 OK / 내용 없음)을 돌려줌
return WebResourceResponse(
"text/plain",
"UTF-8",
ByteArrayInputStream(ByteArray(0))
)
}
return null
}
웹뷰 입장에서는 요청을 보냈으나 즉시 빈 파일이 넘어온 것으로 처리되어, 외부 웹사이트의 구조를 훼손하지 않고도 불필요한 네트워크 트래픽과 로딩 지연을 완벽히 차단할 수 있습니다.
앱 내에서 웹뷰로 전자서명, 서식 작성, 또는 매뉴얼 같은 웹 페이지를 띄워야 한다고 가정해보겠습니다. 터널이나 지하주차장처럼 인터넷이 불안정하거나 완전히 오프라인 상태일 때, 일반적인 웹뷰는 ERR_INTERNET_DISCONNECTED 에러 화면을 띄우며 멈춰버립니다.
웹 단에서는 네트워크가 안 연결되면 서버에 있는 JS, CSS, 이미지 파일 자체를 불러올 방법이 물리적으로 없습니다. 이때 shouldInterceptRequest를 활용하면 인터넷 연결 여부와 상관없이 앱 내부에 빌드 시점에 저장해 둔 파일(assets)을 가로채서 바로 던져줄 수 있습니다.
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
val url = request.url.toString()
// 1. 오프라인에서도 작동해야 하는 웹뷰의 정적 리소스(JS/CSS/이미지) 감지
if (url.startsWith("https://my-service.com/static/")) {
// 2. 네트워크 상태와 상관없이 앱 패키지 안(assets)의 로컬 파일로 가로채서 응답
val fileName = url.substringAfter("https://my-service.com/static/")
return try {
val localStream = view.context.assets.open("web_static/$fileName")
val mimeType = if (fileName.endsWith(".js")) "text/javascript" else "text/css"
WebResourceResponse(mimeType, "UTF-8", localStream)
} catch (e: Exception) {
null // 로컬에 없는 파일은 기존 네트워크 로직을 타도록 넘김
}
}
return null
}
웹뷰 입장에서는 서버에서 리소스를 다운로드받아 온 것처럼 동작하므로, 네트워크 연결이 아예 없는 오프라인 상태에서도 웹뷰 화면과 기본 로직이 깨지지 않고 완벽하게 작동합니다.
결국 위 예시들은 모두 "요청을 가로채서 응답을 대신 준다"는 겉모습은 같지만, 외부 서비스를 이용하는 과정에서 마주치는 다양한 한계로 인해 앱에서 직접 처리를 해야하는 수밖에 없던 경우인 것입니다.
앞서 살펴보았듯 shouldInterceptRequest가 응답을 직접 반환하는 순간, Chromium의 네트워크 스택 전체와, 그 위에 얹혀 있던 모든 최적화(HTTP 캐시, V8 코드 캐시, 압축 처리, 조건부 요청, 병렬 다운로드)를 한꺼번에 우회하게 됩니다.
이 특성 때문에, Chromium이 원래 공짜로 해주던 일들을 개발자가 직접 다시 구현해야 하는 책임을 떠안게 됩니다. 이 책임을 인지하지 못한 채 쓰면 아래와 같은 문제로 이어집니다.
이는 리소스를 받아오는 시간만 고려하면 맞는 말입니다. 하지만 자바스크립트 실행 비용은 리소스를 받아오는 시간과 완전히 별개입니다.

앞서 확인했듯, HttpCache가 호출되지 않으니 V8 코드 캐시도 만들어질 자리가 없습니다. 그래서 이 콜백으로 서빙되는 .js 파일은 같은 페이지를 몇 번을 다시 열어도 매번 처음부터 파싱하고 컴파일됩니다. 네트워크가 빨라진 대가로, 매번 컴파일해야 하는 비용을 지불하게 되는 셈입니다. 파일이 작으면 체감이 안 되지만, SPA 프레임워크 번들처럼 수백 KB~MB급 JS 파일이라면 이 컴파일 비용이 누적되어 오히려 페이지의 로딩이 더 느려질 수 있습니다.
컴파일 비용을 알고 나면, 아예 파일 읽기 자체를 더 빠르게 만들자는 생각으로 자주 쓰는 리소스들을 앱 실행 시점에 메모리에 미리 로드해두려는 시도로 이어지기 쉽습니다.
문제는 디스크 I/O를 줄일 수 있어도 아래와 같은 위험이 존재한다는 것입니다.
일단 OOM(Out Of Memory)이 발생할 수 있습니다. WebView가 서빙해야 할 리소스가 늘어날수록, 미리 올려둔 데이터가 앱의 메모리 사용량을 계속 밀어 올립니다. 저사양 기기나 여러 WebView 인스턴스를 동시에 띄우는 상황에서는 이 누적이 크래시로 직결될 수 있습니다.
또한 여전히 컴파일 비용은 그대로이기에 실질적 효과는 미미합니다. 메모리에서 바이트를 더 빨리 꺼내오는 것과, V8이 그 바이트를 파싱·컴파일하는 것은 완전히 다른 문제입니다. 컴파일 비용 자체는 사라지지 않고 이것이 로딩 시간에 영향을 미침을 고려해야 합니다.
압축(gzip/br), 조건부 요청(ETag/If-Modified-Since), 우선순위 지정 같은 Chromium 네트워크 스택이 원래 알아서 해주던 최적화를 전부 잃습니다. 이걸 직접 구현하지 않으면, 압축 안 된 파일을 매번 통째로 전달하게 될 수도 있습니다.
이 콜백은 요청마다 동기적으로 호출됩니다. 안에서 느린 디스크 I/O나 네트워크 호출을 하면, 같은 스레드 풀을 공유하는 다른 리소스 요청들까지 줄줄이 지연됩니다. Chromium 네트워크 스택은 여러 리소스를 병렬로 받아오도록 최적화되어 있는데, 이 이점을 스스로 포기하는 셈입니다.
참고로 Jetpack에서 공식 제공하는
WebViewAssetLoader도 내부적으로는 결국shouldInterceptRequest기반으로 동작합니다. "공식 라이브러리를 쓰면 이 문제에서 자유롭다"고 단정할 수 없고, 위에서 짚은 트레이드오프를 그대로 물려받는다는 점은 여전히 고려해야 합니다.
2편에서 다룬 Service Worker Cache도 결국 "네트워크 스택을 가로챈다"는 발상은 같습니다. 그래서 둘을 나란히 놓고 비교하면 이 콜백의 특성이 더 선명하게 드러납니다.

공통점: 둘 다 "요청 → 네트워크 스택" 흐름을 가로채, 개발자 코드가 응답을 직접 결정할 수 있게 해줍니다. null을 반환(또는 캐시 미스)하면 원래 흐름대로 통과된다는 동작 방식도 같습니다.
차이점: shouldInterceptRequest는 응답을 반환하는 순간 무조건 네트워크 스택을 건너뜁니다. 반면 Service Worker는 캐시 미스일 때 코드 안에서 표준 Fetch API인 fetch()를 호출하는데, 이는 결국 네트워크 스택을 정상적으로 거칩니다. 그래서 shouldInterceptRequest는 항상 V8 코드 캐시를 무력화시키지만, Service Worker Cache는 캐시 미스 시에는 V8 코드 캐시가 정상적으로 만들어지고 재사용됩니다.
shouldInterceptRequest는 "무조건 빠르게 해주는 지름길"이 아니라, Chromium이 원래 해주던 여러 최적화를 개발자가 직접 다시 구현해야 하는 책임을 떠안는 도구입니다. 이 책임을 다하지 못하면, 서버 요청을 줄이려던 원래 의도와 달리 오히려 성능이 나빠질 수 있음을 유의해야 합니다.
Android Developers — WebViewClient.shouldInterceptRequest
Android Developers — WebResourceResponse
Android Developers — ServiceWorkerController
Android Developers — WebViewAssetLoader
V8 Blog — Code Caching for Developers