웹뷰 브릿지를 WebMessageListener로 마이그레이션하기

Android 웹뷰 브릿지를 addJavascriptInterface에서 AndroidX WebKit의 WebMessageListener로 옮겼다. 웹 코드에서 보면 브릿지 호출이 이렇게 바뀐다.

ts
// 이전: addJavascriptInterface. 값을 바로 돌려준다
const userData = window.AndroidBridge.getUserData();

// 이후: WebMessageListener. 아무것도 돌려주지 않는다
const request = JSON.stringify({ method: 'getUserData' });
window.AndroidBridge.postMessage(request);
window.AndroidBridge.onmessage = (event) => {
  // 이 응답은 어느 요청의 것인가?
};

요청을 여러 개 보내도 응답은 모두 onmessage 하나로 들어온다. 동기 호출일 때는 어느 요청의 응답인지, 어떤 순서로 끝나는지, 실패했는지를 따로 챙길 필요가 없었는데 이제는 웹이 전부 챙겨야 한다. <iframe>이나 Web Worker와 postMessage로 요청을 주고받아 봤다면 익숙한 문제일 것이다. 요청마다 식별자(requestId)를 붙이는 게 흔한 해법인데 이번에는 그보다 단순한 방법을 골랐다.

이 글은 두 방식의 구조 차이부터 설명하고 옮긴 뒤 늘어난 고민, 요청과 응답을 맞추는 네 가지 방법, 실제로 고른 조합, requestId가 꼭 필요해지는 경우를 차례로 정리한다. 옮기고 나서 사내 센트리에 3년 넘게 남아 있던 Java object is gone 에러도 사라졌는데 이 역시 구조 차이에서 나온 결과다.

두 방식의 브릿지 구조

웹뷰의 페이지 JS와 앱의 네이티브 코드는 따로 돈다. Android 8.0부터는 대개 프로세스도 다르다. 브릿지는 그 사이로 호출을 실어 나르는 통로인데 두 방식은 JS에 무엇을 쥐여 주느냐부터 다르다.

이전
addJavascriptInterface
웹뷰 · 페이지 JS
window.AndroidBridge
Java 객체의 핸들
모든 프레임에 들어감
메서드를 부르면 값이 바로 돌아옴
그동안 JS는 기다림
앱 · 네이티브
Java 객체
▲ 어느 프레임이 불렀는지 모름
▲ 먼저 사라지면 Java object is gone
이후
WebMessageListener
웹뷰 · 페이지 JS
window.AndroidBridge
postMessage · onmessage
허용된 origin의 프레임에만 들어감
postMessage로 문자열을 보냄
브라우저가 보낸 쪽 origin을 붙임
replyProxy로 따로 답이 옴
앱 · 네이티브
메시지 리스너
origin을 보고 허용할 기능을 가림
Android 브릿지 구조 · 이전과 이후

addJavascriptInterface는 앱의 Java 객체를 페이지에 그대로 넣어 준다. JS가 쥔 window.AndroidBridge는 그 Java 객체를 가리키는 핸들이라서 메서드를 부르면 Java 메서드가 실행되고 값이 바로 돌아온다. 그동안 JS는 멈춰서 기다린다. 쓰기는 편하지만 이 객체는 웹뷰의 모든 프레임에 들어가고 Java 쪽은 어느 프레임이 불렀는지 모른다.

WebMessageListener는 객체 대신 메시지 통로를 준다. JS가 쥔 것은 postMessage와 onmessage뿐이고 이 통로는 등록할 때 정한 origin 규칙에 맞는 프레임에만 생긴다. JS가 문자열을 보내면 브라우저가 보낸 프레임의 origin을 붙여 네이티브로 넘기고, 네이티브는 replyProxy로 답한다. 호출이 값을 돌려주지 않으니 JS도 멈추지 않는다.

WebMessageListener로 옮긴 이유

인앱약정을 도입하면서 앱 웹뷰가 금융사 페이지를 열게 됐다. SDK는 그 페이지 위에서 돌면서 브릿지로 네이티브 기능을 부른다. 그러니 호출한 페이지의 origin에 따라 쓸 수 있는 브릿지 기능을 나눠야 했다. 이 판단은 네이티브가 해야 한다. 페이지의 JS는 자기 origin을 얼마든지 꾸며 말할 수 있기 때문이다.

