웹뷰 브리지 라이브러리 개발 기록

배경
안녕하십니까? 저는 에코노베이션 26기 박건규입니다. 저는 25년도 상반기 프로젝트로 안전한 산행을 위한 앱, 산결 어플의 프론트엔드 개발을 진행하였습니다. 이 과정에서 단 한번도 해보지 못했던 앱 개발을 진행해야만 했고, 친숙한 리액트 문법을 사용할 수 있는 리액트 네이티브를 선택하게 되었습니다. 그리고 빠른 사용자 테스트 진행을 위해서 화면 대부분을 웹뷰를 통해 구현하기로 하였습니다.
[프로젝트 산결을 소개합니다! 🙋]
여러분들은 등산을 좋아하시나요 🙂 많은 2~30대 분들은 등산 비율이 높지 않지만, 사고 비율은 다른 연령대에 비해서 가장 높다는 사실 알고계신가요? 저희는 이 문제점에 주목하여, 등산을 즐겁게 할 수 있도록 하면서 등산 과정에서 발생할 수 있는 사고 예방, 감지 및 구조 요청을 돕는 앱, 산결을 개발하고 있습니다!
[앱 깃허브 링크]:(https://github.com/JNU-econovation/Soop-APP)
[웹 깃허브 링크]:(https://github.com/JNU-econovation/Soop-WEB)
현재는 앱과 웹을 모노레포로 관리하고있습니다!
웹뷰와 네이티브 앱 간의 통신
우선 웹뷰를 띄우기 위해서는 Webview 라이브러리를 사용하는 방법이 가장 대중적입니다.
웹뷰를 사용할 때에는 Webview 컴포넌트에 보여주고 싶은 웹의 주소를 source로 넘겨주시면 됩니다.

JS는 웹과 앱 두 환경에서 돌아가지만, 결국 하나의 서비스를 이루게 됩니다. 이에 따라서 하나의 통일된 사용자 경험을 주는 것이 중요합니다. 이를 위해 웹과 앱은 서로 필요한 정보들을 소통해야 하는 상황들이 많이 발생하게 됩니다. 웹뷰와 네이티브 앱 간의 통신을 위해서는 아래와 같은 방식을 사용하실 수 있습니다.
네이티브 앱에서 웹뷰로 메시지 보내기
네이티브 앱에서 웹뷰로 메시지를 보내려면 WebView 컴포넌트의 postMessage 메서드를 사용하실 수 있습니다. 이 메서드는 웹뷰 내의 JavaScript 코드로 메시지를 전달합니다. 예를 들어, 네이티브 앱에서 다음과 같이 메시지를 보내실 수 있습니다.
import React from 'react';
import { WebView } from 'react-native-webview';
import { View, Button } from 'react-native';
const MyWebView = () => {
const webviewRef = React.useRef(null);
const sendMessageToWebView = () => {
if (webviewRef.current) {
webviewRef.current.postMessage('Hello from Native App!');
}
};
return (
<View style=>
<WebView
ref={webviewRef}
source=
/>
<Button title="Send Message to WebView" onPress={sendMessageToWebView} />
</View>
);
};
export default MyWebView;
이렇게 보낸 메시지는 웹 내에서 window.ReactNativeWebView.postMessage를 통해 받으실 수 있습니다.
// 웹 내에서 메시지 받기
window.addEventListener('message', (event) => {
console.log('Received message from Native App:', event.data);
});

반대로 웹에서 네이티브 앱으로 메시지를 보내는 경우는 postMessage를 사용하시면 됩니다.
window.ReactNativeWebView.postMessage('Hello from Web!');
앱에서는 이를 Webview의 onMessage 이벤트 핸들러를 통해 받으실 수 있습니다.
<WebView
ref={webViewRef}
source=
onMessage={(event) => {
console.log('Received message from WebView:', event.nativeEvent.data);
}}

웹과 앱이 서로 메시지를 주고 받는 것은 정말 코드 몇 줄로 간단히 구현이 가능합니다. 다만 이를 그대로 사용할 수는 없었는데요. 실제 서비스를 개발하다보면 이보다 훨씬 많은 경우를 고려해야만 합니다.!
기존 이벤트 기반 통신의 문제점들
아직 앱 개발을 많이 해보지 않은 짧은 식견으로 생각한 문제점은 다음과 같습니다.
-
웹과 앱 간의 메시지 형식을 정하기 어렵습니다.
-
이벤트 기반 코드들은 전부 리액트와 생명 주기가 맞지 않는 사이드 이팩트들입니다.
-
앱에서 웹으로 메시지를 보내는 경우, 웹이 로딩되지 않은 상태에서 메시지를 보내면 메시지를 받지 못하게 됩니다.
-
서로간의 통신에서 response가 존재하지 않아 전달이 올바르게 되었는지, 혹은 에러가 발생했는지를 알 수가 없습니다.
분명히 저뿐만 아니라 다른 개발자분들도 동일하게 느꼈을 것이라 생각합니다. 그러하여 다양한 라이브러리를 찾아보았는데, 생각보다 라이브러리가 많지 않았습니다. 정말 잘 만들어진 FE conf 에서 발표된 라이브러리가 있었지만, 앱을 처음 개발하는 저의 입장에서는 가볍게 제게 필요한 기능만을 제공하는 저만의 코드가 있기를 바랐습니다. 그리하여 직접 라이브러리를 개발하기로 하였습니다.
(새로운 라이브러리를 또 공부해서 사용하는 것보다는 제가 필요한 기능만을 가진 가벼운 라이브러리를 만드는 것이 시간적, 개발적으로나 훨씬 비용이 적게 들 것으로 예상하였습니다!)
라이브러리 개발
라이브러리의 목표
라이브러리의 목표는 웹과 네이티브 앱 간의 통신 로직을 간단하게 작성할 수 있도록 도와주는 것입니다. 이를 위해 다음과 같은 기능을 제공하고자 합니다.
-
메시지 타입 정의: 웹과 앱 간의 메시지 타입을 정의할 수 있는 기능을 제공합니다. 정한 타입을 통하여 메시지의 형식을 일관되고, 타입 세이프하게 관리할 수 있습니다.
-
리액트 훅 사용: 리액트 훅을 사용하여 메시지를 보내고 받을 수 있는 간단한 API를 제공합니다. 이를 통해 개발자는 복잡한 사이드 이팩트 관리 없이 메시지를 주고 받을 수 있습니다.
-
응답 처리: 메시지를 보낸 후 응답을 받을 수 있는 기능을 제공합니다. 이를 통해 메시지가 올바르게 전달되었는지 확인할 수 있습니다.
-
에러 처리: 메시지 전송 중 발생할 수 있는 에러를 처리할 수 있는 기능을 제공합니다. 이를 통해 개발자는 에러 상황을 쉽게 처리할 수 있습니다.
-
통신 가능 시점 관리: 웹이 로딩된 후에만 메시지를 보낼 수 있도록 관리합니다. 이를 통해 웹이 로딩되지 않은 상태에서 메시지를 보내는 문제를 해결할 수 있습니다.
위의 목표들은 흔히 웹에서 생각할 수 있는 HTTP 통신과 유사합니다. 그래서 이를 구현하기 위해서는 HTTP 통신을 구현하는 것과 유사한 방식으로 개발하면 될 것이라 생각했습니다.
통신 가능 시점 확인하기
해결해야하는 문제 상황
개발하다보면 웹을 띄우자마자 특정 동작을 해야하는 경우가 존재합니다. 뿐만 아니라 어느 시점부터 웹과 앱 간의 통신이 가능한지 확인해야만 안정적으로 메시지를 주고 받을 수 있을 것입니다.
처음에는 단순히 웹뷰가 로딩된 후(Webview 컴포넌트의 onLoad 사용)에만 메시지를 보내는 방법을 생각했습니다. 하지만 이는 웹뷰가 로드되었다는 것을 알려줄 뿐 정말로 앱에서 보낸 메시지를 받을 수 있는 Next.js의 js가 올바르게 로드되었는지를 나타낼 수는 없었습니다. 그렇기에 대부분의 경우 올바르게 동작하지 않았습니다.
아이디어 : TCP 3-Way-Handshake
어떻게 이 문제를 해결할 수 있을까 고민하다가 TCP 통신을 생각해보았습니다.
- 통신을 시작하는 주체가 먼저 SYN 세그먼트를 보냅니다.
- 상대방은 SYN 세그먼트를 받고, SYN/ACK 세그먼트를 보냅니다.
- 마지막으로 주체는 ACK 세그먼트를 보내며 연결을 완료합니다. 이때 ACK와 동시에 데이터를 보낼 수 있습니다.
이러한 3-Way-Handshake 과정을 통해 각자 통신에 필요한 준비가 완료되었음을 알 수 있습니다.
이를 웹뷰 통신 로직에 적용해볼 수 있지 않을까요?
개발 과정
처음에는 웹뷰에 javascript를 삽입(inject)하여, 웹뷰가 로딩되면 바로 메시지를 보내는 방식으로 구현하고자 하였습니다. 이를 통해서 굳이 웹에서는 앱과의 통신에 대해서 신경쓰지 않으며, 앱 내에서만 통신을 관리할 수 있을 것이라 생각했습니다.
export const WEB_VIEW_HANDSHAKE = `
(() => {
alert('웹뷰 핸드쉐이크 시작');
const webviewReady = (syn, ack) => window.ReactNativeWebView.postMessage(JSON.stringify({ name: 'webview-handshake', meta: {syn, ack} }));
webviewReady(1, 0);
// 만약 웹뷰 요청을 보낸 이후 1초 이내에 응답을 받지 못하면 한번 더 요청을 보낸다.
// 1초로 한 이유는 일반적인 리눅스 시스템에서 TCP 연결을 위한 SYN 타임아웃이 1초로 설정되어 있기 때문이다.
let timeoutId = setTimeout(() => {
webviewReady(1, 0);
}, 1000);
function handleMessage(event) {
try {
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
if (data.name === 'webview-handshake' && data.meta.syn === 1 && data.meta.ack === 1) {
webviewReady(0, 1);
if (timeoutId) {
clearTimeout(timeoutId);
}
window.removeEventListener('message', handleMessage);
}
} catch (error) {
console.error('메시지 처리 중 오류 발생:', error);
}
}
window.addEventListener('message', handleMessage);
return true;
})()
`;
하지만 이 방법은 웹뷰가 로딩되기 전에 메시지를 보내는 문제가 발생했습니다. 이를 해결하기 위해서 웹뷰가 완전히 로딩되어 js가 동작할 수 있는 시점을 직접 웹에서 알려주도록 하였습니다. 웹에서 앱으로 메시지를 보낸다는 것은, javascript가 완전히 로딩되었다는 것을 의미하며, 그 시점 이후부터는 앱으로부터 메시지를 받을 수 있다는 것을 의미합니다. (Next를 기준으로 생각해보면, hydration이 완료된 이후(window를 사용할 수 있는 시점부터)라고 생각할 수 있습니다.)
HTTP로 생각해보면 앱은 항상 실행되고 있는 서버이고, 웹은 중간중간 간헐적으로 접속을 하고 끊기는 클라이언트라고 생각할 수 있습니다. 즉 어쩌면 웹이 먼저 메시지를 보내는 것은 자연스러운 것입니다.
// 3-way-handshake 로직이 추가된 Webview 컴포넌트
const WebViewWithInjected = forwardRef<WebView, WebViewWithInjectedProps>(
({ source, onMessage, onReadyToMessage }, ref) => {
const [isWebViewReady, setIsWebViewReady] = useState(false);
const webViewRef = useRef<WebView>(null);
useImperativeHandle(ref, () => webViewRef.current as WebView);
useEffect(() => {
if (isWebViewReady) {
Alert.alert("onReadyToMessage 실행");
onReadyToMessage?.();
}
}, [isWebViewReady, onReadyToMessage]);
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
const reqMessage = JSON.parse(event.nativeEvent.data);
if (reqMessage.name === "webview-handshake") {
if (reqMessage.meta.syn === 1 && reqMessage.meta.ack === 0) {
webViewRef.current?.postMessage(
JSON.stringify({
name: "webview-handshake",
meta: { syn: 1, ack: 1 },
}),
);
return;
}
if (reqMessage.meta.syn === 0 && reqMessage.meta.ack === 1) {
setIsWebViewReady(true);
Alert.alert("웹뷰 핸드쉐이크 완료" + isWebViewReady);
onReadyToMessage?.();
return;
}
}
if (onMessage) onMessage(reqMessage);
},
[onMessage],
);
return (
<WebView
sourse={/*...*/}
ref={webViewRef}
injectedJavaScript={INJECTED_JAVASCRIPT}
showsHorizontalScrollIndicator={false}
onMessage={handleMessage}
/>
);
},
);
export default WebViewWithInjected;
앱에서는 웹으로부터 syn 메시지를 받으면 syn/ack 메시지를 보내고, ack 메시지를 받으면 웹뷰가 준비되었다는 것을 인지합니다. 이 과정이 전부 끝나면 웹뷰가 준비되었다는 것을 인지하게 됩니다.
웹에서는 아래처럼 구현할 수 있었습니다.
"use client";
type Flag = 0 | 1;
interface WebviewHandshake {
name: "webview-handshake";
flag: {
syn: Flag;
ack: Flag;
};
}
const TIMEOUT = 1000;
function postMessage<Data>(
message: MessageEventResponseData<Data> | WebviewHandshake
) {
window.ReactNativeWebView?.postMessage(JSON.stringify(message));
document.ReactNativeWebView?.postMessage(JSON.stringify(message));
}
export default function Bridge({ onRequest }: BridgeProps) {
// ...앱에서 보낸 일반 메시지 처리 코드...
useEffect(() => {
// 0. Bridge컴포넌트가 로드되고 js를 실행할 수 있는 시점에서, 앱으로 syn 메시지를 보낸다.
const postHandShakeMessage = (syn: Flag, ack: Flag) => {
postMessage({ name: "webview-handshake", flag: { syn, ack } });
};
postHandShakeMessage(1, 0);
// 앱으로 syn을 보낸 후, 1초 이내에 ack을 받지 못하면 다시 syn을 보낸다.
// 1초로 설정한 이유는 일반적인 리눅스 시스템에서 TCP 연결을 위한 SYN 타임아웃이 1초로 설정되어 있기 때문이다.
const timeoutId = setTimeout(() => {
postHandShakeMessage(1, 0);
}, TIMEOUT);
const handleMessage = (event: MessageEvent) => {
event.stopPropagation();
try {
// 3. 앱으로부터 받은 메시지에서 name과 flag를 추출한다.
const {
name,
flag: { syn, ack },
} =
typeof event.data === "string" ? JSON.parse(event.data) : event.data;
// 4. 핸드셰이크 과정에서 syn/ack 메시지를 기대하고 있으며, syn이 1이고 ack이 1인 메시지를 받으면 앱에서 준비가 완료되었다는 것을 인지한다.
// 5. 이후 ack을 보내고, 타임아웃 및 핸드셰이크 이벤트 리스너를 제거한다.
if (name === "webview-handshake" && syn === 1 && ack === 1) {
postHandShakeMessage(0, 1);
if (timeoutId) clearTimeout(timeoutId);
window.removeEventListener("message", handleMessage);
}
} catch (error) {
console.error("handshake 중 에러 발생:", error);
window.removeEventListener("message", handleMessage);
}
};
// 1. 웹뷰가 로딩되면 앱으로부터 메시지를 받기 위해 이벤트 리스너를 등록한다.
// 2. 앱으로부터 메시지를 받으면 handleMessage 함수를 호출한다.
window.addEventListener("message", handleMessage);
}, []);
return null;
}
웹에서는 우선적으로 앱으로 syn 메시지를 보내고, 앱에서 syn/ack 메시지를 받으면 ack 메시지를 보내는 방식으로 구현하였습니다. 이렇게 하면 웹뷰가 로딩된 이후에만 메시지를 보낼 수 있게 됩니다.
이를 시퀀스 다이어그램으로 나타내면 아래와 같습니다.

