핵심 요약
Android WebView가 흰 화면이나 빈 화면으로 멈출 때 메인 문서와 하위 리소스 오류를 구분하고, HTTP·SSL·JavaScript·혼합 콘텐츠·렌더러 종료를 순서대로 진단합니다.
검수 범위
앱이 소유하거나 신뢰 관계가 분명한 HTTPS 웹앱을 Android WebView에 표시하는 경우를 대상으로 합니다. 임의의 외부 웹 탐색은 브라우저나 Custom Tabs가 더 적합하며, iOS WKWebView와 서버 자체 장애는 별도 범위입니다.
브라우저에서는 열리는데 Android 앱의 WebView만 하얗게 보이면 흔히 캐시부터 지우거나 JavaScript 설정을 전부 허용합니다. 하지만 빈 화면은 하나의 오류가 아니라 메인 문서 요청, 하위 리소스, JavaScript 실행, 화면 합성, 렌더러 프로세스 중 한 곳에서 실패한 결과입니다. 실패 층을 나누지 않으면 SSL 검증을 끄거나 혼합 콘텐츠를 허용해 증상만 숨길 수 있습니다.
이 글은 운영 웹앱을 WebView로 표시하는 앱에서 관찰 지점을 먼저 만들고, 재현 결과에 따라 수정 범위를 좁히는 순서를 다룹니다. 설명용 도메인과 로그는 예시이며 특정 앱에서 실행한 결과로 주장하지 않습니다.
빈 화면을 다섯 가지 증상으로 먼저 분류하기
“안 보인다”는 제보만으로 설정을 바꾸지 말고 로딩 표시, 화면 일부, 재현 조건을 함께 기록합니다. 같은 흰 화면처럼 보여도 아래의 첫 확인 지점이 다릅니다.
| 관찰한 증상 | 우선 확인할 층 | 남겨야 할 증거 |
|---|---|---|
| 첫 주소부터 열리지 않음 | INTERNET 권한, DNS·연결, HTTP·TLS | 메인 프레임 URL, 오류 코드, 인증서 실패 |
| 로딩 완료 뒤 흰 화면 | JavaScript 예외, DOM·CSS, 저장소 의존성 | Console 첫 오류, 문서 상태, 스크립트 응답 |
| 글자는 보이고 일부 화면만 비어 있음 | 이미지·API·폰트 같은 하위 리소스 | 실패 URL의 종류, 상태 코드, mixed-content 메시지 |
| 로그인 화면에서만 반복·정지 | redirect, 쿠키 정책, 인증 상태 | redirect 순서, Set-Cookie 조건, 최종 호스트 |
| 잠깐 보인 뒤 WebView가 사라지거나 앱이 종료됨 | 렌더러 프로세스와 메모리 | onRenderProcessGone, crash 여부, 직전 화면 |
또한 WebView 자체가 0×0 크기이거나 다른 View 아래에 가려져도 네트워크 오류처럼 보일 수 있습니다. Layout Inspector 또는 일시적인 배경색으로 실제 크기와 가시성을 먼저 확인합니다. 측정된 너비·높이가 0이라면 웹 설정이 아니라 레이아웃부터 고쳐야 합니다.
1단계: 최소 설정과 관찰 지점부터 만들기
앱에 android.permission.INTERNET이 선언돼 있는지 확인합니다. WebView는 JavaScript를 기본으로 실행하지 않으므로 소유한 웹앱이 JavaScript로 렌더링될 때만 명시적으로 켭니다. 웹앱이 localStorage 같은 DOM 저장소를 실제로 사용한다면 그 기능도 요구사항에 따라 켭니다.
<uses-permission android:name="android.permission.INTERNET" />
val isDebuggable = (
applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE
) != 0
if (isDebuggable) {
WebView.setWebContentsDebuggingEnabled(true)
}
webView.settings.apply {
javaScriptEnabled = true // 신뢰하는 웹앱이 필요로 할 때만
domStorageEnabled = true // 해당 앱의 저장소 요구사항을 확인한 뒤 적용
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
}
webView.webViewClient = DiagnosticWebViewClient()
webView.webChromeClient = DiagnosticChromeClient()
webView.loadUrl("https://app.example.com")
원격 디버깅은 앱의 debuggable 상태를 확인한 뒤 개발 빌드에서만 켭니다. 릴리스에서 항상 활성화하면 사용자가 보고 있는 페이지와 내부 상태가 디버깅 인터페이스에 노출될 수 있습니다. JavaScript가 필요하지 않은 단순 문서라면 굳이 켜지 않는 편이 공격 표면도 작습니다.
WebViewClient를 설정하지 않으면 탭한 링크가 기본 브라우저로 넘어가는 등 원하는 탐색 흐름과 달라질 수 있습니다. 반대로 모든 주소를 무조건 WebView에 가두는 것도 안전하지 않습니다. 허용할 scheme과 host를 먼저 정하고 외부 주소는 별도 브라우저 흐름으로 보냅니다.
2단계: 메인 문서 오류와 하위 리소스 오류를 분리하기
현대 WebView의 onReceivedError는 메인 문서뿐 아니라 이미지·스크립트 같은 리소스 실패에도 호출될 수 있습니다. 따라서 콜백 한 번을 받았다는 이유로 전체 화면을 오류 페이지로 바꾸면, 작은 이미지 하나의 실패가 정상 문서를 덮어버립니다. request.isForMainFrame을 함께 기록해 분리합니다.
class DiagnosticWebViewClient : WebViewClient() {
override fun onPageStarted(view: WebView, url: String?, icon: Bitmap?) {
Log.d("WebViewDiag", "started host=${url?.let { Uri.parse(it).host }}")
}
override fun onPageCommitVisible(view: WebView, url: String?) {
Log.d("WebViewDiag", "committed host=${url?.let { Uri.parse(it).host }}")
}
override fun onPageFinished(view: WebView, url: String?) {
Log.d("WebViewDiag", "finished host=${url?.let { Uri.parse(it).host }}")
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError
) {
Log.e(
"WebViewDiag",
"network main=${request.isForMainFrame} " +
"host=${request.url.host} code=${error.errorCode}"
)
if (request.isForMainFrame) {
showConnectionError()
}
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
response: WebResourceResponse
) {
Log.e(
"WebViewDiag",
"http main=${request.isForMainFrame} " +
"host=${request.url.host} status=${response.statusCode}"
)
}
}
onReceivedHttpError 역시 HTTP 400 이상인 모든 리소스에 호출될 수 있습니다. 메인 문서의 401·403·404·500과 하위 API나 이미지의 실패를 같은 메시지로 처리하지 않습니다. 응답 본문이 항상 제공된다고 가정하지 말고, 서버의 request ID와 시간대를 맞춰 서버 로그에서 원인을 확인합니다.
운영 로그에는 전체 URL query, access token, 쿠키, 입력 폼 값을 남기지 않습니다. host, main-frame 여부, 상태 코드, 앱 버전, 재현 시각처럼 진단에 필요한 최소 정보만 수집하고 보존 기간을 정합니다.
3단계: Chrome DevTools에서 Network와 Console을 같은 시각으로 보기
개발 기기의 USB 디버깅을 허용하고 디버그 앱을 실행한 뒤 데스크톱 Chrome의 chrome://inspect에서 대상 WebView를 엽니다. Network 탭에서는 첫 문서의 status·redirect·Content-Type과 실패한 JS·CSS·API를, Console에서는 첫 JavaScript 오류를 확인합니다. 뒤에 연쇄적으로 발생한 오류보다 가장 먼저 실패한 요청이나 예외가 원인에 가깝습니다.
기기 WebView와 데스크톱 브라우저는 사용자 데이터, 쿠키, 앱 버전, 네트워크 경로가 같지 않을 수 있습니다. “PC Chrome에서 열린다”는 사실은 서버가 살아 있다는 참고일 뿐 WebView 요청이 성공했다는 증거는 아닙니다. 다음 항목을 나란히 비교합니다.
| 확인 위치 | 정상 기준 | 이상일 때 다음 행동 |
|---|---|---|
| 메인 문서 | 예상한 HTTPS URL과 2xx 응답 | redirect loop, 인증, 서버 route 조사 |
| Content-Type | 문서·JS·CSS에 맞는 MIME type | 서버 header와 CDN 설정 수정 |
| 스크립트 | 2xx이며 HTML 오류 페이지가 아님 | 배포 경로·캐시·파일 hash 비교 |
| API | 예상 origin과 인증 상태 | CORS·쿠키·token 흐름을 별도 점검 |
| Console | 초기 렌더링을 막는 uncaught error 없음 | 첫 stack trace의 소스맵과 배포 버전 확인 |
페이지가 JavaScript에서 API 응답을 받은 뒤에만 그려지는 구조라면 문서가 200이어도 흰 화면일 수 있습니다. 이 경우 Android 설정을 늘리기 전에 실패한 API와 예외 처리부터 고칩니다. 인증 문제는 웹 앱 로그인 상태 진단 체크리스트의 쿠키·CORS·CSRF 순서와 함께 확인할 수 있습니다.
4단계: JavaScript와 저장소는 필요한 기능만 켜기
javaScriptEnabled = true로 화면이 나타났다면 원인은 WebView 고장이 아니라 웹앱의 실행 요구사항과 초기 설정이 맞지 않았던 것입니다. 이 상태에서 끝내지 말고 WebView가 불러올 수 있는 host를 제한하고 외부 페이지가 같은 권한으로 실행되지 않도록 탐색 정책을 둡니다.
JavaScript console을 앱 로그에도 연결하면 현장 재현에서 최소한의 오류 위치를 얻을 수 있습니다. 다만 Console 메시지에도 개인정보나 token이 포함될 수 있으므로 릴리스에서는 원문을 무조건 수집하지 않습니다.
class DiagnosticChromeClient : WebChromeClient() {
override fun onConsoleMessage(message: ConsoleMessage): Boolean {
if (BuildConfig.DEBUG) {
Log.d(
"WebViewConsole",
"${message.messageLevel()} " +
"${message.sourceId()}:${message.lineNumber()}"
)
}
return true
}
}
SPA가 localStorage나 IndexedDB 초기화에 실패했을 때 빈 shell만 남는다면 저장소 설정, 저장 공간, 스키마 migration 오류를 구분합니다. 캐시와 전체 앱 데이터를 먼저 지우면 재현에 필요한 상태와 사용자의 로그인까지 사라집니다. 개발용 테스트 profile에서만 초기 상태와 기존 상태를 나눠 검증합니다.
5단계: HTTP·혼합 콘텐츠·SSL 오류를 우회하지 않기
Android 9(API 28) 이상을 대상으로 하는 앱은 cleartext HTTP가 기본적으로 허용되지 않습니다. 운영 주소가 http://이거나 HTTPS 문서가 HTTP 이미지·스크립트·API를 포함하면 브라우저와 WebView 버전에 따라 차단 결과가 다르게 보일 수 있습니다. 가장 안전한 수정은 문서와 모든 필수 리소스를 HTTPS로 제공하는 것입니다.
진단을 통과시키려고 앱 전체에 cleartext를 허용하거나 MIXED_CONTENT_ALWAYS_ALLOW를 쓰면 전송 중 변조 가능성을 키웁니다. 개발망의 임시 호스트가 꼭 필요하다면 운영 빌드와 분리하고 Network Security Configuration을 좁은 범위로 설계합니다. 운영 문제는 인증서와 HTTPS 배포를 바로잡아 해결합니다.
override fun onReceivedSslError(
view: WebView,
handler: SslErrorHandler,
error: SslError
) {
Log.e("WebViewDiag", "ssl primary=${error.primaryError}")
handler.cancel()
}
handler.proceed()로 계속 진행하거나 사용자에게 “무시” 버튼을 제공하지 않습니다. 인증서의 만료일, host name, intermediate chain, 기기 날짜를 확인해 서버나 인증서 배포를 수정합니다. SSL 오류 화면이 사라졌다는 이유로 인증서 검증을 제거하면 빈 화면보다 더 큰 보안 문제가 남습니다.
Flutter 앱에서 WebView 밖의 API 요청도 함께 실패한다면 Flutter Android API 연결 점검에서 manifest 권한, localhost 의미, HTTPS 인증서를 먼저 분리해 볼 수 있습니다.
6단계: shouldOverrideUrlLoading의 반환값을 반대로 쓰지 않기
shouldOverrideUrlLoading에서 true는 앱이 탐색을 직접 처리했음을 뜻하고, false는 WebView가 계속 처리하도록 둔다는 뜻입니다. 같은 URL을 loadUrl()로 다시 넣고 true를 반환하면 중복 요청이나 redirect 흐름 왜곡을 만들 수 있습니다.
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
val uri = request.url
val isTrusted = uri.scheme == "https" &&
uri.host == "app.example.com"
if (isTrusted) return false
if (uri.scheme == "https" || uri.scheme == "http") {
view.context.startActivity(Intent(Intent.ACTION_VIEW, uri))
return true
}
return true // 별도로 허용·검증하지 않은 scheme은 실행하지 않음
}
scheme만 확인하고 javascript:, intent: 같은 주소를 실행하거나, 문자열의 앞부분만 비교해 host를 판정하지 않습니다. 신뢰 도메인의 정확한 host와 HTTPS 여부를 구조화된 URI 값으로 검사합니다. 외부 웹 탐색이 핵심인 화면이라면 WebView보다 Custom Tabs 또는 기본 브라우저가 사용자 신뢰와 보안 경계를 유지하기 쉽습니다.
앱 링크가 WebView와 외부 앱 사이에서 반복된다면 Android App Links와 딥링크 점검의 manifest, 검증 파일, fallback 흐름을 함께 확인합니다.
7단계: JavaScript bridge가 있다면 탐색 정책과 함께 검토하기
addJavascriptInterface는 웹 콘텐츠가 앱 기능을 호출하는 통로입니다. 신뢰하지 않는 페이지나 iframe이 같은 WebView에 들어올 수 있는데 bridge를 계속 노출하면 단순한 표시 문제를 넘어 앱 권한 오용으로 이어질 수 있습니다. 빈 화면을 고친다는 이유로 범용 bridge를 추가하지 않습니다.
- bridge가 꼭 필요한 동작과 호출 방향을 문서화합니다.
- 허용한 HTTPS origin만 같은 WebView에서 탐색되게 합니다.
- 메시지 payload의 길이·형식·허용 명령을 앱 쪽에서 검증합니다.
- 민감한 앱 기능은 사용자 확인과 앱 자체 권한 검사를 다시 거칩니다.
- 가능하면 AndroidX WebKit의 origin-aware 메시징 API를 검토합니다.
웹 페이지의 문자열 검증만 믿지 말고 native 경계에서 재검증합니다. bridge를 제거했을 때만 빈 화면이 해소된다면 예외가 JavaScript 초기화나 메시지 처리에서 발생하는지 Console과 native stack을 함께 봅니다.
8단계: 렌더러 프로세스가 종료된 WebView는 재사용하지 않기
네트워크와 Console에 뚜렷한 실패가 없는데 화면이 사라지거나 반복해서 흰색으로 돌아가면 onRenderProcessGone을 기록합니다. 콜백을 받은 WebView는 더 이상 사용할 수 없으므로 단순 reload()가 아니라 view hierarchy에서 제거하고 참조를 정리한 뒤 새 WebView를 만들어야 합니다.
override fun onRenderProcessGone(
view: WebView,
detail: RenderProcessGoneDetail
): Boolean {
Log.e("WebViewDiag", "rendererCrash=${detail.didCrash()}")
(view.parent as? ViewGroup)?.removeView(view)
view.destroy()
showWebViewRecoveryScreen()
return true
}
true는 앱이 종료 상황을 처리했다는 뜻입니다. 새 WebView 생성은 Activity·Fragment의 현재 lifecycle을 확인한 별도 복구 함수에서 수행합니다. 종료된 인스턴스를 필드에 남기거나 즉시 같은 참조로 loadUrl을 호출하지 않습니다.
렌더러 종료가 반복되면 큰 bitmap, 무한 애니메이션, WebGL, 동시에 살아 있는 WebView 수와 lifecycle 정리를 조사합니다. 화면을 떠난 WebView를 필요 이상 유지하지 말고 부모에서 제거한 뒤 destroy()합니다. 반대로 회전이나 Compose recomposition마다 정상 WebView를 너무 일찍 파괴하지 않도록 소유 lifecycle을 하나로 정합니다.
9단계: 캐시·서비스 워커 문제는 마지막에 증거를 갖고 확인하기
배포 직후에만 빈 화면이 생기고 새 설치에서는 정상이라면 오래된 HTML과 새 JS chunk 조합, service worker cache, CDN 캐시를 의심할 수 있습니다. 먼저 Network 탭에서 실제로 받은 파일명·status·response header를 기록합니다. 전체 캐시 삭제는 원인을 확인한 뒤 격리된 테스트 profile에서 비교 수단으로 사용합니다.
- HTML은 새 배포를 가리키는데 이전 chunk 이름을 요청하는지 확인합니다.
- 실패한 JS가 404 대신 HTML 오류 문서를 200으로 반환하지 않는지 봅니다.
- 서비스 워커가 관리하는 scope와 새 버전 활성화 시점을 기록합니다.
- 웹앱의 cache key에 build version 또는 content hash가 반영되는지 확인합니다.
- 사용자 데이터 전체 삭제 없이 복구 가능한 update UI를 설계합니다.
PWA 배포와 service worker가 원인이라면 PWA 업데이트가 반영되지 않을 때의 cache lifecycle 점검에서 install·waiting·activate 단계를 분리해 볼 수 있습니다.
보안 우회책과 실제 수정 방향
| 피해야 할 임시 처리 | 왜 문제인가? | 실제 수정 방향 |
|---|---|---|
| SSL 오류에서 proceed() | 가짜 서버와의 연결도 허용할 수 있음 | host·만료·chain·기기 시각 수정 |
| MIXED_CONTENT_ALWAYS_ALLOW | HTTPS 안에 변조 가능한 HTTP 리소스가 들어옴 | 모든 필수 리소스를 HTTPS로 이전 |
| 앱 전체 cleartext 허용 | 불필요한 도메인까지 평문 통신 가능 | 운영 HTTPS, 개발 설정 분리 |
| 릴리스 원격 디버깅 상시 활성화 | 페이지 내부 관찰 경로가 늘어남 | debuggable 빌드에서만 활성화 |
| 모든 URL을 WebView에 로드 | 신뢰하지 않는 콘텐츠가 앱 경계 안으로 들어옴 | scheme·host allowlist와 외부 브라우저 사용 |
| 첫 조치로 앱 데이터 전체 삭제 | 증거와 사용자 로그인·데이터를 함께 잃음 | Network·Console·버전 확인 뒤 최소 복구 |
재현표로 “고쳐진 것처럼 보임”을 막기
개발자 기기 한 대에서 새 설치만 확인하면 쿠키, 기존 캐시, WebView 구현 버전의 차이를 놓칩니다. 같은 릴리스 APK로 다음 조합을 기록합니다.
| 테스트 축 | 최소 비교 | 판정 기준 |
|---|---|---|
| 빌드 | debug / release | 디버깅 설정 없이도 릴리스가 정상 |
| 상태 | 새 설치 / 기존 사용자 상태 | cache·저장소 migration 모두 정상 |
| 네트워크 | Wi-Fi / 모바일 또는 다른 망 | DNS·프록시 조건에 따른 차이 기록 |
| WebView | 지원 범위의 서로 다른 구현 버전 | 특정 버전에만 의존하지 않음 |
| 페이지 | 정상 / 404 / 500 / SSL 실패 | 메인·하위 오류 UI가 분리됨 |
| JavaScript | 정상 / 초기화 예외 | 흰 화면 대신 복구 가능한 오류 표시 |
| 렌더러 | 정상 종료 처리 경로 | 종료 인스턴스 재사용 없이 복구 |
로그에는 앱 버전, Android 버전, WebView package version, 최초 URL의 host, 첫 실패 층을 함께 남깁니다. 정상 기준을 “화면이 보임” 하나로 두지 말고 로그인, 뒤로 가기, 외부 링크, 회전·복귀와 같은 실제 사용 흐름까지 확인합니다.
가장 짧은 해결 순서
- WebView의 크기와 INTERNET 권한, 최초 HTTPS URL을 확인합니다.
- onReceivedError와 onReceivedHttpError에서 메인 프레임 여부를 구분합니다.
- 디버그 빌드의 DevTools로 메인 문서, 하위 리소스, 첫 Console 오류를 같은 시각에 봅니다.
- 웹앱이 요구하는 JavaScript·저장소만 켜고 신뢰 host를 제한합니다.
- HTTP·혼합 콘텐츠·SSL은 허용으로 우회하지 말고 HTTPS 배포를 고칩니다.
- 렌더러가 종료됐다면 기존 WebView를 버리고 lifecycle에 맞춰 새로 만듭니다.
- 기존 사용자 상태와 release 빌드까지 재현표로 확인한 뒤 배포합니다.
이 순서의 핵심은 설정을 많이 켜는 것이 아니라 문서 요청, 웹 실행, 화면 합성, 프로세스를 별도 층으로 관찰하는 것입니다. 원인이 확인된 층만 수정하면 빈 화면을 해결하면서 WebView의 보안 경계도 유지할 수 있습니다.
검증 기준과 참고 자료
이 글은 2026-09-18에 Android와 Chrome 공식 문서를 기준으로 WebView 설정, 오류 콜백, 원격 디버깅, 네트워크 보안, JavaScript bridge와 렌더러 종료 처리를 검수했습니다. WebView와 AndroidX WebKit의 세부 동작은 앱의 minSdk·targetSdk와 구현 버전에 따라 달라질 수 있으므로 배포 시 현재 공식 문서를 다시 확인하세요.
- Android: Build web apps in WebView
- Android API: WebViewClient
- Android API: WebSettings
- Android API: WebChromeClient
- Android: Debug JavaScript console logs
- Chrome DevTools: Remote debug WebViews
- Android: Network security configuration
- Android: Native API access with JavaScript bridge
- Android: Manage WebView memory
- Android: In-app browsing and embedded web content