기록

WebView Bridge 대체 구현과 환경 감지

TL;DR

  • @webview-bridgeinitialBridge는 브라우저용 mock을 넣는 자리지만, 그 mock 이름도 bridge method로 집계된다.
  • isWebViewBridgeAvailable은 React Native WebView 존재 여부가 아니라 호출 가능한 method name 개수를 본다.
  • mock을 넣으면 브라우저에서도 bridge available이 true가 될 수 있다.
  • 환경 판정과 fallback 구현은 같은 객체에 두면 안 된다.

한 벌의 Next.js 웹을 일반 브라우저와 Expo WebView 안에서 함께 열었다. 전화 걸기와 문자 보내기는 브라우저에서는 tel:, sms: URL로 처리할 수 있고, 앱 안에서는 네이티브 기능을 쓰고 싶었다. 연락처 읽기는 브라우저에서 제공하지 않으므로 bridge가 필요했다. 웹에서는 이런 형태로 호출한다.

await bridge.openDialer(phone);

브라우저에는 네이티브 함수가 없으므로 호출 전에 bridge 사용 가능 여부를 확인했다.

if (bridge.isWebViewBridgeAvailable) {
  await bridge.openInAppBrowser(url);
} else {
  window.location.href = url;
}

문제는 일반 브라우저에서도 isWebViewBridgeAvailabletrue가 되는 경우였다.

initialBridge

bridge 설정에는 initialBridge를 넣어 두었다.

export const bridge = linkBridge<AppBridge>({
  throwOnError: true,
  initialBridge: {
    openInAppBrowser: async (url) => {
      alert("mocking: " + url);
    },
    share: async () => true,
    openNaverMap: async () => true,
    isWebLoaded: false,
    isWebViewBridgeAvailable: false,
  },
});

의도는 브라우저에서 죽지 않는 대체 구현이었다. isWebViewBridgeAvailable: false도 브라우저용 초기값처럼 넣었다. 하지만 이 값은 bridge instance의 getter를 덮지 못한다. isWebViewBridgeAvailable은 단순 필드가 아니라 라이브러리 내부에서 계산되는 값이다.

available의 기준

설치된 @webview-bridge/web@1.7.8의 구현은 대략 이런 구조다.

get isWebViewBridgeAvailable() {
  return this._bridgeMethods.length > 0;
}

this._bridgeMethods = [
  ...bridgeMethods,
  ...initialBridgeMethodNames,
];

여기서 bridgeMethods는 네이티브 WebView가 주입한 method name 목록이다. 일반 브라우저에서는 비어 있다. initialBridgeMethodNamesinitialBridge에 들어 있는 함수 이름 목록이다. mock 함수도 이름으로 집계된다.

initialBridge.openInAppBrowser = function
initialBridge.share = function
initialBridge.openNaverMap = function

_bridgeMethods.length > 0
  -> isWebViewBridgeAvailable === true

따라서 initialBridge에 mock 함수를 하나라도 넣으면 일반 브라우저에서도 bridge available이 참이 될 수 있다. isWebViewBridgeAvailable: false는 함수가 아니므로 method name 목록에는 들어가지 않는다. 그리고 getter를 덮는 것도 아니다.

환경 판정과 method 판정

라이브러리 안에는 서로 다른 판정이 있다.

판정 보는 것
React Native WebView 환경인가 window.ReactNativeWebView 존재
Bridge method가 있는가 _bridgeMethods 배열 길이

실제 네이티브 method를 만드는 코드는 window.ReactNativeWebView를 본다. 웹뷰 안에서는 네이티브 method가 mock 위에 덮이고, 브라우저에서는 initialBridge만 남는다. 문제는 호출부가 “앱 안인가?”를 묻고 싶어 하면서 isWebViewBridgeAvailable을 쓴 것이다. 이 getter는 앱 환경이 아니라 호출 가능한 이름이 있는지를 답한다. mock 이름도 호출 가능한 이름이다.

브라우저
  -> window.ReactNativeWebView 없음
  -> initialBridge mock 있음
  -> isWebViewBridgeAvailable true
  -> 네이티브 전용 method 호출 시 undefined

OAuth 인앱 브라우저처럼 mock 목록에 없는 method를 호출하면 브라우저에서만 실패한다.

mock의 위치

해결은 판정에 쓰는 bridge instance에서 initialBridge를 제거하는 것이다.

export const bridge = linkBridge<AppBridge>({
  throwOnError: true,
});

브라우저 fallback은 bridge mock이 아니라 호출부의 웹 표준 경로로 둔다.

if (bridge.isWebViewBridgeAvailable) {
  await bridge.openDialer(phone);
  return;
}

window.location.href = `tel:${phone}`;

tel:sms:는 브라우저가 원래 처리할 수 있는 경로다. mock alert보다 실제 fallback에 가깝다. 환경 판정과 대체 구현을 같은 객체에 넣으면 대체 구현을 추가하는 행위가 판정 결과를 바꾼다. 이 경우 mock이 많아질수록 “bridge가 있다”는 답이 더 강해졌다.

네이티브 실패의 모양

네이티브 method 호출 실패도 한 가지 모양으로만 오지 않는다. 라이브러리의 네이티브 디스패처는 method가 없거나 실행 중 예외가 나면 에러 프레임을 보낸다. throwOnError: true면 웹의 Promise가 reject된다. 기본값인 falseundefined로 resolve될 수 있다. 함수가 아닌 값이 method 자리에 있으면 더 애매하다. 응답 프레임이 오지 않아 Promise가 timeout까지 기다릴 수 있다.

상황 웹에서 보이는 결과
method 없음 throwOnError: true에서 reject
method 실행 중 throw throwOnError: true에서 reject
method가 함수가 아님 응답 없이 timeout 가능
method가 빈 배열 반환 성공한 빈 결과

따라서 bridge method의 존재 여부와 실제 동작 여부는 별도다. isNativeMethodAvailable(name)이 참이어도 권한 거부, native exception, 빈 결과는 호출해 봐야 알 수 있다.

정리

isWebViewBridgeAvailable을 “지금 WebView 안인가”로 읽으면 안 된다. 이 값은 네이티브 환경 판정이 아니라 bridge method name 목록을 기준으로 한 available 판정이다. initialBridge에 mock을 넣으면 그 이름도 목록에 들어간다. 이 문제의 기준은 두 가지다.

  1. 환경 판정은 window.ReactNativeWebView처럼 환경을 보는 값으로 한다.
  2. 브라우저 fallback은 mock bridge가 아니라 브라우저가 실제로 제공하는 경로로 둔다.

mock은 테스트와 개발 편의를 위한 구현일 뿐이다. 환경 판정의 입력이 되면 브라우저가 앱처럼 동작한다고 잘못 답할 수 있다.