기존 방식으로는 이 판단을 할 수 없었다. 앞에서 본 것처럼 Java 쪽은 어느 프레임이 불렀는지 모른다. Java 메서드 안에서 webView.getUrl()로 지금 페이지를 확인하면 될 것 같지만 이것도 안 된다. 브릿지 메서드는 WebView의 백그라운드 스레드에서 불리는데 그 스레드에서 WebView 메서드를 부르면 예외가 난다. UI 스레드로 넘겨 물어봐도 돌아오는 건 메인 프레임의 URL뿐이라, 금융사 페이지 안의 <iframe>이 불러도 금융사 origin으로 보인다. Android 보안 가이드도 addJavascriptInterface로는 호출한 프레임의 origin을 확인할 수단이 없다고 설명한다.

WebMessageListener에서는 브라우저가 붙여 준 origin을 보고 판단하면 된다. 네이티브는 origin마다 허용할 메서드 목록을 두고 목록 밖의 호출에는 오류로 답했다.

구형 웹뷰는 WebMessageListener를 지원하지 않아서 기존 addJavascriptInterface도 남겨 두었다. 대신 메서드를 여럿 두지 않고 같은 이름의 postMessage 하나만 열었다. 응답은 네이티브가 evaluateJavascript로 전역 함수를 불러 돌려준다. 이 글에서는 둘을 새 채널과 구형 채널로 부른다.

어느 채널을 쓸지는 네이티브가 loadUrl 전에 WebViewFeature.isFeatureSupported로 정했다. 웹에서 기능을 감지해 고르는 방법도 있지만 지원 여부는 네이티브만 알 수 있고 등록은 페이지가 뜨기 전에 끝나야 한다. 무엇보다 페이지가 채널을 고르게 두면 origin 검증이 없는 쪽을 고를 수도 있다. 구형 채널에 origin 검증이 없는 문제는 강제 앱 업데이트 계획이 잡혀 있어 그때까지만 감수하기로 했다.

기존 방식에서 Java object is gone이 나던 이유

사내 센트리에는 Java object is gone 에러가 3년 넘게 남아 있었다. 2주에 500건 정도 쌓이는 에러였는데 전부 안드로이드에서 났고 iOS에서는 한 건도 없었다. 영향이 낮아 백로그에 두었던 이 에러는 브릿지를 옮긴 뒤 관측 기간 동안 한 건도 나오지 않았다.

원인은 앞 그림의 핸들에 있다. JS가 쥔 핸들과 앱의 Java 객체는 수명이 따로 논다. 브릿지 메서드가 돌려준 Java 객체가 가비지 컬렉션으로 먼저 치워지면 JS에는 가리킬 곳이 없는 핸들만 남고, 이 핸들로 메서드를 부르면 Java object is gone이 난다. 웹뷰가 닫히는 중이라 호출이 앱까지 닿지 못할 때도 같은 에러가 난다. 운영에서 본 에러도 웹뷰가 다른 웹뷰로 바뀌거나 닫힐 때 실행된 콜백에서 났다.

iOS에서 한 건도 없었던 것은 iOS 브릿지가 처음부터 값만 메시지로 주고받기 때문이다. JS가 네이티브 객체를 쥐지 않으니 사라질 객체도 없다. WebMessageListener도 마찬가지로 JS에는 메시지 통로만 있고 반대편 리스너는 웹뷰가 계속 붙잡고 있다. 대신 닫히는 중에 보낸 메시지는 에러도 없이 사라지는데, 이 문제는 뒤의 타임아웃 절에서 다룬다.

구형 채널은 여전히 addJavascriptInterface지만 문자열을 받는 postMessage 하나만 남겨서 Java 객체를 돌려주는 메서드가 없다. 가비지 컬렉션 쪽 원인은 막혔고, 웹뷰가 닫히는 중에 호출이 겹치는 경우만 남아 있다. 관측 기간의 0건을 앞으로도 0건이라고 장담할 수 없는 이유다.

