현재 한 스타트업에 재직하며 2달 반이라는 짧은 시간 내에 앱 버전 2.0.0을 출시해야 했습니다.
회사의 BM이 달려있는 유저앱을 담당하는 프론트엔드 개발자는 저 혼자였고, 웹 프론트엔드 개발자로써 프로젝트들을 해왔던 저는 빠른 생산성을 위해 웹뷰로 많은 스크린을 구현하기로 결정했습니다. 웹뷰로 구현된 부분은 대략 30개(스크린, 바텀시트 등) 이상입니다.
웹뷰로 개발하게 되었을 때의 장점은 아래와 같습니다.
앱을 배포하지 않아도 웹뷰를 수정하면 반영 가능 => 앱 스토어 심사가 불필요. (React Native에서는 eas 시스템, 혹은 codepush를 활용하면 js단의 파일 변경은 앱 스토어 심사 없이 반영이 가능하나, 어디까지나 앞서 말한 시스템에 종속성을 갖고 있습니다.)
앱은 최신 버전이 업데이트되면, 사용자들은 사용하고 있는 앱의 버전을 업데이트 해주어야 최근에 배포된 기능들을 사용할 수 있습니다. 이전 버전에 치명적인 버그가 있다고 했을 때, 사용자가 업데이트를 해주지 않는다면 겉잡을 수 없는 상황이 발생할 수 있습니다. 하지만 버전 체크를 통해 앱스토어 링크로 리다이렉트를 시켜준다면, 문제가 발생하지 않겠지만, 잦은 버전 업데이트는 사용자에게 좋지 않은 사용성으로 다가갈 수 있습니다.
혹시나 있을 앱 개발 프레임워크의 변경에도 용이하다. 만약 React Native로 개발된 앱이 회사의 사정으로 인해 Flutter로 마이그레이션을 해야한다고 가정했을 때, 웹뷰로 구현된 부분에서의 변경사항은 크게 X.
스타트업 입장에서 개발 인력을 구하기가 비교적 어렵지 않습니다. 왜냐하면 앱 개발을 주 스택으로 갖고 계신 분들 보다는, 웹 개발을 주 스택으로 갖고 계신 분이 더 많기 때문.
이외에도 많은 장점과 개발 와중에도 혹은 배포 직전에도 계속해서 변경되는 기획 등 현재 제 상황을 고려한 결과, 웹뷰로 많은 부분을 개발하는 것에 점점 마음이 굳혀지게 되었습니다.
하지만 main feature는 아무래도 사용자에게 보다 더 좋은 성능으로 보여줘야 했으므로 React Native 코드로 구현했습니다.
2달 반동안 제 모든 리소스를 앱 개발에 몰아넣었고, 그 기간 동안 제가 했던 많은 고민을 통해 나온 하이브리드 앱에서의 웹뷰를 관리하는 방법을 공유하고자 합니다.
각 스크린마다 웹뷰를 render한 방법
React Native에서 각각의 스크린을 라우팅한 방법 & React Native <-> React (앱 <-> 웹뷰) 간 이벤트 처리 방식
웹뷰 간 broadcasting을 통한 data refetch 또는 invalidate
react-native-webview 라이브러리를 활용해서 각각의 웹뷰 스크린을 render 했습니다. 이 라이브러리를 활용하면 제가 웹으로 구현했던 부분을 앱 내에서 웹뷰로써 띄울 수 있으며, 해당 웹뷰와 앱 간의 이벤트를 주고받아 특정 데이터 처리, 혹은 다른 스크린으로의 이동 등의 액션을 취할 수 있습니다.
export default function SomeScreen() {
const handleWebViewMessage = (event: { nativeEvent: { data: string } }) => {
const data: TMessageData = JSON.parse(event.nativeEvent.data);
// 웹뷰에서 보내는 데이터
if (data.type === 'data' && data.data) {
//이후의 데이터 핸들링 ...
}
};
return (
<WebView
source={{
uri: `${WEB_APP_BASE_URL}/some-page`,
}}
onMessage={(event) => handleWebViewMessage(event)}
/>
);
}
props로 source에 객체로 uri에 위와 같이 넘기게 되면 해당 페이지가 render됩니다. 하지만 웹뷰 특성상 React Native로 구현된 부분에 비해 로딩이 더 발생하게 되죠.

