3년 묵은 웹뷰 에러의 범인을 찾아서 (브릿지 재설계)

Java object is gone, 그리고 두 GC 영역 사이

아무도 자기 문제라고 말하지 않는 에러

사내 센트리에 3년 넘게 살아 있는 이슈가 하나 있었다.

text
Uncaught Error: Java object is gone

2주에 500건 정도가 꾸준히 쌓였고, 사용자 통계를 붙여 보니 전부 안드로이드였다. iOS에서는 단 한 건도 없었다.

이 에러가 발생하면 사용자의 특정 이벤트가 유실되거나 브릿지 메서드 호출 자체가 실패했다. 결제나 인증처럼 네이티브를 반드시 거쳐야 하는 흐름에서 터지면 사용자는 버튼을 눌러도 아무 일도 일어나지 않는 화면을 보게 된다.

그런데도 이 이슈는 3년을 살아남았다. 기술적으로 어려워서만은 아니었다. 에러 메시지에 Java가 들어가니 웹팀은 네이티브 문제라고 생각했고, 스택트레이스가 JS에서 잡히니 네이티브팀은 웹 코드 문제라고 생각했다. 경계에 있는 문제는 이렇게 아무의 문제도 아닌 채로 늙어간다.

리드를 맡으면서 이 이슈부터 닫고 싶었다. 책임 소재를 따지는 대신 경계 양쪽을 모두 들여다보면 적어도 실패가 만들어지는 경로는 찾을 수 있을 것이라고 생각했다.

미리 밝혀둘 게 있다. 나는 안드로이드 개발자가 아니다. 이 글에 나오는 ART, JNI, WebView 내부 구조는 이번 일 때문에 처음 파본 것들이고, 파면 팔수록 내가 얼마나 모르는지가 선명해졌다.

조사한 내용은 안드로이드 개발자들과 프론트엔드 팀 앞에서 발표한 뒤 사내 안드로이드 개발자에게 검증을 받고 문서로 정리했다. 그 과정에서 틀린 서술을 여러 군데 고쳤지만, 여전히 부정확한 부분이 남아 있을 수 있다. 안드로이드 쪽 서술은 그 전제로 읽어주시면 좋겠다.

발표를 준비하면서 제일 힘들었던 건 소스를 읽는 일이 아니었다. 어제 처음 본 개념을 오늘 남들 앞에서, 그것도 그 분야를 매일 다루는 사람들 앞에서 설명해야 하는 상황이었다. 아는 척은 하고 싶지 않은데 결론은 분명하게 말해야 했고, 그 둘을 동시에 하는 게 생각보다 훨씬 어려웠다. 결국 확인한 것과 확인하지 못한 것을 갈라서 말하는 수밖에 없었는데, 이 글도 같은 방식으로 썼다.

결론부터 적으면 이 글은 “GC가 객체를 수거했다”로 끝나지 않는다. 추적 끝에 나온 것은 서로 다른 실패가 같은 오류 문자열 하나로 합쳐지는 지점이었다. 원인을 하나로 특정하는 대신 그 지점에 진입할 수 있는 조건을 구조에서 제거하고, 다음에 같은 자리에서 무엇이 터지든 보이도록 관측 경로를 만들었다.

에러 메시지가 준 첫 단서

출발점은 에러 메시지 자체였다.

gone, 사라졌다는 표현은 객체의 수명이나 참조가 끊겼다는 신호였다. 이것만으로 곧바로 GC를 원인으로 확정할 수는 없다. 명시적인 해제, 페이지 네비게이션, 브릿지 재등록 같은 수명주기 변화도 객체를 더 이상 사용할 수 없는 상태로 만들 수 있기 때문이다. 다만 GC와 객체 수명 관리가 관련됐을 가능성은 높았다.

iOS 무발생은 원인 후보를 좁히는 데 거의 기여하지 않았다. Android WebView는 V8을, WKWebView는 JavaScriptCore를 쓰고 브릿지 모델 자체가 다르다. 같은 웹 코드를 쓴다는 사실만으로 두 플랫폼이 같은 수명 관리 경로를 지난다고 볼 수 없다.

실제로 조사 범위를 좁힌 근거는 따로 있었다.

  • 에러 문자열 자체가 Java object를 지목했다.
  • 오류가 Android에서만 발생했다.
  • 실패 지점이 addJavascriptInterface로 노출한 브릿지 호출 경로였다.
  • Chromium 소스에 동일한 오류 코드가 실제로 존재했다.

그렇다면 다음 질문은 이것이었다. JS의 window.AndroidBridge와 Android의 Java 객체는 어떤 구조로 연결되어 있으며, 각각의 수명은 누가 관리하는가.

원인을 추론하려면 이 구조부터 정확히 알아야 했다. 그래서 Chromium 소스와 공식 설계 문서를 파기 시작했다.

구조 1: Android WebView와 Java 브릿지

WebView는 브라우저이면서 동시에 네이티브 뷰다

Android WebView는 앱 안에 포함된 Chromium 기반의 네이티브 뷰다.

Android 8.0부터 WebView는 기본적으로 멀티프로세스 모드로 동작한다. 웹 콘텐츠를 실행하는 렌더러는 앱과 분리된 격리 프로세스에서 실행되고, 브라우저 역할을 하는 Chromium 코드와 Java 객체는 앱 프로세스 쪽에서 만난다. 저사양 기기 등 일부 구성에서는 단일 프로세스로 떨어질 수 있지만, 이 글의 구조 분석은 어느 쪽이든 동일하게 성립한다.

브릿지를 중심으로 보면 구조는 다음과 같다.

안드로이드 웹뷰 브릿지의 소유권·참조 구조

JS에서 보이는 window.AndroidBridge는 실제 Java 객체가 아니다. Java 객체를 가리키는 object_id를 가진 래퍼다.

렌더러는 이 래퍼를 통해 앱 프로세스로 동기 Mojo IPC를 보내고, 앱 프로세스의 브라우저 C++ 레이어는 object_id로 실제 Java 객체를 찾는다. C++ 쪽 객체 테이블은 Java 객체를 JNI 약한 참조로 보관한다.

Mojo는 크로미움이 프로세스끼리 통신할 때 쓰는 IPC 프레임워크다. .mojom 파일에 인터페이스를 선언하면 컴파일러가 양쪽 바인딩을 만들어주는, gRPC와 비슷한 구조다.

Mojo 호출은 기본이 비동기다. 메시지를 보내고 하던 일을 계속하다가 답이 오면 콜백으로 받는다. 그런데 인터페이스 선언에서 메서드에 [Sync]를 붙이면 부르는 쪽이 답이 올 때까지 멈춘다. 카톡을 보내는 것과 전화를 거는 것의 차이다.

브릿지가 전화를 택한 이유는 JS 문법에 있다. bridge.getUserInfo()는 Promise가 아니라 값을 바로 돌려받는 형태이고, JS에는 일반 함수 호출 중간에 멈췄다가 재개할 수단이 없다. 값을 반환하려면 렌더러가 실제로 멈춰서 기다리는 수밖에 없다. 설계 문서도 이런 동기 메시지는 크로미움에서 거의 쓰지 않는다고 적어두었다.

여기서 중요한 점이 있다. Java 객체를 실제로 살려 두는 것은 C++의 약한 참조가 아니라 Java 쪽의 강한 참조다.

렌더러는 이 래퍼를 통해 앱 프로세스로 동기 Mojo IPC를 보내고, 앱 프로세스의 브라우저 C++ 레이어는 object_id로 실제 Java 객체를 찾는다. C++ 쪽 객체 테이블은 Java 객체를 JNI 약한 참조로 보관한다.

여기서 중요한 점이 있다. Java 객체를 실제로 살려 두는 것은 C++의 약한 참조가 아니라 Java 쪽의 강한 참조다.