Chromium 소스로 본 두 경로

JS가 AndroidBridge의 메서드를 부르면 렌더러 프로세스가 앱 프로세스로 동기 IPC를 보낸다. 그러면 앱 프로세스의 브라우저 C++ 레이어가 핸들에 든 object_id로 실제 Java 객체를 찾아 부른다.

렌더러 프로세스
window.AndroidBridge
Java 객체가 아니라 래퍼
object_id: 3
메서드 호출 = 동기 IPC
▲ 프레임 정리 중 IPC 실패
앱 프로세스
C++ 객체 테이블
object_id → Java 객체
약한 JNI 참조
Java 객체
ART 힙
강한 참조 (이것만 살려 둔다)
Java 보관 명부
mRetainedObjects
▲ 명부에서 빠진 뒤 GC → 래퍼만 남음
Java object is gone
▲ 두 자리가 같은 문자열
addJavascriptInterface의 객체 핸들

C++ 객체 테이블은 Java 객체를 JNI 약한 참조로만 잡고 있다. 객체를 실제로 살려 두는 것은 Java 쪽 보관 명부(mRetainedObjects)라서, 명부에서 빠진 객체는 ART GC가 돌 때 사라지고 JS 래퍼만 남는다. 이 상태에서 메서드를 부르면 브라우저가 약한 참조를 복구하지 못해 kGinJavaBridgeObjectIsGone을 돌려준다. 렌더러가 이 코드를 문자열로 바꿔 던진 것이 센트리에 쌓인 메시지다. Chromium 설계 문서에도 GC된 객체에 JS 래퍼가 남을 수 있고 그때 호출은 실패한다고 적혀 있다.

같은 오류 코드가 세팅되는 곳이 하나 더 있다. 렌더러는 동기 IPC 호출 자체가 실패해도 Java 객체의 생사와 상관없이 같은 코드를 세팅한다. 프레임이 정리되는 중에 호출이 겹치면 이 경로로 들어간다.

cpp
// content/renderer/java/gin_java_function_invocation_helper.cc
if (remote->InvokeMethod(method_name_, std::move(arguments), &error,
                         &result_wrapper)) {
  // ...
} else {
  error = mojom::GinJavaBridgeError::kGinJavaBridgeObjectIsGone;
}

첫 번째 경로는 재현해 봤다. 테스트 기기를 chrome://inspect로 연결하고 DevTools Memory 탭에서 Collect garbage를 누르자 Java 메서드가 반환한 객체의 메서드 호출이 같은 에러로 실패했다. Android Studio Profiler에서도 대응하는 Java 객체가 ART GC에 수거되는 것을 함께 확인했다. 이름을 붙여 등록한 AndroidBridge 자체는 mInjectedObjects가 따로 잡고 있어서 이 실험으로는 사라지지 않았다.

두 번째 경로는 재현하지 않았지만 운영에서 본 모양과 맞는다. 에러는 웹뷰가 전환되거나 종료될 때 실행된 콜백에서 났는데 그 시점은 프레임이 정리되는 중이다. 다만 두 실패가 같은 문자열을 내기 때문에 500건을 경로별로 나눠 보지는 못했다.

동기 호출이 대신 해 주던 일

동기 호출은 JS를 멈춰 세우는 대신 응답을 호출한 자리로 곧장 돌려줬다. 어느 요청의 응답인지 따질 일이 없었던 이유다. 새 채널의 replyProxy는 프레임과 1:1로 묶인 통로라서 요청을 몇 개 보내든 모든 응답이 같은 onmessage로 들어온다.

iOS는 처음부터 메시지 방식이었다. 앱이 쓰던 WKScriptMessageHandlerWithReply에서는 postMessage가 호출마다 Promise를 돌려주고, 네이티브가 replyHandler로 답하면 그 호출의 Promise가 끝난다.

