RN과 WebView, 서로를 알아가는 법: 나만의 브릿지 인터페이스 만들기
2024-10-20 00:00:00
앱을 만들다 보면 "이 기능은 네이티브로, 저 기능은 웹으로" 하는 선택의 순간이 온다. 스플래시, 알림, 카메라, 블루투스처럼 브라우저에서는 절대 해결이 안 되는 영역이 있고, 그 외의 대부분은 사실 웹으로도 충분하다. 그렇다고 웹만 쓰자니 사용자 경험이 앱에 비해 떨어진다.
그래서 나온 구조가 이렇다. 앱바, 탭바, 라우팅은 네이티브에 위임하고, 각 화면 콘텐츠만 WebView 안의 웹으로 처리하는 것. 웹을 쓰면서도 앱처럼 느껴지게 만드는 방식이다.
문제는, 이렇게 되면 RN과 WebView 안의 React가 서로 말을 걸 수 있어야 한다. 로그인을 WebView에서 처리했는데 네이티브 단도 그 사실을 알아야 하고, WebView 안에서 누른 버튼이 네이티브 네비게이션을 트리거해야 한다. 이걸 위한 인터페이스를 직접 설계하고 구현했다.
뭘 해결하려 했나
세 가지가 핵심이었다.
- 통합 인증: WebView와 네이티브 양쪽에서 쓰는 인증 정보(쿠키 등)를 동기화
- 통합 네비게이션: WebView 안에서 일어나는 화면 이동 요청을 네이티브 라우터가 처리
- 데이터 공유: 네이티브와 WebView, 서로 다른 WebView 간의 상태 공유
이벤트 타입 정의
메시지 기반 통신이므로 이벤트 타입을 먼저 정의했다. 현재는 다섯 가지이고, 추후 토스트/다이얼로그 같은 상호작용 창도 추가될 수 있다.
export const MessageEvent = Object.freeze({
Login: 'login',
Logout: 'logout',
Log: 'log',
Auth: 'auth',
Navigation: 'navigation'
})
2
3
4
5
6
7
웹 단 구현
PlatformResolver: 지금 나를 부른 게 누구야?
WebView 안에서 돌아가는 React 입장에서는 자신을 열어준 게 일반 브라우저인지, 모바일 WebView인지 알 필요가 있다. navigator.userAgent를 파싱해서 이를 판단한다.
export const usePlatformResolver = () => {
const [platform, setPlatform] = useState({});
const userAgent = navigator.userAgent.toLowerCase()
useLayoutEffect(() => {
let os = 'web';
let isWebView = userAgent.indexOf('inunity_webview') > -1
if (userAgent.indexOf("android") > -1) os = 'android'
else if (userAgent.indexOf("ios") > -1) os = 'ios';
setPlatform({ os, isWebView })
}, []);
return platform;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
RN의 WebView 컴포넌트에 userAgent prop을 커스터마이징해서 INUnity_WebView 같은 식별자를 심어두면, 웹 쪽에서 이를 감지할 수 있다.
MessageManager: 메시지 수발신 담당
MessageManager 클래스가 실질적인 통신을 담당한다. window.ReactNativeWebView.postMessage로 RN에 메시지를 보내고, window.addEventListener(iOS) 또는 document.addEventListener(Android)로 메시지를 수신한다.
export class MessageManager {
constructor(webViewInstance) {
this.webViewInstance = webViewInstance;
}
sendMessage(messageEvent, value) {
this.webViewInstance.postMessage(
JSON.stringify({ event: messageEvent, value })
)
}
onMessageReceived({ data }, listeners) {
if (!data) return;
try {
const message = JSON.parse(data);
switch (message.event) {
case MessageEvent.Auth: {
document.cookie = message.value;
break;
}
case MessageEvent.Log: {
alert(message.value);
break;
}
default: {
throw new Error('올바르지 않은 이벤트!')
}
}
listeners[message.event]?.();
this.sendMessage(MessageEvent.Log, 'ack')
} catch (e) {
this.sendMessage(MessageEvent.Log, `[Event Parsing Error] ${e.message}`)
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
라이프사이클 관리는 useMessageManager 훅이 맡는다. iOS와 Android의 이벤트 타겟이 다르다는 점을 이 훅에서 처리한다.
export const useMessageManager = () => {
const [messageManager, setMessageManager] = useState(
new MessageManager(window.ReactNativeWebView)
);
const { os, isWebView } = usePlatformResolver();
useEffect(() => {
if (!isWebView) return;
const mgr = new MessageManager(window.ReactNativeWebView);
if (os === 'ios')
window.addEventListener('message', (e) => mgr.onMessageReceived(e));
else if (os === 'android')
document.addEventListener('message', (e) => mgr.onMessageReceived(e));
setMessageManager(mgr);
return () => {
const target = os === 'ios' ? window : document;
target.removeEventListener('message', mgr.onMessageReceived);
}
}, [isWebView]);
return messageManager;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
앱 단 구현
RN 쪽에서는 WebView 컴포넌트에 onMessage 핸들러를 붙이고, useMessageManager 훅으로 웹 쪽으로 메시지를 보낸다.
const webViewRef = useRef<WebView>(null);
const messageManager = useMessageManager(webViewRef);
<WebView
ref={webViewRef}
userAgent={`... INUnity_WebView`}
onMessage={(event) => {
const message = parseMessage(event.nativeEvent.data);
handleMessage(message, {
[MessageEvent.Login]: () => {
router.push('/list')
},
[MessageEvent.Navigation]: () => {
const navigation = message.value as NavigationEvent;
router.push({
pathname: navigation.path as any,
params: navigation.params as any
})
},
});
}}
/>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Navigation 이벤트는 웹 내부에서 발생한 화면 이동 요청을 네이티브 라우터로 처리하는 핵심 부분이다. WebView가 각 화면의 콘텐츠만 담당하게 하려면, 스택 네비게이션 자체는 네이티브에서 관리해야 한다.
사용법 요약
WebView → RN
// 웹에서
const messageManager = useMessageManager();
messageManager.sendMessage(MessageEvent.Login, { userId: 'kimwash' });
2
3
// 네이티브에서 수신
onMessage={(event) => {
handleMessage(parseMessage(event.nativeEvent.data), {
[MessageEvent.Login]: () => router.push('/home'),
});
}}
2
3
4
5
6
RN → WebView
// 네이티브에서 RN에 저장된 쿠키를 웹에 동기화
messageManager.sendMessage({ event: MessageEvent.Auth, value: cookie });
2
// 웹에서 수신 후 ack 전송
const messageManager = useMessageManager({
[MessageEvent.Auth]: () => {
messageManager.sendMessage(MessageEvent.Log, 'Auth Completed!')
}
});
2
3
4
5
6
마치며
Stack Navigation을 네이티브에 위임하고 WebView를 화면 단위로 쪼개는 구조는 웹뷰 앱에서 자주 쓰이는 패턴이다. 다만 이걸 제대로 구현하려면 Next.js 같은 SSR 프레임워크로 초기 로딩을 최적화하고, BroadcastChannel이나 TanStack Query로 WebView 간 데이터 동기화를 처리하는 것도 함께 고민해야 한다.
이 브릿지 인터페이스는 그 출발점이다. 지금은 다섯 개의 이벤트만 있지만, 토스트, 다이얼로그, 파일 피커 등 네이티브 UI가 필요한 어떤 상호작용이든 이 구조 위에 쌓을 수 있다.