현재 Chromium 구현에는 두 컬렉션이 관여한다.

java
// content/public/android/java/src/org/chromium/content/browser/JavascriptInjectorImpl.java

// The set is passed to native and stored in a weak reference, so ensure this
// strong reference is not optimized away by R8.
@DoNotInline private final Set<Object> mRetainedObjects = new HashSet<>();
private final Map<String, InjectedInterface> mInjectedObjects = new HashMap<>();

mInjectedObjects는 이름을 붙여 등록한 인터페이스를 이름으로 찾을 수 있게 보관한다. mRetainedObjects는 브릿지가 아직 사용 중이라고 판단한 Java 객체를 담는 보관 명부다.

두 컬렉션 모두 Java 객체를 강하게 참조한다. 반면 C++은 이 보관 명부 자체를 약하게 참조한다. C++이 Java 객체와 명부를 모두 강하게 물면 WebView까지 연결되는 순환 참조가 생기기 때문이다. 주석에 R8 최적화 방지 어노테이션까지 붙어 있는 건, Java 쪽 강한 참조가 실수로 사라지면 안 된다는 걸 컴파일러에게도 알려야 했다는 뜻이다.

물류창고에 비유하면 이렇다. JS는 보관증을 들고 있고 실물은 ART 창고에 있다. 창고는 보관 명부에 적힌 물건만 지켜준다. 보관증이 유효하다는 사실과 실물이 아직 있다는 사실은 서로 다른 이야기다.

Chromium java-bridge 문서의 클래스 관계도. Browser Side의 Java VM·C++와 Renderer Side의 C++·JavaScript VM이 나뉘어 있다

출처: https://chromium.googlesource.com/chromium/src/+/main/android_webview/docs/java-bridge.md

named 객체와 transient 객체는 수명이 다르다

브릿지 객체를 모두 같은 방식으로 생각하면 안 된다.

addJavascriptInterface(object, name)로 등록한 객체는 이름이 있는 named 객체다. 정상적인 상태라면 mInjectedObjects가 이 객체를 강하게 보유하며, removeJavascriptInterface(name)로 등록을 취소하기 전까지 유지한다.

반면 Java 메서드가 새로운 Java 객체를 반환하면, 그 객체도 JS에서 호출할 수 있는 익명 브릿지 객체로 변환될 수 있다.

kotlin
class AndroidBridge {
    @JavascriptInterface
    fun getHandler(): Handler {
        return Handler()
    }
}
js
const handler = window.AndroidBridge.getHandler();
handler.execute();

이때 handler에 대응하는 Java 객체는 이름 없이 주입된 transient 객체다. Java 코드가 별도의 강한 참조를 가지고 있지 않다면, 브릿지가 JS 래퍼의 생존 여부를 보고 수명을 대신 관리해야 한다.

렌더러에서 transient 객체의 JS 래퍼가 더 이상 참조되지 않아 수거되면, 브라우저 쪽으로 ObjectWrapperDeleted 신호가 전달된다. 브라우저는 해당 객체가 더 이상 어느 프레임에서도 사용되지 않는다고 판단하면 보관 명부에서 Java 객체를 제거할 수 있다. 그 뒤 ART GC가 실행되면 실제 Java 객체가 수거된다.

Chromium 설계 문서는 이 상태에 이름까지 붙여 두었다.

It is possible that a garbage-collected object still has JavaScript wrappers (that is, remains “injected”). In that case, attempts to call methods of this object will fail.

The only difference between “Not retained, injected” and “Ordinary Java object” states is that in the former, the Java object is still “known” to the JavaScript side, so it can still make calls to it.

Not retained, injected. 브릿지가 더 이상 보호하지 않지만 JS는 여전히 호출할 수 있는 상태다. 이 글이 추적한 실패는 이 상태에 있는 객체를 호출했을 때 일어난다.

브릿지 Java 객체의 상태 전이

named 객체는 mInjectedObjects가 별도로 보유하므로 단순한 JS 래퍼 GC만으로 사라져서는 안 된다. named 객체에서 같은 문제가 발생했다면 추가적인 수명 변화가 있었다는 뜻이다.

구조 2: 브릿지가 WebView에 바인딩되는 시간

addJavascriptInterface를 호출하는 순간과 JS에서 객체를 사용할 수 있게 되는 순간은 다르다.

Android 공식 문서에 따르면 인터페이스의 추가와 제거는 다음 페이지 로드부터 JS에 반영된다. 객체는 모든 프레임에 같은 이름으로 주입되므로 iframe에서도 접근할 수 있다.

브릿지 등록·주입 라이프사이클과 재등록 불일치 창

여기서 재등록이 중요하다. 소스에서 확인되는 것과 버전에 따라 달라지는 것을 나눠서 봐야 한다.

JavascriptInjectorImpl의 등록 코드는 Map.put이다.

java
mInjectedObjects.put(
    name, new InjectedInterface(object, requiredAnnotation, matcher.serialize()));

같은 이름으로 다시 등록하면 이 맵의 항목은 그 자리에서 새 객체로 교체된다. 다음 페이지 로드를 기다리지 않는다.

반면 JS 측 바인딩 변화는 공식 API 문서대로 다음 페이지 로드에서 반영된다. 현재 문서의 래퍼는 여전히 이전 객체를 가리킬 수 있다.

그래서 다음 불일치가 잠시 존재한다.

text
Java 명부(mInjectedObjects):  새 객체
현재 문서의 JS 래퍼:          이전 객체

그 사이 이전 Java 객체가 계속 보호받는지는 별개 문제다. mInjectedObjects의 참조가 빠져도 mRetainedObjects나 네이티브 holder가 그 객체를 잡고 있을 수 있다. 현재 브라우저 측 구현은 객체가 나중에 unnamed 상태가 될 가능성을 고려해 holder를 추가한다. 이 보완이 crbug.com/333171288 계열의 수정이고, 그 이전 WebView에서 같은 코드가 어떻게 동작했는지는 버전을 고정하지 않으면 말할 수 없다.

정리하면 불일치 창이 열린다는 것까지는 소스로 확인되고, 그 창에서 이전 객체가 실제로 무방비였는지는 WebView 버전에 달려 있다.

용어 하나를 짚어둔다. 재등록으로 이름을 잃은 객체를 transient 객체라고 부르면 안 된다. 설계 문서에서 transient는 Java 메서드가 반환해 JS에 주입된 별도의 객체를 가리키고, 문서는 named 객체가 transient가 되는 경로는 없다고 명시한다. 둘은 같은 상태로 흘러갈 수 있어도 서로 다른 개념이다.

그래서 브릿지 등록 위치가 중요하다. onResume처럼 여러 번 호출되는 수명주기 콜백에서 매번 새로운 브릿지 객체를 생성해 등록한다면 의도하지 않은 재등록이 된다. 동일 객체를 반복 등록하는 것과 매번 새로운 객체를 같은 이름으로 등록하는 것도 구분해야 한다.

구조 3: 브릿지 호출 한 번의 여정

window.AndroidBridge.getUserInfo() 한 줄은 생각보다 긴 경로를 지난다.

브릿지 메서드 호출 한 번의 여정

처음 프로퍼티에 접근할 때 렌더러는 브라우저 쪽에 해당 메서드가 존재하는지 묻는다. 결과는 래퍼에 캐시될 수 있지만, 최초 확인은 동기 IPC다.

실제 호출도 동기다. 결과가 돌아올 때까지 JS 실행은 멈춘다. 반면 Java 메서드는 앱의 메인 스레드가 아니라 WebView가 관리하는 전용 백그라운드 스레드에서 실행된다.