ts
// iOS: WKScriptMessageHandlerWithReply
const bridge = window.webkit.messageHandlers.bridge;
const userData = await bridge.postMessage({ method: 'getUserData' });
Android 이전
addJavascriptInterface
Java가 반환할 때까지 JS가 멈춤
getUserData()
값을 돌려줌
반환값
호출 스택이 맞춰 줌
Android 이후
WebMessageListener
JS는 바로 다음 줄을 실행함
postMessage(…)
돌려주는 값 없음
공용 onmessage
▲ 웹이 맞춰야 함
iOS
WithReply
JS는 바로 다음 줄을 실행함
postMessage(…)
Promise를 돌려줌
그 호출의 Promise
플랫폼이 맞춰 줌
응답이 돌아오는 자리

동기 호출에서는 호출 스택이, iOS에서는 플랫폼이 요청과 응답을 맞춰 줬다. JS를 멈춰 세우는 대가로 동기 호출이 해 주던 일은 이것 말고도 더 있다. 나란히 놓고 보면 옮긴 뒤 무엇이 웹의 일이 됐는지 드러난다.

항목 동기 호출(이전) 메시지(이후 Android)
어느 요청의 응답인지 호출 스택이 앎 웹이 맞춰야 함
동시에 떠 있는 호출 JS가 멈춰 하나씩 끝남 여럿. 같은 메서드도 겹침
응답 순서 부른 순서대로 네이티브 처리 시간에 따라 바뀜
실패 부르는 순간 예외 조용히 사라짐
늦은 응답 없음. 오래 걸리면 JS가 멈춤 타임아웃이나 페이지 이동 뒤 도착

표의 오른쪽 칸이 옮긴 뒤 늘어난 고민이다. 결국 브릿지 호출도 fetch처럼 늦거나 실패할 수 있는 요청으로 다뤄야 한다. 웹이 새로 정해야 했던 것은 이렇다.

표시를 붙이는 방법부터 비교해 보자.

요청과 응답을 맞추는 방법

당시에는 requestId를 넣을지를 두고 고민했다. 한 번에 하나씩 보내는 방법과 호출마다 MessagePort를 여는 방법은 이 글을 쓰면서 함께 비교해 본 것이다.

네 방법은 같은 메서드를 동시에 두 번 부를 때와 응답이 늦게 올 때 차이가 난다. 늦은 응답은 타임아웃이 지난 뒤에 올 수도 있고 구형 채널이라면 페이지가 바뀐 뒤에 올 수도 있다. 구형 채널이 응답을 넘기는 evaluateJavascript는 그 시점에 떠 있는 페이지에서 실행되기 때문이다. 새 채널의 replyProxy는 이동한 프레임으로 보낸 메시지를 버리므로 이런 일이 없다.

한 번에 하나씩 보내기

응답에 아무 표시가 없어도 되는 가장 단순한 방법이다. 앞 호출의 응답이 올 때까지 다음 호출을 보내지 않으면 도착한 응답은 늘 방금 보낸 호출의 것이다. 대신 느린 호출 하나가 뒤의 호출을 모두 붙잡는다. 네이티브가 토큰을 갱신하는 동안에는 사용자 정보 조회도 기다려야 하고, 응답 하나가 오지 않으면 타임아웃이 날 때까지 모든 호출이 멈춘다.

메서드 이름을 키로 쓰기

실제로 고른 방법이다. 네이티브가 응답에 메서드 이름을 실어 보내면 웹은 그 이름을 키로 기다리던 호출을 찾는다. iOS는 플랫폼이 요청과 응답을 맞춰 주므로 이 구조는 Android에만 쓴다.

ts
// 키는 메서드 이름, 값은 기다리는 호출의 resolve
const waiters = new Map<string, (data: unknown) => void>();

function call(method: string, params?: unknown) {
  return new Promise((resolve) => {
    waiters.set(method, resolve); // 같은 메서드면 앞 호출을 덮어쓴다
    const request = JSON.stringify({ method, params });
    window.AndroidBridge.postMessage(request);
  });
}

// 새 채널의 onmessage와 구형 채널의 전역 콜백이 부른다
function handleResponse(json: string) {
  const { method, data } = JSON.parse(json);
  waiters.get(method)?.(data);
  waiters.delete(method);
}