이미지 출처 : https://toss.tech/article/react-native-2024
그럼 사용자 입장에서 웹뷰가 로드되는 동안, 빈 화면을 봐야하는 상황이 됩니다. 이에 있어서 피드백을 제공해 주어야하는데, 이는 라이브러리에서 제공하는 onLoadStart, onLoadEnd 를 통해 웹뷰 로딩 상태를 관리할 수 있습니다. 그럼 이 로딩 동안 로딩 컴포넌트를 보여주는 것과 같은 피드백을 제공하면 됩니다.
export default function SomeScreen() {
const { loading, handleLoadStart, handleLoadEnd } = useWebViewLoader();
const handleWebViewMessage = (event: { nativeEvent: { data: string } }) => {
const data: TMessageData = JSON.parse(event.nativeEvent.data);
// 웹뷰에서 보내는 데이터
if (data.type === 'data' && data.data) {
//이후의 데이터 핸들링 ...
}
};
return (
<>
{loading && <LoadingIndicator />}
<WebView
source={{
uri: `${WEB_APP_BASE_URL}/some-page`,
}}
onLoadStart={handleLoadStart}
onLoadEnd={handleLoadEnd}
onMessage={(event) => handleWebViewMessage(event)}
/>
</>
);
}
처음에는 웹뷰에서의 NavBar, 앱에서의 NavBar를 따로 구현하여 각각의 라우팅을 따로 하려고 했으나, 웹뷰 개발이 어느정도 진행되고 나서 앱 개발로 넘어갔을 때, 문제점이 발생했었습니다.
웹뷰 간 라우팅을 웹뷰에서 관리를 했었고, 웹뷰로 구현된 스크린에서 네이티브로 구현된 스크린으로 이동해야할 때의 스크린 스택 관리의 어려움이었습니다. 이러한 상황에서만의 라우팅만을 웹뷰에서 네이티브로 메시지를 보냄으로써 관리하기엔 라우팅 관리의 책임이 한 곳에 모여 있지 않았습니다.
이를 해결하기 위해, 라우팅 관리의 책임과 이를 위한 탭바와 네비게이션바 또한 네이티브에 위임했습니다.
export default function SomeScreen() {
const { loading, handleLoadStart, handleLoadEnd } = useWebViewLoader();
const router = useRouter();
const handleWebMessage = (event: { nativeEvent: { data: string } }) => {
handleWebViewMessage({
event,
onNavigate: (screen: string) => router.navigate(screen),
onReplace: (screen: string) => router.replace(screen),
onPush: (screen: string) => router.push(screen),
});
};
return (
<PageLayout
title="some page"
isNavBar={true}
showBackButton={true}
>
{loading && <LoadingIndicator />}
<WebView
source={{
uri: `${WEB_APP_BASE_URL}/some-page`,
}}
onLoadStart={handleLoadStart}
onLoadEnd={handleLoadEnd}
onMessage={(event) => handleWebViewMessage(event)}
/>
</PageLayout>
);
}
위와 같이 지정해주면 웹뷰에서 앱으로 "이 스크린으로 이동해야해요"와 같은 메시지를 보내주면 handleWebViewMessage에서 콜백 처리를 통해 적절한 라우팅이 가능하게 되고, PageLayout에서의 BackButton을 통해 이전 스택의 스크린으로 이동할 수 있게 되고 최종적으로 제가 원했던 라우팅 관리의 책임을 네이티브에 위임하기를 이루어낼 수 있었습니다. 그리고 handleWebViewMessage를 통해 앱 <-> 웹뷰 간 이벤트 처리도 할 수 있었습니다.
왜 handleWebViewMessage에 콜백을 통해서 라우팅 관리를 했지? handleWebViewMessage 내부에서 라우팅을 처리해버리면 되지 않을까? 라는 의문이 드실 수 있습니다. 저는 구현부를 보았을 때 여기서 어떻게 화면이 그려지고, 작동을 하게 되는지 알 수 있게끔 적절히 데이터의 흐름을 보여주는 것이 다른 사람이 코드를 보았을 때 어려움을 겪지 않는다라고 생각합니다.
그리고 무엇보다, handleWebViewMessage는 스크린 라우팅만을 담당하는 것이 아닌, 함수명과 같이 웹뷰에서의 메시지를 다루는 함수입니다. 특정 링크를 열어야 한다던지, 웹뷰에서의 특정 클릭 등의 상호작용을 통해 앱에서 구현된 바텀시트의 열고 닫힘 상태를 관리해야한다던지 등의 역할을 담고 있기에 위와 같이 구현했습니다.
하지만 이는 제 주관적인 생각 일 뿐이며, 웹뷰-앱 간 메시징에서도 역할을 분리할 수도 있습니다. 예를 들어, 라우팅 핸들링 함수, Linking 함수, 바텀시트 컨트롤 핸들링 함수와 같이 각각의 기능을 세분화시킬 수도 있습니다.
앱 내에서 웹뷰는 하나의 브라우저에서 공유되는 것이 아닌 서로 다른 브라우저입니다. 저는 서버 상태를 관리할 때 tanstack-query를 사용했는데, 특정 액션에 대한 data refetch, invalidate를 하게 될 때 각각의 서로 다른 스크린에서의 웹뷰는 이를 반영할 수 없게 됩니다.
물론 스크린을 이동할 때마다 해당 스크린의 웹뷰를 reload를 해준다면 해결할 수 있습니다만, 이는 부드러운 사용자 경험으로 다가갈 수 없게 됩니다. 결국에 다시 웹뷰를 로드하고 데이터를 패칭하는 시간을 기다려야하게 됩니다.
제가 직면했던 문제 상황은 아래와 같습니다.
사용자가 현재 결제 수단이 등록되지 않은 상황에서
구독 상품 구매 스크린에서 구독 상품을 구매하려고 했을 때,
1.지갑 스크린으로 이동시켜 결제 수단 등록을 유도한다.
2. 사용자는 결제 수단 등록을 완료한 후, 뒤로가기 버튼을 눌러 다시 구독 상품을 구매하려고 한다.
3. 하지만구독 상품 구매 스크린에서 사용자의 카드 정보에 대한 data refetching이 되지 않았기 때문에 구독 상품을 구매하기 위해 버튼을 눌러도 다시 지갑 스크린으로 이동하게 된다.
상당히 치명적인 문제입니다. 사용자는 앱을 껐다 켰을 때는 문제 없이 구독 상품 구매가 가능하게 되겠지만, 이는 매우 좋지 않은 사용자 경험으로 다가가게 됩니다.
이를 위해 각 웹뷰 브라우저 간 broadcasting을 통해 서버 상태를 동기화해주어야했고, Broadcast Channel API에 대해 알게 되었고, pubkey/broadcast-channel를 통해 해결할 수 있었습니다.
tanstack-query를 사용하기에 broadcastQueryClient의 사용을 고려했었으나, 아직 Experimental 기능이기도 하고, 래퍼런스가 충분하지 않아서 빠르게 문제를 해결하기 위해서 보다 많은 래퍼런스를 갖고 있는 pubkey/broadcast-channel를 사용하게 되었습니다.
Broadcast Channel API란 동일 출처이지만, 서로 다른 브라우징 컨텍스트 간의 통신을 가능하게 해주는 API입니다.