문제가 되는 것은 순환 대기다. 브릿지 스레드가 메인 스레드로 작업을 보내고 그 결과를 동기적으로 기다리는 동시에, 메인 스레드가 브릿지 호출의 반환을 기다리고 있으면 양쪽이 서로를 막는다. 설계 문서가 “브라우저 UI 스레드는 렌더러 요청 처리에 관여해서는 안 된다”고 못박은 이유이고, crbug.com/438255가 그 사례다. 메인 스레드로 비동기 전달만 하고 즉시 반환하는 것은 문제가 없다.

경계를 넘을 수 있는 타입도 제한적이다.

  • 원시값
  • 문자열
  • 1차원 배열
  • length 프로퍼티를 가진 array-like 객체와 ES6 타입 배열
  • 이미 주입된 Java 객체 (JS → Java)
  • Java 메서드가 반환하는 새로운 Java 객체 (Java → JS)

임의의 JS 객체를 Java 딕셔너리처럼 자유롭게 넘길 수 있는 것은 아니다. 대상 Java 타입에 따라 0, 빈 문자열, null 등으로 강제 변환될 수 있다.

오버로드 해석도 Java 개발자가 기대하는 방식과 다르다. Android Java Bridge는 전달된 인자 개수가 같은 오버로드 중 임의의 하나를 선택한 뒤 타입을 강제 변환한다. 설계 문서가 스스로 “an arbitrary overloaded method”라고 적어 둔 동작이다. 오버로드가 많은 브릿지 API를 만들면 호출 결과를 직관적으로 예측하기 어렵다.

이런 특성 때문에 브릿지 API는 작고 명시적일수록 좋다.

오류 코드가 세팅되는 두 곳

브라우저 프로세스의 메서드 호출 코드는 object_id로 찾은 객체의 JNI 약한 참조를 실제 Java 로컬 참조로 복구하려 시도한다. 복구에 실패하면 오류 코드를 세팅한다.

cpp
// content/browser/android/java/gin_java_method_invocation_helper.cc
obj = object_->GetLocalRef(env);

if (obj.is_null() && cls.is_null()) {
  SetInvocationError(mojom::GinJavaBridgeError::kGinJavaBridgeObjectIsGone);
  return;
}

여기까지가 처음에 세운 가설과 맞는 그림이었다. 그런데 소스를 더 읽다가 같은 오류 코드가 렌더러 쪽에서도 세팅된다는 걸 발견했다.

cpp
// content/renderer/java/gin_java_function_invocation_helper.cc
if (auto* remote = object->GetRemote()) {
  base::ListValue result_wrapper;
  if (remote->InvokeMethod(method_name_, std::move(arguments), &error,
                           &result_wrapper)) {
    if (!result_wrapper.empty()) {
      result = base::Value::ToUniquePtrValue(result_wrapper[0].Clone());
    }
  } else {
    error = mojom::GinJavaBridgeError::kGinJavaBridgeObjectIsGone;
  }
}

if (!result.get()) {
  args->isolate()->ThrowException(v8::Exception::Error(gin::StringToV8(
      args->isolate(), base::StrCat({"Error invoking ", method_name_, ": ",
                                     GinJavaBridgeErrorToString(error)}))));
}

else 분기는 Java 객체의 생사와 아무 관련이 없다. 동기 Mojo 호출 자체가 실패했을 때 들어가는 자리다.

즉 이 오류 문자열은 두 개의 서로 다른 실패를 덮고 있다.

  1. Java 로컬 참조 복구 실패. 브라우저가 약한 참조를 따라갔는데 referent가 이미 사라진 경우다. 지금까지 따라온 경로다.
  2. 동기 Mojo 호출 실패. 렌더러가 브라우저 측 브릿지 엔드포인트에 호출을 전달하지 못한 경우다. 프레임이나 WebContents 파괴와 호출이 겹치는 경쟁, IPC 엔드포인트 종료 등이 여기 포함될 수 있다.

두 번째 경로에서는 Java 객체가 멀쩡히 살아 있어도 같은 문자열이 나온다.

같은 오류 문자열로 수렴하는 두 실패 경로

이 발견이 분석 전체의 성격을 바꿨다. 센트리에서 하나의 이슈로 묶여 있던 500건이 단일 원인의 500건이라는 보장이 없다는 뜻이기 때문이다.

한 가지 더. 위 코드에서 보이듯 브라우저 프로세스는 JS 예외를 직접 던지지 않는다. 오류 코드를 동기 Mojo 응답으로 돌려줄 뿐이고, 렌더러가 그 코드를 문자열로 바꿔 V8에 Error를 던진다. 현재 구현 기준으로 실제 메시지에는 메서드 이름이 붙는다.

text
Uncaught Error: Error invoking getUserInfo: Java object is gone

우리 센트리에 기록된 제목은 Uncaught Error: Java object is gone이었다. 당시 WebView가 prefix 없는 버전이었는지, 센트리의 그룹화나 우리 쪽 오류 래핑 과정에서 축약된 것인지는 원본 exception.value가 없어 확정할 수 없다.

어떤 객체가 이 상태에 빠지는가

소스 기준으로 조사해야 할 후보는 여섯 가지다. 어느 것도 단독 원인으로 확정한 것은 아니다.

transient 반환 객체

Java 메서드가 새로운 Java 객체를 반환하면 익명 브릿지 객체가 만들어진다. 앱이 그 객체를 별도로 소유하지 않는다면 브릿지의 보관 명부가 유일한 강한 참조일 수 있다. JS 래퍼와 브라우저의 holder 상태가 어긋나면 Java 객체가 먼저 수거될 가능성이 있다.

재등록으로 이름을 잃은 이전 객체

구조 2에서 본 불일치 창이다. 이 경로는 WebView 버전별 구현 차이의 영향을 받는다.

명시적인 제거 또는 수명주기 정리

removeJavascriptInterface, WebView 교체, Fragment 뷰 파괴, 컨테이너 교체 등으로 앱이 생각한 수명과 실제 WebView 수명이 달라질 수 있다.

Chromium 구현 버그

현재 소스에도 이 계열의 holder 관리 수정이 남아 있다. 장애가 특정 WebView 버전에 집중됐다면 앱 코드만이 아니라 WebView 버전 분포도 함께 확인해야 한다.

네비게이션과 호출 사이의 경쟁

네비게이션은 이전 document의 window와 wrapper를 정리한다. 동시에 브릿지 호출이 진행 중이라면 객체 테이블 조회, 명부 갱신, JNI 참조 변환 사이에 좁은 경쟁 구간이 생길 수 있다.

브릿지 엔드포인트 사용 불가

프레임 teardown, WebContents 파괴, IPC 엔드포인트 종료와 호출이 겹치면 동기 Mojo 호출이 실패하고 렌더러가 같은 오류 코드를 세팅한다. 이 경우 Java 객체의 수명은 아무 관련이 없다. 앞의 네비게이션 경쟁과 겹칠 수 있는데, 네비게이션이 진행 중인 호출의 브라우저 측 상대를 없앨 수 있기 때문이다.

여기서 onRenderProcessGone을 후보에 넣지 않은 이유를 적어 둔다. 렌더러 프로세스가 종료되면 그 안의 JS 실행 환경도 사라진다. 예외를 던질 V8도, 센트리로 보낼 SDK도 없다. 공식 문서도 onRenderProcessGone이 호출된 WebView는 더 이상 사용할 수 없다고 명시한다. 렌더러 종료는 별도의 종료 경로이자 별도의 관측 신호로 다뤄야 한다.

가설을 관측 가능한 예측으로 바꾸다