실제로 아래처럼 잘 동작합니다!

관련된 pr은 여기에서 확인하실 수 있습니다.
나머지 문제들 해결
이제 웹뷰가 로딩된 이후에만 메시지를 보낼 수 있게 되었으니, 나머지 문제들을 해결해보겠습니다. 안전한 통신을 위하여 통신 가능 시점을 TCP 3-Way-Handshake로 확인하는 과정을 거쳤습니다. 이제는 이 메시지를 주고 받을 수 있도록, 실제 HTTP 통신에서 메시지를 하위 레이어의 헤더로 감싸 보내듯이 인자로 전달받은 메시지를 헤더로 감싸서 보내도록 하였습니다.

받을 때에는 컴포넌트로, 보낼 때에는 훅으로
웹뷰와 앱 간의 메시지를 주고받을 때, 받는 쪽은 컴포넌트로, 보내는 쪽은 훅으로 구현하였습니다. 이는 리액트의 컴포넌트와 훅의 사용 방식을 따르도록 하여 개발자가 쉽게 사용할 수 있도록 하였습니다.
타입 안정성을 챙기자
흔히 개발자가 api 명세서를 작성할 때 요청과 응답값을 팀의 취향에 맞게 정의합니다. 메시지 타입 또한 사용하는 곳에서 직접 정의할 수 있도록 하는 것이 맞다고 생각하였습니다. 정의한 타입을 지킬 수 있도록 하여 타입 세이프한 통신이 가능하게 하고자 하였습니다.
웹의 경우 아래처럼 사용하도록 하였습니다.
// 웹에서 메시지를 보낼 때
// 요청/응답 메시지 타입을 넣어 정의한다.
const { request } = useBridge<RequestMessage, ResponseMessage>();
const sendMessage = () => {
request({
requestMessage: {
// 전송할 요청 메시지
// 이전에 정의한 메시지 타입을 따를 수 있도록 유도한다.
},
responseCallback: (response) => {
// 응답 메시지가 왔을 때 처리하는 콜백
// response는 이전에 정의한 응답 메시지 타입을 따른다.
},
onErrorCallback: (error) => {
// 에러가 발생했을 때 처리하는 콜백
},
});
};
- useBridge 훅을 통해서 요청을 보낼 수 있는 함수를 얻어오실 수 있습니다.
- useBridge 훅에 요청과 응답 메시지 타입을 제네릭으로 넘겨주어, 요청과 응답 메시지의 타입을 미리 정의하실 수 있습니다.
- request 함수의 경우 보낼 요청 메시지와, 요청에 대한 응답을 받았을 때 호출할 콜백, 에러가 발생했을 때 호출할 콜백을 인자로 받습니다. 각각은 이전에 정의한 메시지 타입을 따릅니다.
// 웹에서 메시지를 받을 때
<BridgeRequestListener<RequestMessage, ResponseMessage>
onRequest={(requestMessage) => {
// requestMessage는 이전에 정의한 요청 메시지 타입을 따른다.
if (/*requestMessage 관련 분기 처리*/)
return {
// 응답 메시지
}
return {
// 예상하지 못한 요청이 왔을 때의 응답 메시지
};
}}
/>
- BridgeRequestListener 컴포넌트에 요청과 응답 메시지 타입을 제네릭으로 넘겨주어, 요청과 응답 메시지의 타입을 미리 정의하실 수 있습니다.
- onRequest 콜백을 통해서 요청 메시지를 받으실 수 있습니다. 요청 메시지는 이전에 정의한 요청 메시지 타입을 따릅니다. 해당 콜백의 반환값은 응답 메시지가 되며, 필수적으로 응답 메시지를 반환해야 합니다.
앱의 경우 아래와 같이 사용하도록 하였습니다.
// 앱에서 메시지를 보낼 때
const { ref, postMessage } = usePostMessageBridge<RequestMessage, ResponseMessage>();
postMessage({
message: {
// 전송할 요청 메시지
// 이전에 정의한 요청 메시지 타입을 따른다.
},
});
return (
<WebviewWithBridge
ref={ref}
//...
/>
);
- usePostMessageBridge 훅을 통해서 메시지를 보낼 수 있는 postMessage 함수와 ref를 얻어올 수 있습니다.
- usePostMessageBridge 훅에 요청과 응답 메시지 타입을 제네릭으로 넘겨주어, 요청과 응답 메시지의 타입을 미리 정의할 수 있습니다.
- postMessage 함수의 경우 보낼 요청 메시지를 인자로 받습니다. 요청 메시지는 이전에 정의한 요청 메시지 타입을 따릅니다.
- ref는 Webview 컴포넌트에 전달해주셔야 합니다. 이를 통해서 Webview 컴포넌트에서 메시지를 보낼 수 있습니다.
// 앱에서 메시지를 받을 때
<WebviewWithBridge<MessageEventRequestData, MessageEventResponseData>
ref={webViewRef}
onBridgeMessage={(reqMessage) => {
// 요청 메시지를 받았을 때 처리하는 콜백
// reqMessage는 이전에 정의한 요청 메시지 타입을 따른다.
if (/*reqMessage 관련 분기 처리*/)
return {
// 응답 메시지
}
return {
// 예상하지 못한 요청이 왔을 때의 응답 메시지
};
}}
middleware={(reqMessage: MessageEventRequestData) => {
// 요청 메시지에 대한 미들웨어 처리. 이는 어떤 요청 메시지가 오더라도 onBridgeMessage 콜백이 호출되기 전에 실행된다.
// 예를 들어, 요청 메시지의 유효성을 검사하거나, 로깅 등을 할 수 있다.
}}
//...
/>
- WebviewWithBridge 컴포넌트에 요청과 응답 메시지 타입을 제네릭으로 넘겨주어, 요청과 응답 메시지의 타입을 미리 정의할 수 있습니다.
- onBridgeMessage 콜백을 통해서 요청 메시지를 받으실 수 있습니다. 요청 메시지는 이전에 정의한 요청 메시지 타입을 따릅니다. 해당 콜백의 반환값은 응답 메시지가 되며, 필수적으로 응답 메시지를 반환해야 합니다.
여기서 웹은 요청에 대한 응답을 콜백으로 받는 반면, 앱은 요청에 대한 응답을 반환값으로 받는다는 차이가 있습니다. 이는 Webview 컴포넌트의 ref로는 응답을 처리할 수 없는 구조이기 때문에 불가피하게 모든 응답 혹은 요청은 WebviewWithBridge 컴포넌트로 받아야만 합니다.
요청과 응답
이와 더불어서 보낸 요청에 대한 응답을 받을 수 있도록 해야 했습니다. 단방향인 이벤트 기반의 통신에서, 받는 값이 요청인지 응답인지를 구분할 수 있어야만 했고, 응답이라면 어떤 요청에 대한 응답인지를 구분해야만 했습니다. 이를 구분하기 위하여 조금 더 명확하게 메시지 타입을 정의하도록 하였습니다.
- 관련 pr : https://github.com/JNU-econovation/Soop-APP/pull/33
메시지 타입은 아래와 같습니다.
{
_id : string // 메시지별로 고유한 식별자
ack : string | null // 해당 메시지가 요청인 경우 null, 응답인 경우 요청 메시지의 _id값
flag : { syn } // 해당 메시지가 연결 요청 메시지인지 표시하는 값. 이는 핸드셰이크 과정에서 사용
body : any // 메시지 본문값. 이는 상단에서 내려준 메시지가 담김
}
모든 메시지는 고유한 식별자 id를 가집니다. 요청이라면 ack값이 null이고, 응답이라면 ack값이 요청 메시지의 _id값이 되도록 하였습니다. 이를 통해서 요청과 응답을 구분할 수 있고, 어떤 요청에 대한 응답인지를 특정지을 수 있었습니다.
메시지 큐
이제 메시지의 형식이 정해졌고, 요청과 응답을 주고 받을 수 있게 되었습니다. 이제는 메시지를 보내는 쪽에서 요청에 대한 응답을 받아 처리할 수 있도록 해야 했습니다. 응답은 요청을 보낸 즉시 오지 않는 비동기 방식으로 동작하기 때문에, 요청을 보낼 때마다 응답을 처리할 콜백을 미리 저장해두고, 응답이 왔을 때 해당 콜백을 찾아 호출해야만 했습니다. 이를 위해서 메시지 큐를 구현하였습니다. 요청을 보낼 때마다 메시지 큐에 요청 메시지의 _id와 응답 콜백을 저장해두고, 응답 메시지를 받았을 때 메시지 큐에서 해당 _id에 대한 콜백을 찾아 호출하는 방식으로 구현하였습니다.
큐의 이름은 TCP 통신에서 흐름 제어를 위해 사용하는 수신 윈도우(RWND, Receive Window)에서 따왔습니다.
class RWindow {
// TODO: 단순 id를 담는 것이 아닌 객체로 변경 고려 (메시지 body를 제외한 모든 정보 담기)
public RWND_BUFFER: Set<string>;
//TODO: WeakMap으로 변경 고려 => RWND_BUFFER에서 객체를 제거하면 GC가 콜백도 제거해줄 것임
private callbackBuffer: Map<string, ((resMessage?: any) => void)[]> =
new Map();
private static WINDOW_SIZE = 20;
constructor() {
this.RWND_BUFFER = new Set<string>();
}
// id 추가
public add(id: string) {
if (this.RWND_BUFFER.size >= RWindow.WINDOW_SIZE) {
throw new Error("RWND_BUFFER is already full");
}
if (this.RWND_BUFFER.has(id)) {
throw new Error("RWND_BUFFER already contains this id");
}
this.RWND_BUFFER.add(id);
}
// id에 해당하는 콜백들 제거 및 반환
public popCallbacksById(id: string) {
this.RWND_BUFFER.delete(id);
const callbacks = this.callbackBuffer.get(id);
this.callbackBuffer.delete(id);
return callbacks ?? [];
}
// id에 콜백 추가
public addListener<ResMessageType>(
id: string,
callback: (resMessage: ResMessageType) => void,
) {
if (this.callbackBuffer.has(id)) {
this.callbackBuffer.set(id, [...this.callbackBuffer.get(id)!, callback]);
return;
}
this.callbackBuffer.set(id, [callback]);
}
// id에 해당하는 콜백들 반환
public getListeners(
id: string,
): ((resMessage?: unknown) => void)[] | undefined {
return this.callbackBuffer.get(id);
}
}
RWindow 인스턴스를 전역으로 생성하여 id와 콜백을 관리하도록 하였습니다. 요청을 보낼 때마다 RWindow 인스턴스에 id값과 콜백을 저장하도록 하였습니다.
이후 메시지를 보낼 수 있는 동작을 정의해보았습니다.
class Message<BodyType = unknown> {
private _id: string;
private ack: string | null;
private flag: { syn: 0 | 1 };
private body?: BodyType;
constructor(
private ref: React.RefObject<WebView<{}> | null>,
private R_WND: RWindow,
{
ack = null,
syn = 0,
body,
}: {
syn: 0 | 1;
ack: string | null;
body?: BodyType;
},
) {
this._id = this.getRandomId();
this.ack = ack;
this.flag = { syn };
this.body = body;
}
private createNewMessageObj = () => {
return {
_id: this._id,
ack: this.ack,
flag: this.flag,
body: this.body,
};
};
// 메시지 전송 및 콜백 등록
public send = <ResMessageType>(callback?: (m: ResMessageType) => void) => {
Message.sendWebviewMessage(this.ref, this.createNewMessageObj());
if (!callback) return;
this.R_WND.addListener(this._id, callback);
};
// 웹의 경우 아래의 방식으로 작성하였다.
// 요청을 보내면 응답 콜백을 등록하고, window에 이벤트 리스너를 등록하여 응답 메시지를 받는다.
// 응답 메시지를 받으면(ack가 _id와 일치), 해당 콜백을 찾아 호출하고, 이벤트 리스너를 제거한다.
public send = <Body>(callback?: (m: WebviewBridgeMessage<Body>) => void) => {
Message.sendWebviewMessage(this.createNewMessageObj());
if (!callback) return;
this.R_WND.addListener(this._id, callback);
const messageHandler = (event: Event) => {
const messageEvent = event as MessageEvent<WebviewBridgeMessage<Body>>;
messageEvent.stopPropagation();
const resMessage =
typeof messageEvent.data === "string"
? (JSON.parse(messageEvent.data) as WebviewBridgeMessage<Body>)
: messageEvent.data;
if (resMessage.ack === this._id) {
const listeners = this.R_WND.popCallbacksById(this._id);
listeners.forEach((listener) => listener(resMessage));
document.removeEventListener("message", messageHandler);
window.removeEventListener("message", messageHandler);
}
};
document.addEventListener("message", messageHandler as EventListener);
window.addEventListener("message", messageHandler);
};
}
이를 한번에 관리할 수 있는 클래스도 만들어 보았습니다.
class WebViewBridge {
private R_WND: RWindow = new RWindow();
public createMessage = <BodyType>(
// WebView ref와 메시지 body를 인자로 받아 메시지 인스턴스를 생성.
// 앱에서만 유효하며, 웹에서는 ref를 받지 않는다.
ref: React.RefObject<WebView | null>,
{
ack = null,
syn = 0,
body,
}: Omit<WebviewBridgeMessage<BodyType>, "flag" | "_id"> & {
syn?: Flag;
},
) => {
return new Message<BodyType>(ref, this.R_WND, {
ack,
syn,
body,
});
};
// id에 해당하는 콜백을 찾아 호출
public renderCallback = (ack: string, body: unknown) => {
const callbacks = this.RWND.popCallbacksById(ack);
callbacks.forEach((callback) => callback(body));
};
}
// 사용법
const Bridge = new WebViewBridge();
Bridge.createMessage(webViewRef, {
syn: ...,
ack: ...,
body: { ... },
}).send(() => {/* callback */});
위의 3개의 클래스를 통해서 메시지를 주고 받을 수 있는 기본적인 구조를 완성하였습니다.
이제 이를 react 훅과 컴포넌트로 감싸서 개발 단계에서 쉽게 사용할 수 있도록 하였습니다.
리액트 훅과 컴포넌트로 감싸기
앱에서 보낼 때에는 usePostMessageBridge 훅을 사용하도록 하였습니다.
interface PostMessageProps<MessageType, ResponseType> {
message: MessageType;
onResponse?: (response: ResponseType) => void;
}
const usePostMessageBridge = <ReqType, ResponseType>() => {
const ref = useRef<WebView | null>(null);
const postMessage = ({
message,
onResponse,
}: PostMessageProps<ReqType, ResponseType>) => {
if (!ref.current) {
// WebView가 아직 로드되지 않은 경우
// ...
return;
}
try {
// 요청 메시지 생성 및 전송
const newMessage = Bridge.createMessage<ReqType>(ref, {
ack: null,
body: message,
});
newMessage.send<ResponseType>((response) => {
if (onResponse) return onResponse(response);
});
} catch (error) {
// ...
}
};
return {
ref,
postMessage,
};
};
웹에서는 아래와 같이 작성하였습니다.
"use client";
interface RequestProps<ReqBody = unknown, ResBody = unknown> {
requestMessage: ReqBody;
responseCallback?: (resMessage: ResBody) => void;
}
const useBridge = <ReqBody = unknown, ResBody = unknown>() => {
const Bridge = getBridge();
const [isReady, setIsReady] = useState(false); // 웹뷰가 준비되었는지 여부
// 웹뷰가 준비되었는지 확인하는 핸드셰이크 메시지 전송.
const sendHandshakeSynMessage = useCallback(() => {
if (isReady) return;
Bridge.createMessage({
syn: BRIDGE.SET,
ack: null,
}).send((message) => { // 앱으로부터 syn/ack 메시지를 받으면 ack 메시지를 보내고, 웹뷰가 준비되었음을 인지
const {
_id,
ack,
flag: { syn },
} = message;
if (syn !== BRIDGE.SET) // error...
if (ack === null) // error...
setIsReady(true); // 앱으로부터 syn/ack 메시지를 받았으므로, 앱이 웹뷰 통신 준비가 되었음을 인지
// ack 메시지 전송 (핸드셰이크 완료)
Bridge.createMessage({
ack: _id,
syn: BRIDGE.RESET,
}).send();
});
}, [Bridge, isReady]);
// 컴포넌트가 마운트될 때 핸드셰이크 시작
useEffect(() => {
sendHandshakeSynMessage();
}, [sendHandshakeSynMessage]);
// 요청을 보낼 수 있는 request 함수
const request = ({
requestMessage,
responseCallback,
}: RequestProps<ReqBody, ResBody>) => {
// 서버에서 실행되지 않도록 방지
if (typeof window === "undefined") return;
// 입력받은 요청 메시지를 전송하고 응답 콜백 등록
Bridge.createMessage({
ack: BRIDGE.BLANK,
body: requestMessage,
}).send<ResBody>(({ body }) => {
if (responseCallback && body) return responseCallback(body);
});
};
return { request };
};
응답을 받는 앱 컴포넌트는 아래와 같이 작성하였습니다.
interface WebViewWithBridgeProps<ReqMessage, ResMessage>
extends Omit<ComponentProps<typeof WebView>, "onMessage"> {
onBridgeMessage?:
| ((reqMessage: ReqMessage) => ResMessage | void)
| ((reqMessage: ReqMessage) => Promise<ResMessage | void>);
onReadyToMessage?: () => void;
middleware?: (message: ReqMessage) => void;
ref?: Ref<WebView>;
}
const WebviewWithBridge = <ReqMessage, ResMessage>({
onBridgeMessage,
onReadyToMessage,
middleware,
ref,
...props
}: WebViewWithBridgeProps<ReqMessage, ResMessage>) => {
const [isReady, setIsReady] = useState(false); // 웹뷰가 준비되었는지 여부
const internalRef = useRef<WebView>(null);
const webViewRef = ref ? (ref as React.RefObject<WebView>) : internalRef;
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
const reqMessage = JSON.parse(
event.nativeEvent.data,
) as WebviewBridgeMessage<ReqMessage>;
const {
_id,
ack,
flag: { syn },
body,
} = reqMessage;
// 만약 수신한 메시지가 ack 메시지라면(특정 요청 메시지에 대한 응답 메시지라면), 해당 콜백을 찾아 호출
if (ack) Bridge.renderCallback(ack, reqMessage);
// handshake 로직
// 웹으로부터 handshake sync 메시지 수신
if (!isReady) {
if (!isReady && syn === 1 && ack === null) {
// 웹에서 syn을 보냈을 때, syn/ack을 보내준다.
Bridge.createMessage(webViewRef, {
syn: 1,
ack: _id,
}).send<WebviewHandshake>(({ ack, flag: { syn } }) => {
if (!isReady && syn === 0 && ack !== null) {
setIsReady(true);
return;
}
});
return;
}
}
// 웹뷰가 준비되지 않은 상태라면 일반적인 메시지 처리를 하지 않음
if (!isReady) return;
// 일반적인 메시지 처리
if (onBridgeMessage) {
const resMessage = onBridgeMessage(body);
// onBridgeMessage가 Promise를 반환하는 경우
if (resMessage instanceof Promise) {
resMessage
.then((response) => {
if (!response) {
// 응답이 없는 경우 경고 또는 에러 처리
}
// 응답 메시지 전송
Bridge.createMessage(webViewRef, {
ack: _id,
body: response,
}).send();
})
.catch((error) => {
// ...
});
return;
} else {
// onBridgeMessage가 일반 값을 반환하는 경우
if (!resMessage) {
// 응답이 없는 경우 경고 또는 에러 처리
}
// 응답 메시지 전송
Bridge.createMessage(webViewRef, {
ack: _id,
body: resMessage,
}).send();
}
}
},
[onBridgeMessage, middleware, isReady],
);
return <WebView ref={webViewRef} onMessage={handleMessage} {...props} />;
};
웹에서는 아래와 같이 작성하실 수 있습니다.
"use client";
interface BridgeProps<RequestMessage, ResponseMessage> {
onRequest: (reqMessage: RequestMessage) => ResponseMessage;
requestValidator?: (reqMessage?: RequestMessage) => boolean;
}
export default function BridgeRequestListener<RequestType, ResponseType>({
onRequest,
requestValidator,
}: BridgeProps<RequestType, ResponseType>) {
const Bridge = getBridge();
const [isReady, setIsReady] = useState(false); // 웹뷰가 준비되었는지 여부
// 핸드셰이크 메시지 전송 로직
const sendHandshakeSynMessage = useCallback(() => {
if (isReady) return;
// 앱으로 syn 메시지 전송. 올바른 응답을 받으면 ack 메시지를 보내고, 웹뷰가 준비되었음을 인지
Bridge.createMessage({
syn: BRIDGE.SET,
ack: null,
}).send((message) => {
const {
_id,
ack,
flag: { syn },
} = message;
// 올바른 응답이라면
setIsReady(true); // 앱이 웹뷰 통신 준비가 되었음을 인지
// ack 메시지 전송 (핸드셰이크 완료)
Bridge.createMessage({
ack: _id,
syn: BRIDGE.RESET,
}).send();
});
}, [Bridge, isReady]);
// 웹뷰의 응답을 처리하는 로직. 앱으로부터 요청을 받았을 때 실행된다.
useEffect(() => {
if (!isReady) return;
const handleMessage = (event: Event) => {
const messageEvent = event as MessageEvent;
const { data } = messageEvent;
try {
const {
ack,
_id,
flag: { syn },
body,
} = typeof data === "string"
? (JSON.parse(data) as WebviewBridgeMessage<RequestType>)
: (data as WebviewBridgeMessage<RequestType>);
if (ack !== null)
throw new Error(
"클라이언트에서 보낸 요청에 ack가 포함되어 있습니다.",
);
if (syn === 1)
throw new Error(
"핸드셰이크가 끝난 시점에서 웹뷰 핸드셰이크 메시지가 도착하였습니다.",
);
if (requestValidator && !requestValidator(body)) {
throw new Error(
"요청 메시지의 유효성 검사에 실패하였습니다. 요청 메시지를 확인해주세요.",
);
}
if (body) {
const responseMessage = onRequest(body);
if (strictMode && !responseMessage)
throw new Error("응답 메시지가 정의되지 않았습니다.");
Bridge.createMessage({
ack: _id,
syn: BRIDGE.RESET,
body: responseMessage,
}).send();
}
} catch (error) {
console.error("메시지 처리 중 오류 발생:", error);
}
};
// 요청에 대한 응답을 처리하는 로직
if (Message.checkIsAndroid()) {
document.addEventListener("message", handleMessage as EventListener);
return () =>
document.removeEventListener("message", handleMessage as EventListener);
} else {
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}
}, [Bridge, isReady, onRequest, requestValidator, strictMode]);
// 웹뷰 핸드셰이크를 위한 로직
useEffect(() => {
sendHandshakeSynMessage();
}, [sendHandshakeSynMessage]);
return null;
}
실제 사용 모습
웹뷰를 사용하였을 때, 웹의 기본 화면 전환(네비게이션)을 그대로 사용하게 되면 툭툭 끊기고 부드럽지 못한 화면 전환이 발생합니다. 이로 인해 현재 애플리케이션에서 모든 네비게이션(화면 전환)의 책임을 앱에서 담당하도록 하였습니다. 그리고, 웹에서 특정 인터랙션 발생 시 특정 페이지로 화면을 전환하도록 메시지를 보내도록 하였습니다.

사진과 같이 통신을 통한 웹뷰에서의 화면 전환이 잘 되는 것을 볼 수 있습니다!!!
설명을 위해서 많은 코드가 생략되었습니다. 전체 코드는 여기에서 확인하실 수 있습니다.
마무리
이렇게 해서 웹뷰와 앱 간의 양방향 통신을 안전하게 할 수 있는 구조를 완성하였습니다. 뭔가 개발하면서 제 스스로 문제점을 인식하고, 이를 해결할 수 있는 라이브러리(?)를 만들어 간 점에서 진짜 개발자가 된 느낌이 들었습니다. 물론 아직도 부족한 점이 많고, 개선할 점이 많은 코드이지만, 기본적인 구조를 완성했다는 점에서 뿌듯합니다.
점차 부족한 코드들도 개선해 나가면서, 이 라이브러리를 발전시켜 나가고 싶습니다. 기회가 된다면 오픈 소스로 공개하여, 다른 개발자분들도 이 라이브러리를 사용할 수 있도록 하고 싶습니다..!!