이전 게시글들에서 React Webview와 ReactNative의 통신을 시켜보았고, 추가적인 데이터도 주입시켜보았다.
이때의 통신은
ReactNative->React 통신의 경우 onLoad시 React Webview에서 필요한 데이터를 한번에 주입시켜주는 형태 하나였고
React->ReactNative 통신이 대다수여서 불편함이 있지 않았었다.
하지만 프로젝트가 커지고, 서버 API호출을 줄이고자 내활동을 저장하는 부분에 대해 네이티브 기기의 DB를 사용하는 부분을 적용시켰고, 이에 따라 Webview가 열린 후에 데이터를 요청해야하는 부분이 점점 커져갔다.
기존의 방식
처음에는 native DB에 있는 하나의 데이터만이 필요하여 ReactNative의 WebView컴포넌트가 열린 후, onLoad함수로 모든 데이터를 한번에 집어넣는 형태였다.
이전 게시글을 참고하면 좋다.
문제상황
예를 들어, 내가 신고한 게시글의 경우에 문제가 있었다.
신고한 게시글을 또 신고하게 해서는 안됐다. 해당 부분을 네이티브 DB에 저장해 뒀다가 신고 유무를 확인해주어야했다.
이러한 기능을 구현하려면 아래와 같은 흐름이 구성되어야한다.
- 신고를 누름
- 네이티브 DB에서 해당 글 신고했는지 확인
- 신고했다면 신고하지 못하게 처리 / 신고하지 않았다면 신고
- 신고했다면, 네이티브 DB에 해당 데이터 추가
그런데 문제는 내가 이전에 설계했던 CustomWebView에서는 신고 DB데이터를 딱한번, 웹뷰를 열때 받아오게 만들었다는 것이다.
이러면, 새롭게 신고를 한 경우를 확인할 수가 없다.(연속적으로 동일한 게시글에 신고를 하는 경우)
이런 부분이 매우 많아지기 시작했다. (내가 작성한 글/내가 좋아요누른 글/댓글을 작성한 글, 알림 피드 등등...)
해결방안 두가지
이에 두가지 방법을 생각했다.
- onLoad로 받아온 DB데이터를 전역상태로 관리한다.
- WebView-Native통신을 WebView컴포넌트가 열린 후에도 가능하게 만든다.
처음에는 전역상태로 관리해볼까? 했으나, 상태가 점점 많아지고, 관리하기 힘들어지는 부분이 있어서 후자를 택했다.
기존 통신의 문제점
기존 통신의 문제점은 바로 WebView와 Native가 Event Driven이라는 점이었다.
결국은 EventListener를 통해 동작하기에 Request-Response형태로 확장시켜 주어야했다.
설계하기
요청하기
WebView에서 Request하기 위한 방법을 만들었다.
하나의 요청에 하나의 eventListener를 등록하는 형태로 만들었다.
그러나, 요청에 대한 응답이 온 후에도 이게 남아있으면 callback함수가 여러번 수행되게 되므로, 응답을 받게되면 eventListener를 제거해주었다.
export default function webViewRequest<T>(callback: () => void) {
const promise = new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timeout')), 3000); //3초가 지나면 reject된다.
const handler = (event: MessageEvent) => {
clearTimeout(timer);
const data = JSON.parse(event.data);
resolve(data);
document.removeEventListener('message', handler as EventListener);
window.removeEventListener('message', handler);
};
document.addEventListener('message', handler as EventListener);
window.addEventListener('message', handler);
callback();
});
return promise;
}
응답하기
RN에서는 onMessage 함수를 통해 React로 부터 받은 메시지를 확인할 수 있다.
하지만 이 모든 로직을 여기서 해결하기엔 너무나도 많은 통신이 생길 것 같아 로직을 분리해주어야했다.
현재 RN이 백엔드 처럼 작동하기에 express코드와 유사하게 코드를 작성해보았다.
이러면 onMessage에서는 router로 넘겨주기만 하면된다.
const onMessage = async (event: WebViewMessageEvent) => {
const {nativeEvent} = event;
const customWebViewEvent: CustomWebViewEventType = JSON.parse(
nativeEvent.data,
); //타입에 대해서는 아래에서 더 설명하겠다.
if (webViewRef.current) {
router(
customWebViewEvent,
webViewRef.current,
navigation,
deviceToken,
dispatch,
);
}
};
동시에 다수의 요청을 하는 경우
다수의 요청이 있는경우가 문제였다.
위와 같이 연속적으로 3개의 다른 요청을 하게되면, native의 postMessage가 한번만 수행되도 모든 이벤트 리스너가 수행되고, 해당 이벤트 리스너가 제거된다.
이러한 문제를 막기위해서 통신에 type이라는 구분자를 만들어주었다.
React에서 A이벤트를 보낼때 type을 담아서 보내고, RN에서 응답시에 동일한 type을 보내주는 것이다.
그럼 이제 type을 설계하면된다.
Request-Response type설계하기
RN이 응답을 해주기때문에 RN에서 먼저 타입을 설계해주었다.
요청시에 data body가 있는지에 따라 WebViewCommonEventType, WebViewDBEventType으로 나누어주었다.
export interface WebViewCommonEventType {
type: WebViewEventTypeCategory | WebViewDBGetEvent;
}
export interface WebViewDBEventType {
type:
| WebViewDBSaveEvent
| WebViewDBDeleteEvent
| WebViewDBModifyEvent
data: {[key: string]: unknown};
}
세부 타입을 아래와 같다.
type WebViewEventTypeCategory =
| 'BACK'
| 'LOGOUT';
type WebViewDBGetEvent ='GET_SOME_DATA';
export type WebViewDBSaveEvent ='SAVE_DATA'
type WebViewDBDeleteEvent = 'DELETE_DATA'
type WebViewDBModifyEvent = 'MODIFY_DATA';
RN에서 타입기반으로 작동하게 만들기
router->controller->service를 통해 메시지가 전달되고, 반대로 응답이 오는 구조다.
postMessage를 단순화하기
문제는 postMessage가 WebView.current에 붙어있는 함수라는 것이다.
이걸 분리해줄 필요가 있었다.
일단 WebView를 받는 postMessage함수를 만들고, 이후에 이 함수에 WebView객체를 바인딩시켜줄 것이다.
import WebView from 'react-native-webview';
import {CustomWebViewEventType} from './type';
export default function postMessage(
webView: WebView<{}>,
type: CustomWebViewEventType['type'],
data?: unknown,
) {
webView.postMessage(
JSON.stringify({
type,
data,
}),
);
}
이 함수를 bind를 통해 router에서 인자로 받는 WebView컴포넌트를 고정시켜주었다.
export default function router(
...
WebView: WebView<{}>,
...
) {
const send = postMessage.bind(null, WebView);
}
이걸 Controller로 전달해주자.
Controller 설계
Controller에서 DB만 조회하는 것이아니라 native의 특정 함수를 수행시키는 경우도 있어 commonController와 DBPostController로 만들어주었다.
commonController는 다양한 인자를 받아야 하기에 switch를 사용하는 함수형태로, DBPostController는 객체로 만들어주었다.
export default function router(
customWebViewEvent: CustomWebViewEventType,
WebView: WebView<{}>,
navigation: NativeStackNavigationProp<StackNavigationType>,
deviceToken: string,
dispatch: any,
) {
const send = postMessage.bind(null, WebView);
const IS_DB_POST_EVENT = 'data' in customWebViewEvent;
if (IS_DB_POST_EVENT) {
const {type, data} = customWebViewEvent;
return DBPostController[type](data, send);
}
return commonController(
customWebViewEvent,
send,
navigation,
deviceToken,
dispatch,
);
}
DBPostController의 타입설계
아래와 같이 위에서 설계한 CustomWebVIewEventType을 key로 갖고, value로 data와 send를 인자로 받는 함수를 갖는 객체형태로 Controller를 만들어주었다.
send는 위에서 바인딩한 send다.
export type Controller<T extends string> = Record<
T,
(
data: unknown,
send: (type: CustomWebViewEventType['type'], data?: unknown) => void, //postMessage를 바인딩한 send
) => Promise<void>
>;
이제 아래와 같이 작성해 줄 수 있다. (타입을 지정했기에 자동완성이 되어 편하다.)
const DBPostController: Controller<WebViewDBEventType['type']> = {
SAVE_DATA: async (data, send) => {
await InsertService.data1(data);
send('SAVE_DATA');
},
SAVE_DATA2: async (data, send) => {
await InsertService.data2(data);
send('SAVE_DATA2');
},
}
React에서 요청시 type적용하기
RN과 타입을 맞춰주기 위해 객체를 하나 만들어주었다.
export default {
TOKEN: 'TOKEN',
BACK: 'BACK',
LOGOUT: 'LOGOUT',
IDENTITY_VERIFY: 'IDENTITY_VERIFY',
SAVE_DATA: 'SAVE_DATA',
DELETE_DATA: 'DELETE_DATA',
MODIFY_DATA: 'MODIFY_DATA',
NOTIFICATION: 'NOTIFICATION',
};
기존의 WebViewRequest에서 type이 같을때만 eventListener를 제거하도록 처리해주자.
import { WEBVIEW_EVENTS } from '@repo/constants';
interface WebViewResponse<T> {
type: keyof typeof WEBVIEW_EVENTS;
data: T;
}
export default function webViewRequest<T>(callback: () => void, type: keyof typeof WEBVIEW_EVENTS) {
const promise = new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timeout')), 3000);
const handler = (event: MessageEvent) => {
clearTimeout(timer);
const { data, type: responseType }: WebViewResponse<T> = JSON.parse(event.data);
if (responseType === type) { //responseType과 기존 요청의 type이 같아야만 제대로 동작
resolve(data);
document.removeEventListener('message', handler as EventListener);
window.removeEventListener('message', handler);
}
};
document.addEventListener('message', handler as EventListener);
window.addEventListener('message', handler);
callback();
});
return promise;
}
이렇게 하면 이제 webViewRequest가 아래와 같이 동작하게 된다.
이제 RN에 특정 데이터를 받고싶다면 이렇게 보내면된다.
const data = await webViewRequest(() => window.ReactNativeWebView.postMessage(JSON.stringify({ type:"GET_DATA" })), "GET_DATA");
그리고, RN에 특정 데이터를 추가하고싶다면 이렇게 사용하면 된다.
webViewRequest(() => window.ReactNativeWebView.postMessage(JSON.stringify({ type:"INSERT_DATA",data:"some data..." })), "INSERT_DATA");
그치만 이렇게 사용하기에는 너무나도 길고 복잡하다.
또한, RN에서 응답데이터를 주는 경우 어떤 데이터인지 타입으로 확인하기도 어렵다.
따라서 이 함수를 래핑해주자. 동시에 제네릭을 통해 반환타입을 지정할 수 있게 만들었다. (axios처럼)
const fireEvent = <T>(type: string) =>
webViewRequest<T>(() => window.ReactNativeWebView.postMessage(JSON.stringify({ type })), type as keyof typeof WEBVIEW_EVENTS);
const fireEventWithData = <T>(type: string, data: unknown) =>
webViewRequest<T>(() => window.ReactNativeWebView.postMessage(JSON.stringify({ type, data })), type as keyof typeof WEBVIEW_EVENTS);
음 하지만, 여전히 복잡하다.
객체를 통해 특정 WEBVIEW_EVENTS에 대한 함수를 미리 정의해두자.
const nativeEvent = {
TOKEN: () => fireEvent<{ accessToken: string; refreshToken: string }>(WEBVIEW_EVENTS.TOKEN),
GET_DATA: <T>() => fireEvent<T>(WEBVIEW_EVENTS.GET_DATA),
BACK: () => fireEvent(WEBVIEW_EVENTS.BACK),
LOGOUT: () => fireEvent(WEBVIEW_EVENTS.LOGOUT),
SAVE_DATA: (data: unknown) => fireEventWithData(WEBVIEW_EVENTS.SAVE_DATA, data),
DELETE_DATA: (data: unknown) => fireEventWithData(WEBVIEW_EVENTS.DELETE_DATA, data),
MODIFY_DATA: (data: unknown) => fireEventWithData(WEBVIEW_EVENTS.MODIFY_DATA, data),
};
이렇게 하면 native.특정이벤트()형태로 바로 데이터를 얻어올 수 있다!
이제 아래와 같이 깔끔하게 사용할 수 있다
export default function useSomeQuery() {
const getSomeData = async () => {
const result = await nativeEvent.GET_DATA<UserActionResponse[]>();
const likes = await nativeEvent.GET_LIKE_DATA<UserActionResponse[]>();
const feed: Feed[] = result.map((article) => ({ ...article, liked: likes.some((like) => like.id === article.id) }));
return feed;
};
return useQuery({ queryKey: [REACT_QUERY_KEYS.SOME_DATA], queryFn: getSomeData, staleTime: 0 });
}
다하고나니 새삼 http요청/axios/express가 얼마나 잘 만든 라이브러리인지 알 수 있었다.
타입을 맞추고, 확장 가능하게 하는게 정말 보통일이 아니다...