메서드가 다르면 키도 다르니 응답 순서가 바뀌어도 문제없다. 하지만 같은 메서드를 두 번 부르면 뒤 호출이 앞 호출을 덮어써서 앞 호출은 응답을 받지 못한다. 타임아웃이 없다면 영영 끝나지 않는다.

requestId를 키로 쓰기

요청마다 새 식별자를 붙이고 네이티브가 응답에 그 식별자를 그대로 실어 돌려주는 방법이다. 위 코드에서 달라지는 건 키 하나뿐이다.

ts
waiters.set(method, resolve); // 메서드 이름을 키로
waiters.set(requestId, resolve); // requestId를 키로

키가 호출마다 다르니 같은 메서드를 몇 번 불러도 각자 자기 응답을 받는다. 대기 목록에 없는 식별자는 늦게 온 응답이니 버리면 된다. 다만 식별자는 페이지가 바뀌어도 겹치지 않아야 한다. 페이지마다 1부터 세면 구형 채널에서 이전 페이지의 1번 응답이 새 페이지의 1번 요청으로 들어가 버린다. crypto.randomUUID()를 쓰면 간단하지만 Chrome 92부터 지원하니 오래된 웹뷰에는 없을 수 있다. Cordova와 Capacitor의 브릿지, Worker 통신 라이브러리 Comlink, JSON-RPC가 모두 이런 식으로 요청과 응답을 맞춘다.

호출마다 MessagePort 열기

호출할 때마다 new MessageChannel()을 만들어 한쪽 포트를 메시지와 함께 보내는 방법이다. 새 채널의 postMessage는 포트를 함께 넘길 수 있어서 네이티브가 그 포트로 답하면 응답은 그 호출에게만 간다. iOS의 WithReply와 같은 구조라 표시도 필요 없다. 걸리는 건 구형 채널이다. addJavascriptInterface로는 포트를 넘길 수 없으니 구형 채널에는 다른 방법을 하나 더 마련해야 한다.

방법 같은 메서드 동시 호출 늦게 온 응답 필요한 것
한 번에 하나씩 앞 호출을 기다림 다음 호출이 받음 웹의 대기열
메서드 이름 키 앞 호출이 덮어써짐 같은 메서드의 다음 호출이 받음 응답에 메서드 이름
requestId 호출마다 자기 응답 버림 요청과 응답에 식별자
호출마다 MessagePort 호출마다 자기 응답 버림 네이티브가 포트로 답함. 구형 채널은 불가

가장 확실한 건 requestId와 포트다. 그런데도 메서드 이름 키를 고른 건 브릿지 호출의 성격 때문이었다.

호출의 성격에 맞춘 선택

당시 쓰던 브릿지 호출은 대부분 이런 것이었다.

  • 이벤트 로그 전송: 보내고 끝나는 명령이라 응답을 기다리지 않는다.
  • 사용자 정보 조회: getUserData 하나가 사용자 이름과 userId, 디바이스 ID 같은 값을 함께 돌려줬다. 자주 바뀌지 않는 값이다.
  • 토큰 조회: 웹은 API를 부를 때 getAccessToken으로 토큰을 받았다. 토큰 관리는 네이티브가 맡아 리프레시 토큰으로 액세스 토큰을 갱신했다.

로그 전송은 응답을 기다리지 않으니 맞출 것도 없다. 사용자 정보와 토큰은 현재 값을 돌려주는 조회라서 늦게 온 응답을 다음 호출이 받아도 틀린 값이 아니다. 남는 약점은 같은 메서드를 동시에 부르는 경우 하나다.

getUserData는 호출 수를 줄여서 풀었다. 여러 곳에서 부르던 것을 웹이 한 번만 부르고 받은 값을 나눠 쓰게 바꿨다. 호출을 구분하기보다 애초에 동시에 부를 일을 없앤 것이다.

하나로 합친 토큰 요청

토큰은 사정이 달랐다. API를 부를 때마다 토큰이 필요하니 같은 메서드를 동시에 부르는 일이 오히려 기본이다. 페이지가 뜨자마자 API 세 개가 동시에 토큰을 요청하는 경우를 생각해 보자.

