웹과 React Native 사이 더 나은 방식으로 메시지 주고 받기
React
React Native
Async
Message
WebView
2023년 5월 5일
웹과 앱 사이에서 postMessage()
와 onMessage
이벤트를 이용하여 메시지를 주고 받을 수 있습니다. 하지만 이 방식은 다양한 문제점이 있습니다. 이번 글에서는 기존 메시지를 주고 받던 코드의 문제점을 발견하고, 이를 해결하는 과정을 공유드리겠습니다.
기존 방식 #
웹은 React 기준, 앱은 React native
react-native-webview
기준으로 합니다.
예시로 웹에서 앱으로 버전 정보를 가져와서 화면에 띄우고 버전을 가져오는 중에는 로딩 화면을 보여주는 기능을 구현하여 봅시다.
import { useEffect, onMessage } from 'react'
const App = () => {
const [loading, setLoading] = useState<boolean>(false)
const [version, setVersion] = useState<string | null>(null)
const onClickGetVersion = () => {
// 버전 가져오기
window.ReactNativeWebView.postMessage(
JSON.stringify({ type: 'getVersion' }),
)
// 로딩중이라고 표시
setLoading(true)
}
const onMessage = useCallback((event) => {
const data = JSON.parse(event.data)
// 버전을 저장하고 로딩 끄기
if (data.type === 'version') {
setVersion(data.version)
setLoading(false)
}
}, [])
// 메시지 이벤트 리스너 등록
useEffect(() => {
window.addEventListener('message', onMessage)
return () => {
window.removeEventListener('message', onMessage)
}
}, [onMessage])
return (
<div>
<button onClick={onClickGetVersion}>버전 가져오기</button>
<div>현재 버전: {version}</div>
{loading && <div>로딩중...</div>}
</div>
)
}
import { useEffect, useRef } from 'react'
import { WebView } from 'react-native-webview'
const App = () => {
const webviewRef = useRef<WebView>(null)
const onMessage = (event) => {
const data = JSON.parse(event.nativeEvent.data)
// 버전 가져오기
if (data.type === 'getVersion') {
webviewRef.current?.postMessage(
JSON.stringify({
type: 'version',
version: '1.0.0',
}),
)
}
}
return (
<WebView
ref={webviewRef}
source={{ uri: 'http://localhost:3000' }}
onMessage={onMessage}
/>
)
}
이렇게 하면 요구사항을 만족하는 코드를 작성할 수 있습니다.
위 코드의 문제점은 무엇일까요?
문제점 #
위 코드의 문제점은 다음과 같습니다.
onClickGetVersion
이 실행 될 때version
state가 바뀐다는 것을 한 눈에 알 수 없습니다.- 이를 알기 위해선 React Native의 코드까지 읽어야 합니다. (배경 지식이 필요하게 됨)
- 주고 받는
type
이 많아지면 그만큼onMessage
의 분기가 많아집니다.- 이는 코드의 가독성을 떨어뜨립니다.
- 버전을 가지고 오지 못한 경우의 처리가 되어있지 않습니다.
- 이는 앱에서 비동기 처리 후 메시지를 보내는 경우에 더욱 문제가 됩니다.
코드 개선 #
찾은 문제점을 해결하기 위해 코드를 개선해보겠습니다.
1번과 3번 문제를 해결하기 위해 메시지 요청 메서드는 Promise를 반환하면 좋을 것 같습니다. asnyc / await 문법을 사용할 수 있게 되기 떄문입니다. try / catch 문을 사용하여 에러 처리도 가능합니다.
2번 문제는 type
을 action
으로 변경하고, 한 action
에 대한 처리를 훅으로 분리하여 해결하겠습니다.
구현 전 메시지를 주고 받을 때 일관적으로 사용할 메시지 타입을 정의하겠습니다.
export interface Message<T = undefined> {
/** 메세지의 고유한 아이디 */
id: string
/**
* 해당 메시지의 타입
* request: 해당 메시지가 요청임을 의미
* response: 해당 메시지가 요청에 대한 응답임을 의미
* */
type: 'request' | 'response'
/** 해당 메시지의 동작을 정의한다 */
action: string
/** 해당 메시지에 담긴 정보 */
data?: T
}
앞으로 메시지를 주고 받을 때는 위의 인터페이스를 이용하여 주고 받을 것입니다.
작동 #
작동 방식은 아래로 요약할 수 있습니다.
- A에서
postMessage
를 사용해type
이request
인 메시지를 B로 보낸다. 그리고 해당 메시지의response
메시지를 기다린다. - B의
onMessage
이벤트 리스너에서type
이request
인 메시지를 받으면 해당 메시지의action
을 실행한다. action
이 실행되고type
이response
인 메시지를 A로 보낸다.- A의
onMessage
이벤트 리스너에서type
이response
인 메시지를 받으면 해당 메시지의 코드를 진행한다.
구현 #
위의 과정을 코드로 작성해보겠습니다. React와 React Native는 postMessage
와 onMessage
의 사용법을 제외하고는 동일하기 떄문에 하나의 코드로 작성하겠습니다.
먼저 요청 메시지를 보낼 수 있는 메서드 requestMessage
와 requestMessage
의 resolve
를 담당하는 useResolveMessageEvent
훅을 작성하겠습니다.
import { createNanoEvents } from 'nanoevents'
const emitter = createNanoEvents()
export const messageEmitter = createNanoEvents()
export const requestMessage = async <
RequestDataType = undefined,
ResponseDataType = undefined,
>(
// 요청할 메시지 정보, id와 type은 자동으로 생성되므로 생략한다
message: Omit<Message<RequestDataType>, 'id' | 'type'>,
options?: {
timeout?: number
},
): Promise<ResponseDataType> => {
// 타임아웃 설정 (기본 30초)
const timeout = options?.timeout ?? 30000
const { action, data } = message
const id = uuidv4()
const type = 'request'
// 요청 메시지를 보낸다
postMessage(
JSON.stringify({
id,
type,
action,
data,
}),
)
// 응답 메시지를 기다린다
const response = await new Promise<Message<ResponseDataType>>(() => {
// 해당 메시지 아이디로 이벤트를 받으면 해당 메시지를 반환한다
const timer = setTimeout(() => {
reject(new Error('timeout'))
}, timeout)
// 해당 메시지 아이디로 이벤트를 받으면 해당 메시지를 반환한다
emitter.once(id, (message: Message<ResponseDataType>) => {
resolve(message)
// 해당 메시지를 받으면 타이머를 제거한다
clearTimeout(timer)
})
})
return response.data
}
export const useResolveMessageEvent = () => {
// `response` 메시지를 걸러서 이벤트를 발생시키는 리스너
const resolveMessageEvent = useCallback((event) => {
// 메시지가 JSON 형식이 아니면 무시한다
try {
JSON.parse(event.data)
} catch (error) {
return
}
const message: Message = JSON.parse(event.data)
// 메시지가 response 타입이 아니면 무시한다
if (message.type !== 'response') return
// 해당 메시지의 이벤트를 발생시킨다
emitter.emit(message.id, message)
}, [])
// 리스너 등록
useEffect(() => {
const unbind = messageEmitter.on('message', resolveMessageEvent)
return () => {
unbind()
}
}, [resolveMessageEvent])
return requestMessage
}
그리고 요청 메시지에 응답할 수 있도록 useResponseMessage
훅을 만들겠습니다.
//...
export const useResponseMessage = <
RequestDataType = undefined,
ResponseDataType = undefined,
>(
action: string,
callback: (
data?: RequestDataType,
) => ResponseDataType | Promise<ResponseDataType>,
) => {
const onMessage = useCallback(
async (event) => {
// 메시지가 JSON 형식이 아니면 무시한다
try {
JSON.parse(event.data)
} catch (error) {
return
}
const message: Message = JSON.parse(event.data)
// 메시지가 request 타입이 아니면 무시한다
if (message.type !== 'request') return
// 메시지의 action이 param의 action과 다르면 무시한다
if (message.action !== action) return
const data = await callback(message.data)
// 처리가 끝났으니 응답 메시지를 보낸다
postMessage(
JSON.stringify({
id: message.id,
type: 'response',
action: message.action,
data,
}),
)
},
[action, callback],
)
useEffect(() => {
const unbind = messageEmitter.on('message', onMessage)
return () => {
unbind()
}
}, [onMessage])
}
사용 #
이제 requestMessage
와 useResponseMessage
를 이용하여 코드를 작성해보겠습니다.
import { useEffect, onMessage } from 'react'
import {
useResolveMessageEvent,
useResponseMessage,
messageEmitter,
requestMessage,
} from './hooks/message'
const App = () => {
const [loading, setLoading] = useState<boolean>(false)
const [version, setVersion] = useState<string | null>(null)
const onClickGetVersion = () => {
try {
setLoading(true)
const data = await requestMessage<undefined, { version: string }>({
action: 'getVersion',
})
setVersion(data.version)
} catch (error) {
setVersion('가져오는데 오류 발생')
} finally {
setLoading(false)
}
}
useResolveMessageEvent()
const onMessage = useCallback(
(event) => {
messageEmitter.emit('message', event)
},
[messageEmitter],
)
useEffect(() => {
window.addEventListener('message', onMessage)
return () => {
window.removeEventListener('message', onMessage)
}
}, [onMessage])
return (
<div>
<button onClick={onClickGetVersion}>버전 가져오기</button>
<div>현재 버전: {version}</div>
{loading && <div>로딩중...</div>}
</div>
)
}
import { useEffect, useRef, useMemo } from 'react'
import { WebView } from 'react-native-webview'
import {
useResponseMessage,
messageEmitter,
requestMessage,
useResolveMessageEvent,
} from './hooks/message'
const App = () => {
const webviewRef = useRef<WebView>(null)
useResponseMessage<undefined, { version: string }>('getVersion', () => {
return { version: '1.0.0' }
})
useResolveMessageEvent()
const onMessage = useCallback(
(event) => {
messageEmitter.emit('message', event.nativeEvent)
},
[messageEmitter],
)
return (
<WebView
ref={webviewRef}
source={{ uri: 'http://localhost:3000' }}
onMessage={onMessage}
/>
)
}
마무리 #
이렇게해서 메시지를 주고 받을 때도 Promise
를 사용하여 코드를 작성할 수 있게 되었습니다. 이를 통해
코드의 가독성과 유지보수성이 높아졌습니다. 또한 웹과 앱 코드의 차이가 거의 없어서 모듈화 하기도 좋습니다.
응답이 많아지더라도 useResponseMessage
훅을 이용하여 간단하게 처리할 수 있습니다.
이번 글이 도움이 되었길 바랍니다. 감사합니다.