RN WebView에서 유니버셜 링크가 안 열릴 때 — onShouldStartLoadWithRequest와 onOpenWindow
웹뷰 안에 띄운 페이지에서 “앱에서 열기” 같은 버튼을 누르면 외부 앱이 열려야 한다. 그런데 어느 날 이 동작이 안 됐다. 같은 링크를 모바일 사파리에서 열면 멀쩡히 앱이 떴고, RN 화면 안에 들여놓은 웹뷰에서만 아무 반응이 없었다. 원인을 추적하다 보니 결국 RN WebView가 웹 페이지의 네비게이션을 가로채는 두 개의 콜백, onShouldStartLoadWithRequest와 onOpenWindow가 서로 다른 사건을 잡는다는 사실에 닿았다. 이 글은 그 두 콜백이 각각 무엇을 잡고 무엇을 흘려보내는지, 그리고 그 차이가 왜 “웹뷰 안의 웹”과 “RN 스크린”의 동작 차이로 이어지는지 정리한다.
웹페이지가 앱을 여는 다섯 가지 방법
먼저 웹페이지가 “다른 화면으로 가거나 외부 앱을 여는” 방식에는 어떤 모양들이 있는지 보자. RN WebView가 잡아내는 사건은 결국 이 방법들에 대응한다.
같은 창 안에서 URL을 바꾸는 가장 흔한 두 가지는 <a href="..."> 클릭과 window.location.href = "..." 할당이다. 둘 다 “지금 보고 있는 문서가 다른 URL로 이동”하는 동일한 종류의 네비게이션이다. 폼 제출(<form action="...">)도 같은 범주에 들어간다.
새 창을 여는 방법은 두 가지다. <a href="..." target="_blank">는 마크업에 “새 컨텍스트에서 열어 달라”는 의도가 표시된 링크고, window.open(url)은 스크립트로 같은 의도를 표시한다. 브라우저는 이 둘을 동일하게 “새 윈도우/탭을 만든다”로 해석한다.
마지막은 비-HTTP 스킴이다. kakaotalk://, intent://, itms-apps:// 같은 커스텀 스킴 URL과, 형태는 https://지만 OS가 등록된 앱으로 라우팅해 주는 유니버셜 링크(iOS) 및 앱 링크(Android)가 여기에 들어간다. 외형은 <a href>나 window.open()과 같지만, 실제 디스패치 주체가 브라우저가 아니라 OS라는 점이 핵심이다.
onShouldStartLoadWithRequest가 잡는 것
onShouldStartLoadWithRequest는 “지금 이 WebView가 새 URL을 로드하려고 한다”는 시점에 호출된다. 내부적으로는 iOS의 WKNavigationDelegate.decidePolicyFor:navigationAction과 Android의 WebViewClient.shouldOverrideUrlLoading을 같은 인터페이스로 묶은 콜백이다. 콜백은 boolean을 돌려준다. true를 돌려주면 그대로 WebView가 로드하고, false를 돌려주면 로드가 취소된다. 우리가 “외부 앱으로 빠져나가야 한다”고 판단했을 때 false를 돌려주고 Linking.openURL()로 토스하는 패턴이 여기서 나온다.
중요한 건 이 콜백이 잡는 이벤트의 범위다. <a href> 클릭, window.location.href 할당, 폼 제출처럼 같은 문서가 다른 URL로 “이동”하는 이벤트는 모두 여기에 들어온다. 커스텀 스킴이나 유니버셜 링크도 같은 창에서 호출되는 한 여기에 들어온다. 그래서 request.url.startsWith("kakaotalk://") 같은 조건으로 분기해 Linking.openURL로 보내는 방식이 잘 동작한다.
그러나 잡지 못하는 이벤트가 있다. target="_blank"가 붙은 링크와 window.open() 호출이다. 이 둘은 “현재 WebView의 네비게이션”이 아니라 “새 WebView를 만들어 달라”는 요청이기 때문에 별도의 콜백으로 분기된다.
onOpenWindow가 잡는 것
onOpenWindow는 RN WebView의 콜백이다. iOS에서는 WKUIDelegate.webView:createWebViewWithConfiguration:forNavigationAction:windowFeatures:, Android에서는 WebChromeClient.onCreateWindow()를 감싼다. 호출 시점은 정확히 “페이지가 새 컨텍스트를 만들려고 한다”는 순간이다. target="_blank" 링크 클릭과 window.open() 호출이 여기로 떨어진다.
Android 쪽에 함정이 하나 있다. 기본 WebChromeClient는 onCreateWindow를 비활성 상태로 둔다. 그래서 WebView 컴포넌트에 setSupportMultipleWindows={true}를 명시적으로 켜 줘야 콜백이 실제로 발화한다. iOS의 WKWebView는 target="_blank"로 새 창을 열려는 요청을 기본 상태에서 그냥 무시한다. 즉 두 콜백 중 한쪽만 달아 두면 “모바일 사파리에서는 되는데 RN 웹뷰에서는 안 된다”는 증상이 정확히 이쪽 경로에서 발생한다.
콜백이 받는 이벤트에는 nativeEvent.targetUrl이 들어 있다. 이 URL을 보고 외부 앱으로 보낼지(유니버셜 링크라면 Linking.openURL로 OS에 위임), 같은 WebView에서 이어 열지(스크립트로 ref.injectJavaScript를 써서 현재 창의 location을 바꾸기) 결정한다. 후자가 필요한 경우는 의외로 흔한데, “새 탭으로 열기”의 의도가 단지 “나가지 않고 새로 띄우기”인 결제 페이지 같은 경우가 그렇다.
그래서 왜 유니버셜 링크가 안 열렸나
문제의 페이지는 “앱에서 열기” 버튼을 window.open()로 두고 있었다. 같은 URL을 사파리에서 열면 iOS가 associated domain을 확인하고 앱으로 라우팅한다. 그런데 RN WebView 안에서는 두 가지가 동시에 막혀 있었다.
첫째, https://...app.io/...로 시작하는 스킴이 웹스킴으로 잘못 판정되고 있었다.
둘째, onShouldStartLoadWithRequest에는 이 클릭이 도달하지 않았다. 앱스킴을 window.open()으로 열고 있었기 때문이다. 우리는 거기서 https://...app.io를 잡아 Linking.openURL로 던지는 분기를 가지고 있었지만 발화 자체가 없었다.
셋째, onOpenWindow는 아예 붙어 있지 않았다. 그 결과 iOS WKWebView에서의 새 창 요청은 조용히 무시되었고, Android는 setSupportMultipleWindows가 꺼져 있어 똑같이 무시됐다.
해결은 두 콜백을 모두 다는 것이다. onShouldStartLoadWithRequest는 같은 창에서 일어나는 외부 스킴/유니버셜 링크를 거른다. onOpenWindow는 새 창으로 열리는 동일한 종류의 요청을 같은 규칙으로 거른다. 두 갈래로 들어오는 사건을 같은 “외부 앱으로 보낼지 판단” 함수에 모아 주면 “target에 따라 동작이 달라지는” 현상이 사라진다. Android에서는 추가로 setSupportMultipleWindows={true}를 켠다.
<WebView
ref={webRef}
setSupportMultipleWindows
onShouldStartLoadWithRequest={(request) => {
if (shouldOpenExternally(request.url)) {
Linking.openURL(request.url);
return false;
}
return true;
}}
onOpenWindow={(event) => {
const { targetUrl } = event.nativeEvent;
if (shouldOpenExternally(targetUrl)) {
Linking.openURL(targetUrl);
return;
}
// 외부 앱이 아니면 현재 WebView에서 이어서 연다
webRef.current?.injectJavaScript(
`window.location.href = ${JSON.stringify(targetUrl)};`
);
}}
/>
shouldOpenExternally는 “http(s)가 아닌 스킴이거나, 우리가 알고 있는 유니버셜 링크 호스트”라는 규칙을 둔다. 유니버셜 링크는 외형이 https라서 호스트 화이트리스트나 최소한의 패턴 유효성 검사가 필요하다.
RN 스크린에서 같은 링크를 다룰 때는 무엇이 다른가
같은 “앱에서 열기” URL을 RN 스크린 안의 <Pressable>버튼에 연결한다고 가정하면, 호출은 자바스크립트에서 직접 Linking.openURL(url)로 떨어진다. 이 호출은 OS의 URL 디스패처(iOS의 UIApplication.openURL:, Android의 Intent.ACTION_VIEW)로 곧장 나간다. 중간에 “렌더링 컨텍스트의 네비게이션을 가로챈다”는 단계가 없다. associated domain이 매칭되면 OS가 호스트 앱으로 라우팅하고, 매칭되지 않으면 시스템 브라우저가 응대한다. 즉 RN 스크린에서는 우리가 직접 호출하기 때문에 “누가 이 URL을 처리할지” 결정 권한이 처음부터 OS에 있다.
웹에서는 결정 권한이 브라우저에 있다. <a href>를 클릭하면 브라우저가 스킴을 보고 자기 자신이 처리할지(http/https), OS에 위임할지(custom scheme), associated domain을 보고 앱으로 보낼지(universal link)를 정한다. 사파리는 이 결정을 사용자 입장에서 자연스럽게 내리도록 설계돼 있어서, 우리가 따로 신경 쓸 게 없다.
WebView는 둘의 중간이다. 렌더링 컨텍스트는 웹이지만 호스트가 RN 앱이라서, 호스트가 원하면 결정을 가져갈 수 있다. RN WebView는 그 “가져갈 수 있는 결정 지점”을 두 개의 콜백으로 노출한다. 같은 창의 네비게이션은 onShouldStartLoadWithRequest로, 새 창 요청은 onOpenWindow로. 두 콜백 중 한쪽만 처리하는 코드는 “브라우저처럼 사용자 의도대로 동작하는 것 같다가도 특정 마크업에서 갑자기 침묵하는” 웹뷰가 된다.
좀 더 쉽게 WebView의 흐름을 정리해보면 이렇다.
RN 앱 안에는 자바스크립트 런타임이 두 개 있다.
RN 자체가 자바스크립트로 도는데, 그 자바스크립트는 React Native의 JS 엔진(JSC 또는 Hermes)에서 실행된다. Pressable의 onPress, Linking.openURL, RN 코드에 적는 모든 자바스크립트가 여기서 돈다. WebView 안에 띄운 웹페이지의 자바스크립트는 완전히 다른 곳 - WKWebView 안의 WebKit 자바스크립트 엔진에서 돈다. 페이지의
그래서 HTML <button>을 누르면 무슨 일이 벌어지나
페이지 안에 이런 버튼이 있다고 해보자.
<button onclick="location.href = 'https://link.app.io'">앱에서 열기</button>
탭 이벤트는 RN 코드에 절대 도달하지 않는다. WebView 안의 자바스크립트 엔진이 그 클릭을 받고, 그 안에서 onclick 핸들러가 실행된다. 이 시점까지는 네이티브와 무관한 일이다.
그러다 location.href = ’…’ 할당이 실행되는 순간, WebView가 “어 페이지가 다른 URL로 가고 싶어 하네”를 감지한다. 여기서부터가 네이티브의 일이다. WKWebView가 자기 WKNavigationDelegate.decidePolicyFor:를 부르고, RN WebView가 그걸 RN 자바스크립트로 토스해 준다. 그게 onShouldStartLoadWithRequest다.
즉, 버튼 클릭 자체가 보이는 게 아니라, 그 클릭의 결과로 일어나는 네비게이션만 콜백에 잡힌다. 겉으로는 “누군가 페이지를 https://link.app.io/로 보내려 한다”만 보이는 거고, 그 누군가가 사용자였는지, setTimeout이었는지, 광고 스크립트였는지는 콜백에서 분간되지 않는다.
마크업이 뭘 하느냐에 따라 어느 콜백으로 가는지가 달라진다.
onclick="location.href = '...'" 또는 <a href="..."> 처럼 같은 창에서 이동하면 onShouldStartLoadWithRequest로 오고,
onclick="window.open('...')" 또는 <a href="..." target="_blank">처럼 새 창을 열려고 하면 onOpenWindow로 간다.
onclick="fetch('/api/foo')"처럼 네비게이션이 일어나지 않는 클릭은 어느 콜백에도 안 오는데, 이건 페이지가 자기 안에서 끝나는 일이라 네이티브 입장에선 보이지 않는 것이기 때문이다.
정리
onShouldStartLoadWithRequest는 같은 WebView 안의 URL 이동을 가로챈다. <a href>, window.location.href, 폼 제출이 여기에 들어온다. 유니버셜 링크나 커스텀 스킴이 같은 창에서 호출되는 한 여기서 잡으면 된다.
onOpenWindow는 새 컨텍스트를 만드는 요청을 가로챈다. target="_blank"와 window.open()이 여기에 들어온다. iOS는 콜백을 달지 않으면 요청을 버리고, Android는 setSupportMultipleWindows를 켜야 콜백이 발화한다.
웹뷰 안에서 “외부 앱으로 이어지는 링크 - App to App”를 안정적으로 처리하려면 두 콜백을 한 쌍으로 보고 같은 규칙을 적용하는 편이 안전하다. 한쪽만 다루면 같은 의도의 링크가 마크업 모양에 따라 다르게 동작한다. RN 스크린에서 Linking.openURL을 쓰는 코드와 달리, 웹뷰는 결정 권한이 페이지와 호스트 사이에서 갈리는 환경이라는 점을 염두에 두는 게 핵심이다.