메서드 이름 키만 쓰면 세 요청이 같은 키를 차례로 덮어써서 앞의 둘은 타임아웃으로 끝난다. requestId를 붙이면 세 호출이 각자 자기 응답을 받지만 요청 세 개가 그대로 네이티브로 가는 건 마찬가지다. 네이티브가 갱신을 하나로 합치지 않으면 같은 리프레시 토큰으로 갱신이 세 번 겹친다. 리프레시 토큰을 한 번만 쓰게 하는 서버라면 겹친 갱신을 재사용으로 보고 세션을 끊을 수도 있다(RFC 9700 4.14.2).

네이티브가 요청마다 갱신할 때
메서드 이름 키 · 합치지 않음
API A
갱신 1
API B
갱신 2
API C
갱신 3
▲ A·B 타임아웃, 갱신 3번
메서드 이름 키 · 진행 중인 요청 공유
실제 선택
API A
API B
API C
갱신 1
● 셋 다 받음, 갱신 1번
requestId · 합치지 않음
API A
갱신 1
API B
갱신 2
API C
갱신 3
▲ 셋 다 받음, 갱신 3번
requestId · 진행 중인 요청 공유
API A
API B
API C
갱신 1
● 셋 다 받음, 갱신 1번
동시에 들어온 토큰 요청 세 개

결국 합치지 않으면 어느 방법이든 같은 요청을 여러 번 보낸다. 그래서 웹에 토큰 매니저를 두고 요청을 하나로 합쳤다. 핵심은 진행 중인 요청을 기억해 두는 변수 하나다.

ts
let pendingToken: Promise<unknown> | null = null;

function getAccessToken() {
  // 진행 중인 요청이 있으면 새로 보내지 않고 같이 기다린다
  pendingToken ??= call('getAccessToken').finally(() => {
    pendingToken = null;
  });
  return pendingToken;
}

요청을 하나로 합치고 나면 어느 방법으로 맞추든 결과는 같다. 네이티브로 가는 요청이 하나뿐이니 메서드 이름 키든 requestId든 세 호출이 같은 토큰을 받는다. 토큰 요청에 필요한 건 몇 번째 요청의 응답이 아니라 지금 유효한 토큰 하나였다. axios 인터셉터로 토큰 갱신을 한 번만 하게 만들어 봤다면 익숙한 모양이고, SWR이나 TanStack Query가 같은 키의 요청을 하나로 합치는 것도 같은 발상이다.

남은 구멍은 네이티브 쪽에 있다. 네이티브가 갱신을 한 번으로 합쳤는지는 확인하지 못했는데 합치지 않았을 가능성이 크다. 그렇다면 토큰 매니저를 거치지 않는 요청은 여전히 겹칠 수 있다. 갱신이 타임아웃보다 오래 걸려 웹이 다시 요청하는 경우와 네이티브 화면이 같은 시점에 갱신을 부르는 경우다. 이 구멍은 네이티브 토큰 관리자가 갱신을 한 번으로 합쳐야 막힌다.

타임아웃이 맡는 실패

메시지 방식에서는 실패가 조용하다. 동기 브릿지에서는 호출하는 순간 예외로 드러나던 실패가 새 채널에서는 소리 없이 사라진다. 프레임이 정리되는 중에 보낸 메시지는 예외 없이 버려지고 네이티브가 응답을 빠뜨려도 웹은 알 길이 없다. 타임아웃이 없으면 기다리던 코드는 멈춘 채로 남고 센트리에도 아무것도 남지 않는다. 그래서 브릿지 호출마다 타임아웃을 두었다.

타임아웃 값은 메서드의 성격에 맞춰야 한다. 로컬에서 바로 답하는 조회는 짧게, 네이티브가 네트워크로 토큰을 갱신하는 요청은 그 갱신보다 길게 잡는다. 너무 짧으면 앞에서 본 것처럼 다시 보낸 요청이 갱신을 겹치게 만든다. 사용자 조작을 기다리는 동작에는 아예 짧은 타임아웃을 두지 않는다. iOS의 WithReply도 네이티브가 답을 붙잡고만 있으면 Promise가 끝나지 않으니 같은 원칙이 필요하다.

