blog-thumbnail

웹과 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}
    />
  )
}

이렇게 하면 요구사항을 만족하는 코드를 작성할 수 있습니다.

위 코드의 문제점은 무엇일까요?

문제점 #

위 코드의 문제점은 다음과 같습니다.

  1. onClickGetVersion이 실행 될 때 version state가 바뀐다는 것을 한 눈에 알 수 없습니다.
    • 이를 알기 위해선 React Native의 코드까지 읽어야 합니다. (배경 지식이 필요하게 됨)
  2. 주고 받는 type이 많아지면 그만큼 onMessage의 분기가 많아집니다.
    • 이는 코드의 가독성을 떨어뜨립니다.
  3. 버전을 가지고 오지 못한 경우의 처리가 되어있지 않습니다.
    • 이는 앱에서 비동기 처리 후 메시지를 보내는 경우에 더욱 문제가 됩니다.

코드 개선 #

찾은 문제점을 해결하기 위해 코드를 개선해보겠습니다.

1번과 3번 문제를 해결하기 위해 메시지 요청 메서드는 Promise를 반환하면 좋을 것 같습니다. asnyc / await 문법을 사용할 수 있게 되기 떄문입니다. try / catch 문을 사용하여 에러 처리도 가능합니다.

2번 문제는 typeaction으로 변경하고, 한 action에 대한 처리를 훅으로 분리하여 해결하겠습니다.


구현 전 메시지를 주고 받을 때 일관적으로 사용할 메시지 타입을 정의하겠습니다.

export interface Message<T = undefined> {
  /** 메세지의 고유한 아이디 */
  id: string

  /**
   * 해당 메시지의 타입
   * request: 해당 메시지가 요청임을 의미
   * response: 해당 메시지가 요청에 대한 응답임을 의미
   * */
  type: 'request' | 'response'

  /** 해당 메시지의 동작을 정의한다 */
  action: string

  /** 해당 메시지에 담긴 정보 */
  data?: T
}

앞으로 메시지를 주고 받을 때는 위의 인터페이스를 이용하여 주고 받을 것입니다.

작동 #

작동 방식은 아래로 요약할 수 있습니다.

  1. A에서 postMessage를 사용해 typerequest인 메시지를 B로 보낸다. 그리고 해당 메시지의 response 메시지를 기다린다.
  2. B의 onMessage 이벤트 리스너에서 typerequest인 메시지를 받으면 해당 메시지의 action을 실행한다.
  3. action이 실행되고 typeresponse인 메시지를 A로 보낸다.
  4. A의 onMessage 이벤트 리스너에서 typeresponse인 메시지를 받으면 해당 메시지의 코드를 진행한다.

구현 #

위의 과정을 코드로 작성해보겠습니다. React와 React Native는 postMessageonMessage의 사용법을 제외하고는 동일하기 떄문에 하나의 코드로 작성하겠습니다.


먼저 요청 메시지를 보낼 수 있는 메서드 requestMessagerequestMessageresolve를 담당하는 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])
}

사용 #

이제 requestMessageuseResponseMessage를 이용하여 코드를 작성해보겠습니다.

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 훅을 이용하여 간단하게 처리할 수 있습니다.


이번 글이 도움이 되었길 바랍니다. 감사합니다.


최근 게시물

김진근 • © 2024