사진을 크게 보던 화면이 사라지고, 홈 화면이 나왔다. 오류 창도 없었다. 앱이 그냥 없어졌다.
로그에는 WebView 렌더러 프로세스가 종료됐다는 줄이 남아 있었다. TWA 일 때 이 일은 Chrome 의 몫이었다. 탭 하나가 메모리를 견디지 못하면 Chrome 이 탭에 오류 안내를 띄우고 앱 껍데기는 멀쩡했다. WebView 로 옮기면서 그 화면도 사라졌다. 렌더러가 죽었을 때 무엇을 할지 정하지 않은 앱은, 렌더러와 함께 죽는다.
렌더러는 혼자 죽지 않는다
WebView 의 웹 콘텐츠는 앱 프로세스가 아니라 별도의 렌더러 프로세스에서 그려진다. 저사양 기기에서 큰 사진이나 무거운 페이지를 열다 메모리가 모자라면 이 렌더러가 먼저 죽는다. 그러면 WebViewClient.onRenderProcessGone 이 불린다. 여기서 true 를 돌려주면 "이 WebView 는 내가 정리하겠다"는 뜻이고, 처리하지 않거나 false 면 시스템이 앱 프로세스까지 내린다. 앞의 장면이 그것이었다.
처음 대응은 단순했다. 죽은 WebView 는 다시 쓸 수 없으니 뷰 트리에서 떼어 destroy() 하고, 같은 자리에 새 WebView 를 만들어 설정을 다시 입힌 뒤 마지막 URL 을 연다. 디버그 빌드에서 개발자 도구로 페이지를 강제로 크래시시켜 보니 앱은 남고 화면만 다시 불러왔다.
A/S 관리 앱에서 이 복구가 한 번 다시 뚫렸다. 링크를 새 창으로 열면 앱 안에서 확대 가능한 전체 화면 뷰어를 띄우는 기능을 넣었는데, 뷰어가 열린 상태에서 렌더러가 죽으면 앱이 그대로 종료됐다. 로그에는 crash wasn't handled by all associated webviews 라고 적혀 있었다.
한 앱의 WebView 들은 렌더러 프로세스 하나를 같이 쓴다. 렌더러가 죽으면 그 렌더러를 쓰던 모든 WebView 가 각자 onRenderProcessGone 을 받고, 그중 하나라도 처리하지 않으면 앱이 내려간다. 메인 WebView 는 처리하고 있었지만 뷰어의 WebView 는 그 콜백이 없었다. 뷰어 쪽 클라이언트에도 콜백을 달아 자기를 닫고 true 를 돌려주게 했고, 메인 복구 루틴은 시작하기 전에 떠 있는 뷰어부터 닫는다. 뷰어를 연 채로 다시 크래시를 일으켜, 앱은 남고 뷰어는 닫히고 메인 화면이 다시 열리는 것을 확인했다.
같은 자리에서 또 죽는 페이지
살려 내는 것만으로는 끝나지 않았다. 렌더러를 죽인 원인이 그 페이지 자체라면, 새 WebView 는 같은 페이지를 열고 같은 이유로 또 죽는다.
A/S 관리 앱은 짧은 시간 안에 다시 죽으면 마지막 URL 대신 시작 URL 로 돌아가게 했다. 기준은 10초다. 식단서랍은 한 단계 더 나갔다. 시작 URL 조차 다시 열지 않고 재시도 화면에서 멈춘다.
private static final int RENDER_CRASH_LIMIT = 3;
private static final long RENDER_CRASH_WINDOW_MS = 60_000;
private static int renderCrashCount; // recreate() 를 넘어 유지
@Override
public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) {
boolean crashed = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && detail.didCrash();
if (crashed) {
long now = SystemClock.elapsedRealtime();
renderCrashCount = (now - lastRenderCrashAt <= RENDER_CRASH_WINDOW_MS) ? renderCrashCount + 1 : 1;
lastRenderCrashAt = now;
if (renderCrashCount >= RENDER_CRASH_LIMIT) {
renderCrashCount = 0;
showErrorAfterRenderCrash = true;
}
}
recreate();
return true;
}
식단서랍은 WebView 를 바꿔 끼우는 대신 recreate() 로 액티비티를 통째로 다시 만든다. 그래서 카운터가 static 이다. 인스턴스 필드는 recreate() 와 함께 사라지지만 정적 필드는 프로세스가 살아 있는 동안 남는다. 1분 안에 세 번째 크래시가 오면 표시를 남기고, 새로 만들어진 onCreate 가 그 표시를 보면 페이지를 열지 않고 재시도 화면만 띄운 채 돌아간다.
didCrash() 를 따로 본 데도 이유가 있다. 렌더러가 스스로 죽은 것과 시스템이 메모리를 회수하려고 렌더러를 내린 것은 다르다. 후자는 같은 페이지를 다시 열어도 대개 멀쩡하니, 그것까지 세면 멀쩡한 사용자가 재시도 화면을 만난다. 세는 건 진짜 크래시뿐이다.
재시도 버튼은 reloadPage() 를 부른다. 액티비티를 새로 만든 뒤라 WebView 의 getUrl() 이 비어 있으면 reload() 대신 시작 URL 을 연다.
흰 화면 대신 다시 시도
재시도 화면은 원래 네트워크 오류용이었다. onReceivedError 에서 request.isForMainFrame() 일 때만 띄운다. Chrome 이었다면 공룡 화면이 나왔을 자리다.
그런데 공용 Wi-Fi 에 붙은 상태에서 앱을 열면 재시도 화면도 아니고 흰 화면만 남았다. 와이파이가 로그인 페이지로 요청을 가로채면 앱 도메인의 인증서와 맞지 않는 응답이 오고, 이건 onReceivedError 가 아니라 onReceivedSslError 로 온다. 처리하지 않으면 기본 동작은 조용한 취소다. 아무것도 그려지지 않는다.
인터넷에서 찾으면 이 콜백의 흔한 예제는 handler.proceed() 한 줄이다. 흰 화면은 사라지지만 인증서 검사를 통째로 끄는 것이다. 로그인 쿠키를 들고 다니는 앱이 아무 인증서나 믿으면, 같은 공용 Wi-Fi 에서 누군가 중간에 끼어들어도 앱은 기꺼이 이야기를 나눈다. Google Play 도 조건 없이 proceed() 하는 구현을 보안 경고 대상으로 본다.
@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
handler.cancel();
String url = error.getUrl();
if (url != null && isAppHost(Uri.parse(url))) {
progressBar.setVisibility(View.GONE);
errorView.setVisibility(View.VISIBLE);
}
}
진행하지 않고 취소한다. 다만 앱 도메인 요청이 막혔을 때는 사용자가 상황을 알 수 있게 재시도 화면을 띄운다. 외부 리소스의 인증서 오류는 화면을 막지 않고 무시한다.
창 바깥의 일들
Chrome 이 맡던 일은 웹 화면 안쪽만이 아니었다.
targetSdk 를 35 이상으로 올리면 Android 15 부터 edge-to-edge 가 강제된다. 앱 화면이 상태바와 내비게이션 바 밑까지 깔리고, statusBarColor 는 무시된다. 루트 뷰에 인셋 리스너를 달아 시스템 바와 키보드 높이만큼 패딩을 주고, 그 패딩 영역에 보이는 창 배경으로 상태바 색을 만들었다. A/S 관리 앱의 뷰어는 상단 바가 브랜드 색이라, 뷰어 테마의 창 배경을 colorPrimary 로 하고 windowLightStatusBar 를 false 로 두어 흰 아이콘이 보이게 했다.
뒤로가기는 두 번 고쳤다. 웹 히스토리가 있으면 goBack(), 없으면 콜백을 잠깐 끄고 기본 동작에 넘긴다. Android 12 부터는 루트 액티비티에서 뒤로가기를 누르면 앱이 종료되지 않고 백그라운드로만 간다. 껐던 콜백을 다시 켜지 않으면, 앱으로 돌아온 뒤에는 뒤로가기가 웹 히스토리를 타지 않고 곧장 앱을 내려 버렸다. 식단서랍에는 여기에 2초 안에 두 번 눌러야 종료되는 확인을 붙였다. 첫 번째 누름에는 "한 번 더 누르면 종료됩니다" 토스트만 띄운다.
로그인이 풀리는 일도 있었다. WebView 의 쿠키는 메모리에 있다가 주기적으로만 디스크에 쓰인다. 로그인 직후 앱을 내리고 시스템이 프로세스를 정리하면, 아직 쓰이지 않은 세션 쿠키가 사라진다. onPause 에서 CookieManager.getInstance().flush() 를 불러 화면을 떠나는 순간 기록하게 했다.
회전은 막았다. 방향이나 크기가 바뀌면 액티비티가 새로 만들어지는데, saveState 로 히스토리는 돌아와도 입력하던 폼, 펼쳐 둔 목록, 스크립트 상태는 돌아오지 않는다. 매니페스트의 configChanges 에 orientation|screenSize 뿐 아니라 screenLayout|smallestScreenSize 까지 넣었다. 폴더블을 펼치거나 멀티윈도우로 바꿔도 WebView 는 레이아웃만 다시 잡는다.
캐시도 쌓이고 있었다. 사진 업로드에서 카메라 촬영을 고르면 앱 캐시의 camera 폴더에 원본 파일을 만들어 카메라 앱에 넘기는데, 업로드가 끝난 뒤 그 파일을 지우는 코드가 없었다. 앱이 시작할 때 별도 스레드에서 하루 지난 파일을 지운다.
마지막은 알림이다. 뷰어를 보던 중에 알림을 누르면 onNewIntent 가 와서 메인 WebView 가 알림의 화면으로 이동했지만, 뷰어가 위를 덮고 있어 아무 반응이 없는 것처럼 보였다. 이동하기 전에 떠 있는 뷰어를 닫는다. 식단서랍에서 같은 자리에 있던 것은 전체 화면 영상이었고, 똑같이 먼저 전체 화면을 푼다.
모달 안에서 당기면
당겨서 새로고침은 TWA 일 때 Chrome 이 그냥 해 주던 기능이다. 식단서랍 WebView 를 SwipeRefreshLayout 으로 감싸면 될 일이라고 생각했다.
SwipeRefreshLayout 은 자식이 위로 더 스크롤할 수 있는지를 물어서, 맨 위일 때만 당김을 가로챈다. WebView 가 대답하는 것은 문서 전체의 스크롤 위치뿐이다. 문서가 맨 위인 상태에서 모달을 띄우고 모달 안의 긴 목록을 내렸다가 다시 올리면, WebView 는 여전히 "맨 위"라고 대답한다. 목록을 올리려던 손가락에 화면 전체가 새로고침된다. 앱은 손가락 밑에 무엇이 있는지 모른다.
그건 웹만 안다. 그래서 웹에게 물었다. 앱 도메인 프레임에만 주입되는 보안 채널에 터치 판정을 하나 더 실었다.
document.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) return;
var de = document.documentElement, bd = document.body || de;
var r = getComputedStyle(de), bs = getComputedStyle(bd);
var b = r.overflowY === 'hidden' || bs.overflowY === 'hidden' || ob(r) || ob(bs);
for (var el = e.target; !b && el && el.nodeType === 1 && el !== de && el !== bd; el = el.parentElement) {
var cs = getComputedStyle(el);
b = el.scrollTop > 0 || cs.position === 'fixed' || ob(cs);
}
send({ type: 'touch', block: b });
}, { capture: true, passive: true });
손가락이 닿은 요소부터 부모로 올라가며 본다. 그 사이에 스크롤이 내려가 있는 영역, position: fixed 인 레이어(모달·드로어), overscroll-behavior 로 당김을 막은 영역이 있으면 막는다. 모달이 열려 body 스크롤이 잠긴 상태도 막는다. 결과는 {type:'touch', block} 메시지로 앱에 온다. passive: true 라 웹의 스크롤은 조금도 늦어지지 않는다.
남은 문제는 시간이었다. 메시지는 비동기라, 손가락이 내려간 순간에는 판정이 아직 없을 수 있다. 액티비티의 dispatchTouchEvent 에서 ACTION_DOWN 마다 시각을 남기고 "판정 대기 중" 표시를 켠다. SwipeRefreshLayout 이 묻는 콜백은 이렇게 대답한다.
private boolean isRefreshBlocked() {
if (webView == null || webView.canScrollVertically(-1) || touchProbeBlocked) {
return true;
}
return touchProbePending && touchAction != MotionEvent.ACTION_DOWN
&& SystemClock.uptimeMillis() - touchDownAt < TOUCH_PROBE_WAIT_MS;
}
ACTION_DOWN 에서는 막지 않는다. 여기서 막으면 SwipeRefreshLayout 이 시작 좌표를 기록하지 못해 판정이 와도 당김이 동작하지 않는다. 그다음 MOVE 부터는 판정을 최대 150ms 기다린다. 막으라는 판정이면 그 제스처 내내 막는다.
기능이 어느 정도 갖춰진 뒤 LauncherActivity 를 처음부터 끝까지 다시 읽었다. 기능을 하나씩 붙일 때는 보이지 않던 결함이 한꺼번에 나왔고, 모아서 고쳤다.
onDestroy 에서 WebView 를 뷰 트리에 붙인 채 destroy() 하고 있었다. 떼어 낸 뒤 부수고 필드를 비워, 그 뒤에 늦게 도착하는 푸시 토큰 콜백이 죽은 WebView 를 건드리지 않게 했다. data: URL 로 내려받는 파일은 URLDecoder 로 풀고 있었는데, 이건 + 를 공백으로 바꾼다. data URL 은 퍼센트 인코딩만 쓰니 Uri.decode 가 맞다. 다운로드 폴더에 파일을 쓰다 실패하면 IS_PENDING 상태의 빈 항목이 남았다. 실패하면 그 항목을 지운다.
하나하나는 작다. 그런데 모두 "Chrome 이었으면 생기지 않았을 문제"라는 공통점이 있었다.
Chrome 이 대신 해 주던 일
연재를 시작할 때 이 전환은 포장을 바꾸는 일이었다. 같은 웹 서비스를 TWA 대신 WebView 로 감싸면 Play 의 비공개 테스트 문턱을 넘을 수 있다. 화면은 같고, 서버도 그대로였다.
다 옮기고 보니 바뀐 것은 포장이 아니라 책임의 경계였다. TWA 의 앱은 Chrome 에게 주소 하나를 건네는 껍데기였다. 파일 선택, 다운로드, 새 창, 인증서 경고, 상태바, 쿠키, 렌더러가 죽었을 때의 화면까지 Chrome 이 정했다. WebView 로 옮기자 그 하나하나가 콜백 하나, 플래그 하나가 되어 앱으로 넘어왔다. 아무것도 정하지 않으면 기본값이 정하고, 그 기본값은 흰 화면이거나 앱 종료인 경우가 많았다.
웹 서비스를 앱으로 내놓는다는 건 웹을 그대로 보여 주는 일이 아니었다. 웹이 다 하지 못하는 순간, 네트워크가 끊기고 메모리가 모자라고 사용자가 엉뚱한 곳을 당기는 순간에 앱이 무엇을 할지 하나씩 정해 주는 일이었다. 웹은 거의 그대로다. 바깥에서 받쳐 주는 쪽이 두꺼워졌을 뿐이다.
이 연재는 여기서 마친다. 두 앱은 지금도 같은 구조 위에서 웹이 바뀔 때마다 조용히 따라가고 있다.