메서드 이름을 키로 쓸 때는 타임아웃에서 조심할 곳이 하나 있다. 타이머가 대기 목록에서 항목을 지우기 전에 그 항목이 아직 자기 것인지 확인해야 한다. 그사이 같은 메서드의 다음 호출이 덮어썼다면 앞 호출의 타이머가 뒤 호출의 항목까지 지워 버리기 때문이다. 메서드 이름을 키로 써도 웹은 사실 호출 하나하나를 구분하고 있고, 그 구분을 응답이 싣고 오지 못할 뿐이다.

구형 채널에는 제약이 하나 더 있다. 구형 채널의 postMessage는 여전히 동기 호출이라 네이티브가 이 진입점 안에서 오래 일하면 JS가 멈추고 타임아웃 타이머도 돌지 못한다. 그래서 구형 진입점은 메시지를 받자마자 작업을 넘기고 바로 돌아와야 한다.

응답을 받을 쪽이 먼저 사라지는 경우는 fetch와 같은 문제다. React 문서가 Effect에서 데이터를 가져올 때 쓰는 ignore 플래그처럼 호출한 쪽에서 늦게 온 결과를 버리면 된다.

requestId가 필요해지는 경우

지금까지 본 호출은 현재 값 하나를 원하는 조회라서 메서드 이름만으로 충분했다. 호출마다 서로 다른 결과를 기다리기 시작하면 이야기가 달라진다. 다음과 같은 호출이 생기면 requestId를 넣어야 한다.

  • 파라미터만 다른 같은 메서드를 동시에 부를 때: 상품 이미지 여러 장을 네이티브로 불러오는 경우다. 메서드 이름이 같아 앞 호출이 덮어써지고 파라미터가 달라 합칠 수도 없다. 파라미터를 키에 넣는다 해도 네이티브가 그 키를 응답에 실어 와야 하니 결국 requestId와 다르지 않다.
  • 결과를 한 번만 반영해야 하는 명령을 다시 보낼 때: 결제나 업로드를 타임아웃 뒤에 다시 시도하는 경우다. 첫 시도의 늦은 응답을 두 번째 시도의 결과로 받으면 안 된다. 네이티브가 같은 요청을 두 번 실행하지 않도록 막을 때도 이 식별자를 쓴다.
  • 응답이 여러 번 오는 호출: 다운로드 진행률이나 위치 업데이트처럼 호출 하나에 응답이 계속 온다. 같은 메서드의 호출이 둘만 떠 있어도 메서드 이름으로는 어느 호출의 응답인지 가릴 수 없다.
  • 사용자 조작을 기다리는 호출: 카메라, 파일 선택, 권한 요청은 오래 걸리고 중간에 취소될 수도 있다. 화면을 떠났다가 돌아와 다시 부르면 앞 호출의 응답이 새 호출로 들어간다. 특정 호출만 취소하라고 네이티브에 알릴 때도 그 호출을 가리킬 이름이 필요하다.

네 경우의 공통점은 늦게 온 응답이 틀린 값이 된다는 것이다. 현재 값을 돌려주는 조회에서는 늦게 온 응답도 맞는 값이었다. 메서드 이름으로 충분한지 requestId가 필요한지는 이 차이로 갈린다.

정리

WebMessageListener로 옮기자 브릿지 호출은 더 이상 값을 바로 돌려주지 않았고 동기 호출이 알아서 해 주던 요청·응답 맞추기와 실패 감지를 웹이 떠안았다. 무엇으로 요청과 응답을 맞출지는 늦게 온 응답이 틀린 값이 되는지를 보고 고르면 된다. 브릿지 호출을 성격별로 정리하면 이렇다.

호출의 성격 실제 호출 방법
결과가 필요 없는 명령 이벤트 로그 전송 맞출 필요 없음
잘 바뀌지 않는 조회 getUserData 메서드 이름 키, 한 번 받아 나눠 씀
만료되는 조회 getAccessToken 메서드 이름 키, 진행 중인 요청 공유
늦은 응답이 틀린 값이 되는 호출 없다고 봤음 requestId 또는 호출마다 MessagePort

마지막 줄 같은 호출이 생기면 그때 requestId를 넣으면 된다.

참고 자료