여기까지의 구조를 바탕으로 세 가지 예측을 세웠다.

  1. 실패 직전까지 JS 쪽 브릿지 래퍼는 존재할 수 있다.
  2. 렌더러 쪽 GC가 래퍼 정리와 holder 갱신에 관여한다면, GC를 유도했을 때 취약한 수명 상태가 표면화될 수 있다.
  3. 실제 실패 시점에는 ART 힙에서 대응하는 Java 객체가 수거된 상태여야 한다.

이 예측은 각각 다른 도구로 확인해야 했다.

JS 힙은 Chrome DevTools가 볼 수 있다. Java 객체가 있는 ART 힙은 Android Studio Profiler가 봐야 한다. 브라우저 C++의 객체 테이블과 보관 명부는 양쪽 도구 어디에서도 완전히 보이지 않는다.

원인의 사슬과 관측 도구 커버리지 갭

이 문제가 한 팀의 도구만으로 풀리지 않았던 이유다.

가설 검증

예측 1: JS 래퍼는 살아 있는가

Android 테스트 기기를 chrome://inspect로 연결하고 Chrome DevTools의 Memory 탭에서 힙 스냅샷을 확인했다.

JS 컨텍스트에는 window.AndroidBridge가 존재했고 브릿지 메서드도 보였다. 웹 코드에서 보면 객체가 사라진 것처럼 보이지 않았다.

다만 이것만으로 Java 객체의 생존까지 확인할 수는 없다. JS에서 보이는 것은 object_id를 가진 래퍼이기 때문이다.

Chrome DevTools 메모리 탭 힙 스냅샷. TransientBridgeObject가 남아 있고 보관자에 AndroidBridge와 Window가 보인다

예측 2: 렌더러 GC로 재현할 수 있는가

DevTools Memory 탭의 Collect garbage 버튼으로 렌더러 GC를 유도했다.

그 직후 방금까지 성공하던 브릿지 호출에서 Java object is gone이 발생했다. 운영 환경에서 낮은 확률로 나타나던 실패를 테스트 환경에서 반복해서 표면화할 수 있게 된 것이다.

여기서 확인한 것은 두 가지다. 실패가 JS 코드의 단순한 메서드 부재가 아니었다는 것, 그리고 렌더러의 객체 수명 정리가 실패 사슬에 참여했다는 것.

그러나 이것만으로 운영 장애가 반드시 저사양 기기의 메모리 압박 때문에 발생했다고 단정할 수는 없다. 메모리 압박, 기기 사양, WebView 버전과 오류율의 상관관계를 별도로 측정하지 않았기 때문이다.

예측 3: ART에서 Java 객체가 실제로 수거되는가

Chrome DevTools는 ART 힙을 볼 수 없다. 그래서 네이티브팀에 지금까지의 관측 범위를 정리해 전달했다.

JS 래퍼는 존재한다. 렌더러 GC를 유도하면 실패가 재현된다. 이제 Android Studio Profiler에서 대응하는 Java 객체가 실제로 수거되는지 확인해야 한다.

함께 Android Studio Profiler를 확인한 결과, 실패가 재현되는 과정에서 관련 브릿지 Java 객체가 ART GC에 의해 수거되는 것을 관측했다.

세 예측은 첫 번째 실패 경로와 일치했다. JS 래퍼가 존재하더라도 반대편 Java 객체가 사라질 수 있고, 그 상태에서 호출하면 브라우저가 약한 참조를 복원하지 못해 오류를 반환한다.

iOS에서는 왜 같은 에러가 없었나

같은 웹 비즈니스 로직을 iOS에서도 쓰고 있었다. 그러나 브릿지 구조가 달랐다.

WKWebView에서는 WKScriptMessageHandler를 등록하고 JS가 이렇게 메시지를 보낸다.

js
window.webkit.messageHandlers.bridge.postMessage(message);

전달되는 값이 문자열로만 제한되지는 않는다. NSNumber, NSString, NSDate, NSArray, NSDictionary, NSNull이 허용된다. 다만 이것들은 모두 이다. JS가 네이티브 객체의 identity를 받는 경로가 아니다.

여기가 결정적인 차이다. Android의 addJavascriptInterface는 Java 객체를 JS에서 메서드 호출이 가능한 객체처럼 노출하고, Java 메서드가 또 다른 Java 객체를 반환하면 transient 브릿지 객체까지 만든다. WKScriptMessageHandler는 이름과 핸들러를 연결할 뿐이라 JS가 Swift나 Objective-C 객체를 가리키는 핸들을 받아 그 객체의 메서드를 호출하는 구조가 아니다.

그래서 Android Java Bridge의 object_id → weak JNI reference → ART object 같은 수명 추적 경로가 아예 없다.

이게 Apple이 메시지 전달 철학을 선택해서 그런 것만은 아니다. WKWebView는 웹 콘텐츠를 별도 프로세스에서 실행하기 때문에 앱에 JSContext를 넘겨주지 않는다. UIWebView 시절에는 JSContextJSExport로 네이티브 객체를 JS에 직접 노출할 수 있었고, 그쪽이 오히려 Android의 브릿지 모델과 닮아 있었다. 프로세스를 가르면서 객체를 공유할 방법 자체가 사라진 셈이다.

iOS가 수명 문제에서 자유로운 것은 아니다. 방향이 반대일 뿐이다.

WebKit 구현에서 WKUserContentController는 등록된 핸들러를 RefPtr로 강하게 보유한다. 그래서 뷰 컨트롤러가 자기 자신을 핸들러로 등록하면 WebView에서 configuration, user content controller를 거쳐 다시 자기 자신으로 돌아오는 retain cycle이 만들어진다. removeScriptMessageHandler(forName:)를 적절한 시점에 호출하거나 weak proxy로 감싸는 패턴이 관례가 된 이유다.

여기에 함정이 하나 더 있다. WKWebViewConfiguration은 WebView에 전달될 때 복사된다. 등록할 때 쓴 configuration 객체와 webView.configuration은 서로 다른 인스턴스라서, 해제는 반드시 webView.configuration.userContentController에서 해야 한다.

iOS 쪽에서 한 가지 더 짚어둘 게 있다. iOS 14부터는 WKScriptMessageHandlerWithReply가 있어서 네이티브가 JS에 직접 응답할 수 있고, JS 쪽에서는 postMessage가 Promise를 반환한다. 응답 상관관계를 플랫폼이 대신 해결해주는 것이다.

그럼에도 공통 계층에 requestId를 둔 이유는 Android에 같은 것이 없기 때문이다. 프로토콜을 하나로 유지하려면 두 플랫폼이 모두 만족하는 최소 공통분모를 잡아야 했고, 그게 requestId 기반 상관관계였다. iOS만 놓고 보면 과설계지만, 웹 코드가 플랫폼을 구분하지 않게 만드는 값으로는 충분했다.

두 플랫폼의 실패 형태는 다르다. Android는 약한 참조를 택해 조기 수거라는 꼬리를 얻었고, iOS는 강한 참조를 택해 릭이라는 꼬리를 얻었다. 그래도 교훈은 같았다. 웹과 네이티브가 객체를 공유한다고 생각할수록 수명 문제가 프로토콜 밖으로 숨어든다.

해결 1: 브릿지 수명을 앱이 명시적으로 소유하다

원인이 Android 브릿지의 수명 경로에 걸쳐 있었기 때문에 네이티브 변경이 필요했다.

첫 번째 원칙은 브릿지 객체의 소유자를 명확히 하는 것이었다.

kotlin
class WebViewContainer(
    private val bridgeDependencies: BridgeDependencies,
) {
    // WebView와 수명을 같이하는 강한 참조
    private val bridge = WebBridge(bridgeDependencies)

    fun setup(webView: WebView, url: String) {
        webView.addJavascriptInterface(bridge, "AndroidBridge")
        webView.loadUrl(url)
    }
}