출처: https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API
현재 제가 처한 문제 상황을 해결할 수 있는 해결책이었고, 어떻게 서버 상태를 동기화 했는지 설명하도록 하겠습니다.
import { QueryClient } from '@tanstack/react-query';
import { BroadcastChannel } from 'broadcast-channel';
type QueryMessageType = 'INVALIDATE_QUERIES' | 'REFETCH_QUERIES';
interface IMessage {
type: QueryMessageType;
queryKey: string[];
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 0,
},
},
});
const channel = new BroadcastChannel('query-sync-channel');
channel.onmessage = (message: IMessage) => {
if (message.type === 'INVALIDATE_QUERIES') {
queryClient.invalidateQueries({ queryKey: message.queryKey });
return;
}
if (message.type === 'REFETCH_QUERIES') {
queryClient.refetchQueries({ queryKey: message.queryKey });
return;
}
};
export { queryClient, channel };
위와 같이 메시지 타입을 지정해줍니다. queryClient 내에서만 사용될 broadcasting이라 INVALIDATE_QUERIES, REFETCH_QUERIES로만 역할을 한정지었습니다.
channel.postMessage({
type: 'INVALIDATE_QUERIES',
queryKey: getUserCardListQueryKey().queryKey,
});
위와 같이 하면, 카드를 등록한 후 다시 구독 상품 구매 스크린으로 돌아갔을 때 사용자의 카드 리스트에 대한 data가 invalidate 되어 부드러운 사용자 경험을 제공할 수 있게 됩니다.

이렇게 React Native 하이브리드 앱 개발 과정에서 겪은 웹뷰 관련 문제를 어떻게 해결할 수 있었는지에 대해 알아보았습니다. 물론 제가 소개한 방법이 아니더라도, 다른 방법으로도 해결할 수 있을 것입니다.
그리고, 꼭 React Native가 아닌 다른 프레임워크를 사용하더라도 비슷한 방식으로 해결할 수 있을 것입니다. 제가 소개한 방법이 여러분들에게도 작은 도움이 되길 바라며, 이 글을 마치겠습니다. 읽어주셔서 감사합니다.
잘 봤습니당!