WebView Bridge 대체 구현과 환경 감지
TL;DR
@webview-bridge의initialBridge는 브라우저용 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;
}
문제는 일반 브라우저에서도 isWebViewBridgeAvailable이 true가 되는 경우였다.
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 목록이다. 일반 브라우저에서는 비어 있다. initialBridgeMethodNames는 initialBridge에 들어 있는 함수 이름 목록이다. 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된다. 기본값인 false면 undefined로 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을 넣으면 그 이름도 목록에 들어간다. 이 문제의 기준은 두 가지다.
- 환경 판정은
window.ReactNativeWebView처럼 환경을 보는 값으로 한다. - 브라우저 fallback은 mock bridge가 아니라 브라우저가 실제로 제공하는 경로로 둔다.
mock은 테스트와 개발 편의를 위한 구현일 뿐이다. 환경 판정의 입력이 되면 브라우저가 앱처럼 동작한다고 잘못 답할 수 있다.