정상적인 named 인터페이스라면 WebView 내부의 mInjectedObjects도 객체를 강하게 보유한다. 따라서 앱이 별도의 필드로 참조하는 것만으로 근본 원인이 자동으로 증명되는 것은 아니다.

그럼에도 애플리케이션 레벨의 강한 소유권을 둔 이유는 분명했다. 브릿지 수명을 WebView 내부 구현에만 맡기지 않게 되고, 코드만 읽어도 소유자가 누구인지 알 수 있으며, 수명주기 콜백에서 임시 객체를 계속 만들어 등록하는 실수를 막는다. WebView 버전별 내부 구현 차이에 대한 방어선이 생기는 것은 덤이다.

두 번째 원칙은 등록을 한 번만 수행하는 것이었다. loadUrl 전에 등록하고, 세션 중 같은 이름으로 새 객체를 재등록하지 않고, 페이지 전환마다 removeJavascriptInterface와 재등록을 반복하지 않는다. WebView를 폐기할 때만 수명을 함께 끝낸다.

세 번째 원칙은 Java 객체를 반환하지 않는 것이었다. 인터페이스를 JSON 문자열 하나를 받는 단일 진입점으로 줄였다.

kotlin
class WebBridge(
    private val router: BridgeRouter,
) {
    @JavascriptInterface
    fun postMessage(messageJson: String) {
        router.route(messageJson)
    }
}

브릿지 메서드가 Java 객체를 반환하지 않으면 transient 브릿지 객체가 생성되지 않는다. JS 래퍼의 수명에 따라 Java 객체 보호 상태가 바뀌는 경로 자체가 크게 줄어든다.

신규 프로젝트라면 WebViewCompat.addWebMessageListener도 우선 검토할 수 있다. 이 API는 origin allowlist, source origin, main-frame 여부, JavaScriptReplyProxy 기반 응답 경로를 제공한다. 다만 WebViewFeature.isFeatureSupported로 기능 지원 여부를 확인하고, 지원하지 않는 버전을 위한 fallback 정책도 함께 설계해야 한다.

해결 2: 호출을 요청·응답 계약으로 만들다

수명 구조만 고치고 끝내지는 않았다.

이번 일로 배운 것은 브릿지라는 경계 자체가 장애 지점이라는 사실이었다. OS가 다르고, 프로세스가 다르고, GC가 다르고, 스레드가 다르다. WebView 버전마다 내부 구현이 바뀌고, 페이지 네비게이션으로 JS 실행 컨텍스트도 교체된다. 이 경계에서는 장애가 다시 날 수 있다고 전제하는 편이 맞다.

그런데 기존 구조는 웹에서 네이티브 메서드를 호출하는 것이 전부였다. 호출이 실제로 처리됐는지, 중간에 유실됐는지, 네이티브 내부에서 실패했는지 웹이 일관된 형태로 알 수 없었다. 3년간 아무도 규모를 체감하지 못한 이유이기도 하다.

그래서 호출을 요청·응답 계약으로 다시 설계했다. 요청마다 requestId를 발급하고, pending Map에 Promise의 완료 함수를 저장한다. 네이티브 응답에는 같은 requestId를 넣는다. 일정 시간 안에 응답이 오지 않으면 timeout으로 실패시킨다.

ts
const PROTOCOL_VERSION = 1;

type BridgeRequest = {
  protocolVersion: number;
  requestId: string;
  method: string;
  params?: unknown;
};

type BridgeResponse =
  | { protocolVersion: number; requestId: string; ok: true; data: unknown }
  | {
      protocolVersion: number;
      requestId: string;
      ok: false;
      error: { code: string; message: string };
    };

type PendingEntry = {
  method: string;
  timeoutId: ReturnType<typeof setTimeout>;
  resolve: (value: unknown) => void;
  reject: (error: Error) => void;
};

function reportBridgeError(method: string, error: Error) {
  Sentry.captureException(error, { tags: { bridge_method: method } });
}

abstract class BridgeCore {
  private readonly pending = new Map<string, PendingEntry>();
  private sequence = 0;

  private installed = false;

  // 플랫폼별로 달라지는 것은 이 하나뿐이다
  protected abstract post(messageJson: string): void;

  // 전역을 건드리는 부수효과는 생성자가 아니라 명시적 호출로 분리한다.
  // SSR 환경에서 모듈 평가만으로 window에 접근하는 일을 막고,
  // 중복 설치도 조용히 덮어쓰지 않고 드러낸다.
  install() {
    if (typeof window === "undefined") return;
    if (window.__resolveBridge) {
      throw new Error("Bridge SDK가 이미 설치되어 있습니다.");
    }

    window.__resolveBridge = (responseJson: string) => {
      this.handleResponse(responseJson);
    };
    this.installed = true;
  }

  dispose() {
    for (const entry of this.pending.values()) {
      clearTimeout(entry.timeoutId);
      entry.reject(new BridgeError("DISPOSED", `${entry.method} 요청이 정리됨`));
    }
    this.pending.clear();

    if (this.installed) {
      delete window.__resolveBridge;
      this.installed = false;
    }
  }

  protected call<T>(
    method: string,
    params?: unknown,
    timeoutMs = 3000,
  ): Promise<T> {
    const requestId = `${method}_${Date.now()}_${++this.sequence}`;

    return new Promise<T>((resolve, reject) => {
      const settleWithError = (error: Error) => {
        const entry = this.pending.get(requestId);
        if (!entry) return;

        clearTimeout(entry.timeoutId);
        this.pending.delete(requestId);
        reportBridgeError(method, error);
        reject(error);
      };

      const timeoutId = setTimeout(() => {
        settleWithError(new BridgeTimeoutError(method, timeoutMs));
      }, timeoutMs);

      this.pending.set(requestId, {
        method,
        timeoutId,
        resolve: resolve as (value: unknown) => void,
        reject,
      });

      try {
        const request: BridgeRequest = {
          protocolVersion: PROTOCOL_VERSION,
          requestId,
          method,
          params,
        };
        this.post(JSON.stringify(request));
      } catch (caught) {
        // 호출 시점의 동기 예외가 여기서 잡힌다.
        // "Error invoking getUserInfo: Java object is gone"이 이 경로다.
        settleWithError(
          caught instanceof Error
            ? caught
            : new BridgeError("UNKNOWN_SYNC_ERROR", String(caught)),
        );
      }
    });
  }

  private handleResponse(responseJson: string) {
    let parsed: unknown;

    try {
      parsed = JSON.parse(responseJson);
    } catch (caught) {
      Sentry.captureException(
        new BridgeError("INVALID_RESPONSE_JSON", String(caught)),
      );
      return;
    }

    // JSON.parse는 "null"이나 "5"에도 성공한다.
    // 모양을 확인하지 않고 바로 필드를 읽으면 여기서 TypeError가 나고,
    // 네이티브가 evaluateJavascript로 부른 호출이라 그 예외는 조용히 사라진다.
    if (!isBridgeResponse(parsed)) {
      Sentry.captureException(
        new BridgeError("MALFORMED_RESPONSE", responseJson.slice(0, 200)),
      );
      return;
    }

    const response = parsed;
    const entry = this.pending.get(response.requestId);
    if (!entry) {
      // 이미 timeout으로 정리됐거나 이전 document에 속한 요청
      return;
    }

    clearTimeout(entry.timeoutId);
    this.pending.delete(response.requestId);

    if (response.ok) {
      entry.resolve(response.data);
      return;
    }

    const error = new BridgeError(response.error.code, response.error.message);
    reportBridgeError(entry.method, error);
    entry.reject(error);
  }
}

function isBridgeResponse(value: unknown): value is BridgeResponse {
  if (typeof value !== "object" || value === null) return false;
  const candidate = value as Partial<BridgeResponse>;
  return (
    typeof candidate.requestId === "string" &&
    typeof candidate.ok === "boolean"
  );
}

브릿지 호출 한 건의 생명주기

한 번의 호출은 세 가지 방식으로만 끝난다. 어느 쪽으로 끝나든 타이머 해제와 pending 삭제를 같은 자리에서 처리한다.

Promise.race로도 구현할 수 있지만, 요청이 먼저 성공했을 때 timeout 타이머를 명시적으로 해제하지 않으면 호출마다 불필요한 타이머가 남는다. 그래서 pending entry가 정리되는 한 지점에서 타이머까지 함께 정리하도록 만들었다.

window.__resolveBridge가 전역 하나뿐이므로 이 SDK는 문서당 한 인스턴스만 만든다는 전제가 붙는다. 여러 인스턴스를 만들 수 있는 구조라면 응답 창구도 인스턴스별로 라우팅해야 한다.

타임아웃이 지켜주지 않는 구간

여기에 이 설계의 중요한 한계가 하나 있다. 처음 만들 때는 못 보고 지나갔던 부분이다.

Android 브릿지 호출은 동기다. this.post(...)window.AndroidBridge.postMessage(json)을 부르는 동안 V8은 멈춰 있다. 그런데 timeout 타이머는 JS 태스크 큐에서 돌아간다.

무슨 뜻이냐면, 네이티브가 @JavascriptInterface 진입점 안에서 오래 걸리는 일을 하면 JS 스레드가 통째로 얼어붙고 timeout 타이머는 아예 실행되지 못한다. 3초 타임아웃을 걸어놨는데 5초 동안 화면이 멎어 있는 상황이 나온다.

kotlin
@JavascriptInterface
fun postMessage(messageJson: String) {
    router.route(messageJson)  // 반드시 즉시 반환해야 한다
}

router.route는 파싱하고 워커로 넘긴 뒤 곧바로 돌아와야 한다. 여기서 DB를 읽거나 네트워크를 기다리면 타임아웃 설계 전체가 무력해진다.

정리하면 이 타임아웃이 막아주는 것은 네이티브가 응답을 안 보내는 경우까지다. 네이티브가 동기 진입점 안에서 붙잡고 있는 경우는 막지 못한다. 후자는 코드 리뷰에서 걸러야 하는 종류의 문제다.

응답은 네이티브가 evaluateJavascriptwindow.__resolveBridge를 호출해 돌려준다. 이때 응답 JSON을 JS 코드 문자열에 이어 붙이지 말고 하나의 문자열 인자로 넘겨야 한다. WebMessageListener를 쓸 수 있다면 JavaScriptReplyProxy.postMessage로 코드와 데이터를 아예 분리할 수 있다.

requestId는 왜 필요한가

“그냥 호출하면 실행되는 구조 아닌가?”

이 설계를 리뷰할 때 동료에게 받은 질문이다. 요청을 보내면 네이티브가 처리하고 응답을 주는데, 굳이 모든 요청에 ID를 붙여 짝을 맞출 필요가 있느냐는 것이었다.

먼저 인정하고 시작해야 할 게 있다. 기존의 동기 브릿지에는 이 문제가 아예 없었다.

js
const info = window.AndroidBridge.getUserInfo();

이건 값을 그 자리에서 돌려받는다. 반환값이 곧 답이라 섞일 여지가 없다. 상관관계 문제는 우리가 비동기 메시지 모델로 바꾸면서 새로 만든 것이다. requestId는 그 전환의 비용이다.

그럼 왜 동기를 버렸나

세 가지 이유가 있었다.

첫째, 동기 호출은 네이티브 작업이 끝날 때까지 V8을 멈춘다. getUserInfo()가 DB를 80ms 읽으면 그 80ms 동안 화면이 그대로 굳는다.

둘째, 그래서 타임아웃을 걸 수가 없다. JS가 멈춰 있는데 타이머가 돌 리 없다.

셋째, iOS는 애초에 동기 브릿지가 불가능하다. postMessage는 값을 반환하지 않는다. 프로토콜을 한 벌로 유지하려면 Android도 비동기로 맞추는 수밖에 없었다.

동기인 것은 전달까지다

여기서 헷갈리기 쉬운 지점이 있다. 새 구조에서도 postMessage 호출 자체는 여전히 동기다. 그런데 그게 상관관계 문제를 막아주지는 않는다.

js
window.AndroidBridge.postMessage(json);  // 동기. 하지만 void를 반환한다
// ...
window.__resolveBridge(responseJson);    // 결과는 나중에, 별도의 태스크로

동기로 보장되는 것은 “네이티브가 메시지를 접수했다”까지다. 실제 결과는 네이티브가 evaluateJavascript__resolveBridge를 부를 때 오고, 그건 완전히 다른 태스크다.

오히려 동기 전달이 문제를 확정적으로 만든다. JS가 블로킹된 동안에는 응답 콜백이 끼어들 수 없으니, 연달아 부른 두 호출의 등록이 반드시 둘 다 먼저 일어난다.

무엇을 키로 쓸 것인가

응답이 도착했을 때 어느 Promise를 깨울지 정해야 한다. 후보는 두 가지다.

도착 순서를 쓰는 방법은 금방 탈락한다. 네이티브 핸들러가 각자의 워커로 흩어지는 순간 완료 순서는 요청 순서가 아니라 작업 소요 시간 순서가 되기 때문이다.

그러면 메서드 이름을 키로 쓰면 어떨까. getUserInfogetDeviceInfo는 서로 다른 슬롯에 들어가니 위 문제는 막힌다. 실제로 이게 자연스러운 다음 생각이고, 함정도 여기 있다.

메서드 이름 키와 requestId 키의 차이

같은 메서드를 동시에 두 번 부르면 슬롯이 하나뿐이라 두 번째가 첫 번째를 덮어쓴다. 응답 A는 슬롯에 남은 Promise 2를 resolve하고, 응답 B는 빈 슬롯을 만나 버려진다. Promise 1은 타임아웃까지 매달려 있다.

더블 탭, React StrictMode의 개발 모드 이중 호출, 두 컴포넌트가 같은 정보를 요청하며 마운트되는 상황. 흔하다. getItem("a")getItem("b")처럼 파라미터만 다른 호출도 메서드 이름은 같으니 똑같이 깨진다.

결정적인 것은 늦게 도착한 응답이다

타임아웃과 겹치면 더 조용한 방식으로 틀린다.

text
t=0.0s  getUserInfo() 호출        pending["getUserInfo"] = P1
t=3.0s  타임아웃, P1 reject, 슬롯 비움
t=3.5s  사용자가 재시도            pending["getUserInfo"] = P2
t=4.0s  첫 번째 호출의 응답이 이제야 도착
        메서드 이름으로 조회되니 P2가 3초 전 데이터로 resolve된다

에러도 나지 않고 로그도 남지 않는다. 사용자는 낡은 데이터를 본다.

requestId가 있으면 그 응답의 id는 이미 pending에서 지워졌으므로 매칭되지 않고 무시된다. P2는 자기 응답을 기다린다. 앞선 코드의 if (!entry) return;이 이 자리다.

requestId는 요청과 응답의 관계를 순서가 아니라 데이터로 표현한다. HTTP/2가 stream ID를 사용하고 JSON-RPC가 id 필드를 두는 것과 같은 원리다. 요청을 식별하는 값은 “무엇을 불렀나”가 아니라 “몇 번째 호출인가”여야 하는데, 메서드 이름은 앞의 것만 알려준다.

덤으로 얻은 것도 있다. requestId 하나로 웹과 Android와 iOS 세 코드베이스의 로그를 한 줄에 꿸 수 있게 됐다.

최종 설계: 객체가 아니라 메시지를 공유한다

최종적으로 웹 SDK는 요청 생성, 상관관계, 타임아웃, 오류 표준화를 책임지고 네이티브는 메시지 검증, 라우팅, 실행, 응답을 책임지도록 나눴다.

요청·응답 계약으로 재설계한 브릿지 구조

네이티브 브릿지 코어는 JSON을 파싱하고, protocol version과 스키마를 검증하고, method registry에서 핸들러를 찾고, 핸들러를 적절한 worker로 전달한 뒤, 결과 또는 오류를 같은 requestId와 함께 응답한다. 응답 전달은 WebView가 요구하는 스레드에서 수행한다.

없는 메서드는 명시적인 오류가 된다.

json
{
  "protocolVersion": 1,
  "requestId": "user_101",
  "ok": false,
  "error": {
    "code": "METHOD_NOT_FOUND",
    "message": "Unknown bridge method: getUserInfo"
  }
}

핸들러 예외도 네이티브에서 삼키지 않는다.

json
{
  "protocolVersion": 1,
  "requestId": "user_101",
  "ok": false,
  "error": {
    "code": "NATIVE_HANDLER_ERROR",
    "message": "Failed to load user information"
  }
}

응답이 오지 않으면 웹 SDK가 timeout으로 실패시킨다.

이렇게 하면 JS 실행 컨텍스트가 유지되는 동안 관측 가능한 실패를 세 종류로 정리할 수 있다.

호출 시점에 터지는 것은 동기 예외로 들어온다. 브릿지 객체 부재, Java object is gone, 인자 변환 실패가 여기 해당하고 try/catch가 즉시 reject로 바꾼다. 네이티브가 처리에 실패한 경우에는 파싱 실패든 없는 메서드든 핸들러 예외든 ok: false 응답으로 돌아오고, 웹이 그것을 reject로 바꾼다. 아무 응답도 오지 않으면 timeout 타이머가 reject를 만든다.

어느 갈래든 결말은 reject 하나이고, reject는 메서드 태그와 함께 센트리로 보고된다.

한 가지 주의할 점은 SDK가 직접 보고하는 것과 센트리의 전역 unhandledrejection 핸들러가 겹칠 수 있다는 것이다. 호출부가 catch하지 않으면 같은 실패가 두 번 집계된다. 실제 오류율을 보려면 SDK가 붙이는 bridge_method 태그를 기준으로 세거나, 전역 핸들러에서 이 계열을 제외해야 한다.

다만 “모든 실패를 반드시 잡는다”고 표현하면 안 된다. 호스트 앱 프로세스가 크래시하거나 WebView 렌더러가 종료되면 JS 런타임 자체가 사라질 수 있다. 페이지가 네비게이션되면 이전 document의 pending Map과 타이머도 함께 사라진다. 이런 상황에서는 Promise가 reject되거나 센트리 이벤트가 전송될 기회가 없다.

이 설계가 보장하는 범위는 현재 JS 실행 컨텍스트가 유지되는 동안 관측 가능한 동기 예외, 네이티브 오류 응답, 무응답을 reject라는 단일 형태로 수렴시키는 것까지다. 그래서 렌더러 프로세스 종료는 별도로 계측해야 한다. WebViewClient.onRenderProcessGone을 따로 기록하고, 같은 세션에서 브릿지 실패와 시간적으로 함께 나타나는지를 측정해야 두 신호의 관계를 말할 수 있다.

플랫폼별 전송 방식은 adapter 뒤로 숨긴다

웹 비즈니스 코드는 Android와 iOS의 전송 방식을 직접 알 필요가 없도록 했다.

ts
interface NativeBridge {
  getUserInfo(): Promise<UserInfo>;
  getDeviceInfo(): Promise<DeviceInfo>;
}

class AndroidBridgeAdapter extends BridgeCore implements NativeBridge {
  protected post(messageJson: string) {
    window.AndroidBridge.postMessage(messageJson);
  }

  getUserInfo() {
    return this.call<UserInfo>("getUserInfo");
  }

  getDeviceInfo() {
    return this.call<DeviceInfo>("getDeviceInfo");
  }
}

class IOSBridgeAdapter extends BridgeCore implements NativeBridge {
  protected post(messageJson: string) {
    window.webkit.messageHandlers.bridge.postMessage(messageJson);
  }

  getUserInfo() {
    return this.call<UserInfo>("getUserInfo");
  }

  getDeviceInfo() {
    return this.call<DeviceInfo>("getDeviceInfo");
  }
}

BridgeCore가 requestId 발급, pending Map 관리, timeout, 응답 파싱, 오류 표준화, 센트리 보고를 담당한다. adapter가 담당하는 것은 전송 방식 하나뿐이다.

브릿지 SDK의 계약과 상속 구조

네이티브 쪽도 같은 요청·응답 스키마를 구현한다. 플랫폼은 달라도 프로토콜은 하나다. 새 브릿지 메서드를 추가할 때 웹, Android, iOS가 각자의 기억에 의존하지 않고 같은 계약 문서를 보게 된 것이 이 구조의 가장 큰 효과였다.

이 분석이 확정하지 못한 것

글을 정확하게 남기기 위해 확인하지 못한 것들을 한 곳에 모아 둔다.

  • 운영 환경에서 강한 참조를 최초로 끊은 코드 경로. 앞서 나열한 여섯 후보 중 하나로 좁히려면 객체 identity와 등록·해제 호출 기록이 필요하다.
  • 두 오류 경로의 운영 비중. 500건 중 몇 건이 Java 로컬 참조 복구 실패였고 몇 건이 동기 Mojo 호출 실패였는지 구분할 데이터가 없다.
  • 당시 WebView 버전과 원본 exception.value. 메시지에 메서드 이름 prefix가 있었는지 확정하지 못했다.
  • 기기 사양·메모리 등급·WebView 버전과 오류율의 상관관계. 저사양 기기에서 더 자주 발생했다는 인상은 있었지만 측정하지 않았다.
  • onRenderProcessGone과 브릿지 실패의 동시 발생 여부. 계측 자체가 없었다.
  • 이벤트 유실 지표. 브릿지 오류가 0건이 됐다는 사실만으로 사용자 이벤트 유실이 모두 사라졌다고 단정할 수 없다.

특히 두 번째 항목이 아프다. 두 경로가 같은 문자열을 내기 때문에 사후에 나누는 것도 어렵다. 지금 구조라면 메서드 태그와 오류 코드가 남으니 다음에 같은 일이 생기면 구분할 수 있다.

결과

네이티브 브릿지의 소유권과 등록 방식을 바꾸고, Java 객체를 반환하지 않는 메시지형 인터페이스와 요청·응답 계약을 함께 배포했다.

변경 전 센트리에는 Java object is gone이 약 2주에 500건씩 기록되고 있었다. 변경 배포 후 관측 기간에는 같은 오류가 0건으로 집계됐다.

여기서 0건은 관측 범위 안에서 해당 센트리 오류가 발생하지 않았다는 뜻이다. 시스템 전체에서 앞으로도 절대 발생하지 않는다는 의미는 아니다.

운영 결과가 의미하는 바는 다음과 같다.

  • 기존 실패 경로에 진입할 수 있는 객체 수명 구조를 제거했다.
  • 브릿지 호출 실패가 Promise rejection으로 표면화됐다.
  • 네이티브 오류 응답과 무응답을 구분할 수 있게 됐다.
  • 메서드별 오류율과 timeout을 모니터링할 수 있게 됐다.
  • 플랫폼별 전송 방식과 공통 프로토콜을 분리했다.

한 번의 오류를 막는 수정에서 끝나지 않고, 다음 경계 장애를 더 빨리 발견할 수 있는 구조를 만들었다.

WebView 브릿지를 설계할 때 무엇을 먼저 볼 것인가

이 경험 이후 브릿지를 새로 설계하거나 리뷰할 때 확인하는 순서를 정리했다.

1. 보안 경계

브릿지는 웹 콘텐츠에 네이티브 기능을 호출할 수 있는 문을 연다. addJavascriptInterface는 객체를 모든 프레임에 주입하고, 앱은 어떤 origin의 frame이 메서드를 호출했는지 구분할 수 없다.

  • @JavascriptInterface로 노출 메서드를 최소화한다.
  • 모든 입력을 신뢰하지 않고 검증한다.
  • WebView에 제3자 콘텐츠를 로드하지 않는다.
  • iframe과 리디렉션도 신뢰 경계에 포함한다.
  • 가능하면 WebMessageListener의 origin allowlist를 사용한다.
  • wildcard origin은 명확한 이유가 있을 때만 허용한다.

API 17 이전에는 주입된 객체의 getClass와 reflection을 이용해 앱 권한으로 Java 코드를 실행할 수 있었던 취약점도 있었다. 편의를 위해 여는 창구라고 생각하지 말고 신뢰 경계로 놓고 설계해야 한다.

여기에 정면으로 충돌하는 지점이 하나 있다. 설계 문서의 cross-frame 대책은 객체 자체가 아니라 stateless factory를 주입해 프레임마다 자기 객체를 만들게 하라는 것이다. 그런데 factory가 반환하는 객체가 곧 이 글이 제거한 transient 객체다. 보안 권고를 그대로 따르면 수명 위험이 늘어난다.

우리가 택한 단일 문자열 진입점은 수명 위험은 없애지만 프레임 출처를 구분하지 못한다. addJavascriptInterface가 호출자의 origin을 알려주지 않기 때문이다. 두 요구를 동시에 만족시키려면 WebMessageListener로 가야 한다. allowedOriginRules, sourceOrigin, isMainFrame을 제공하므로 객체를 나눠주지 않고도 호출자를 식별할 수 있다.

2. 수명과 소유권

다음 질문에 코드로 답할 수 있어야 한다.

  • 브릿지 객체를 누가 강하게 소유하는가.
  • 언제 생성되고 언제 해제되는가.
  • WebView와 같은 수명을 가지는가.
  • 같은 이름으로 재등록될 수 있는가.
  • 메서드가 새로운 Java 객체를 반환하는가.
  • 네비게이션 중 진행 중인 요청은 어떻게 처리하는가.

소유권이 “WebView 내부에서 알아서 관리한다”로만 남아 있으면 버전 변화나 수명주기 코드와 결합했을 때 문제가 숨어든다.

3. 프로토콜 형태

객체를 공유하기보다 메시지를 전달한다. 권장하는 최소 필드는 다음과 같다.

ts
type BridgeMessage = {
  protocolVersion: number;
  requestId: string;
  method: string;
  params?: unknown;
};

응답도 같은 requestId와 명시적인 성공·실패 상태를 가져야 한다.

4. 실패의 관측 가능성

모든 호출은 가능한 범위 안에서 성공 또는 실패라는 결말을 가져야 한다. 동기 예외, 네이티브 오류 응답, timeout, 파싱 오류, 지원하지 않는 메서드, 지원하지 않는 프로토콜 버전이 모두 여기 해당한다.

각 오류에는 method, app version, OS version, WebView version, protocol version을 태그로 남긴다. 단, 프로세스 종료나 페이지 폐기처럼 JS가 보고할 수 없는 실패는 별도의 앱·렌더러 모니터링이 필요하다.

5. 동기성과 스레딩 계약

Android Java Bridge 호출은 JS를 블로킹하고 Java 메서드를 WebView 전용 백그라운드 스레드에서 실행한다.

브릿지의 동기 진입점에서는 무거운 작업을 직접 수행하지 않는다. 필요한 작업은 worker로 전달하고 결과를 비동기 메시지로 응답한다. 응답 완료 순서가 요청 순서와 같다고 가정하지 않고, 상관관계는 requestId로 명시한다.

6. 플랫폼 추상화와 capability 협상

웹 코드는 하나의 SDK 인터페이스만 보게 한다. Android, iOS, WebView 버전별 차이는 adapter 안에서 처리한다.

프로토콜에는 protocol version, 지원 메서드 목록, 기능별 최소 앱 버전, 최대 메시지 크기, timeout 정책, binary payload 지원 여부를 포함할 수 있다. 앱 버전이 오래돼 특정 메서드를 지원하지 않는 상황을 런타임 예외가 아니라 capability 협상으로 처리할 수 있어야 한다.

경계에 있는 문제

웹 프론트엔드 개발자가 브라우저를 잘 아는 것은 당연하다. 그런데 실제 업무에서 만나는 어려운 문제들은 브라우저 안보다 경계에 더 자주 숨어 있었다. 웹과 네이티브 사이, 렌더러와 앱 프로세스 사이, V8의 객체 수명과 ART의 객체 수명 사이, 우리 팀의 대시보드와 다른 팀의 대시보드 사이.

WebView는 그 경계가 특히 두껍다. 브라우저 위에서만 도는 것이 아니라 네이티브 환경 위에서 동작하기 때문에 네이티브의 수명주기와 메모리 모델을 어느 정도 이해해야 한다. Android와 iOS의 브릿지 모델도 다르고, 같은 Android라도 WebView 버전에 따라 Chromium 내부 구현이 바뀐다.

이번에 살펴본 Gin 브릿지 코드도 IPC에서 Mojo로 이동했고, 최근에는 Gin 래퍼의 메모리 관리 구현도 바뀌었다. 같은 파일의 main 브랜치를 보고 있더라도 장애 당시 기기에 배포된 코드와 같다고 가정할 수 없다. 소스 코드를 파는 것만큼 버전을 고정하는 일이 중요하다.

이 문제가 3년을 산 이유는 코드 한 줄이 어려워서가 아니었다. 원인의 사슬이 JS 힙, Chromium 브릿지, Java 보관 명부, ART 힙에 걸쳐 있었다. 웹팀의 도구로는 Java 객체가 보이지 않았고, 네이티브팀의 도구로는 JS 래퍼가 보이지 않았다. 어느 한쪽만 보면 자기 영역은 정상으로 보였다.

경계에 있는 문제는 이렇게 살아남는다. 아무도 경계 너머를 볼 의무가 없고, 그래서 아무도 전체 실패 경로를 보지 않는다.

그리고 확인한 것은 GC가 객체 하나를 수거했다는 사실이 아니었다. Java object is gone은 단일 원인의 이름이 아니었다. 최소 두 개의 서로 다른 실패가 같은 enum 값을 거쳐 같은 문자열로 나온다. 대시보드에서 하나의 이슈로 묶여 보이던 500건이 실제로는 몇 종류였는지는 지금도 모른다. 경계에서 생기는 오류는 이렇게 서로 다른 실패를 하나의 이름 아래 감춘다.

그래서 수정의 핵심도 GC를 피하는 요령이 아니었다. 소유자를 명확히 했고, 재등록 경로를 제거했고, transient 객체를 만들지 않았고, 객체 호출을 메시지 프로토콜로 바꿨고, 요청과 응답의 관계를 데이터로 표현했고, 실패를 관측 가능한 형태로 만들었다.

나는 이런 경계의 문제를 해결하는 데 강점을 가진 개발자가 되고 싶다. 경계의 문제는 풀기도 어렵지만 추진하기가 더 어렵다. 원인을 특정하는 기술과, 어느 팀도 자기 일이라고 생각하지 않는 일을 자기 일로 만드는 태도가 함께 필요하다. 이번 일은 그 두 가지를 연습한 기록이다.

참고 자료