본문 바로가기

FrontEnd

리액트 쿼리 : 웹소켓

반응형

원문 보기 : https://tkdodo.eu/blog/using-web-sockets-with-react-query

웹소켓이란?

WebSocket을 사용하면 푸시 메시지 또는 "라이브 데이터"를 서버에서 클라이언트(브라우저)로 보낼 수 있습니다.
 
일반적으로 HTTP를 사용하면 클라이언트가 서버에 요청하여 일부 데이터를 요청하고
서버가 해당 데이터 또는 오류로 응답한 다음 연결이 닫힙니다.

클라이언트가 연결을 열고 요청을 시작하는 역할만 담당하기에,

업데이트를 사용할 수 있음을 알게 되면 서버가 클라이언트에 데이터를 푸시할 여지가 없습니다.

 

이를 WebSockets 이 해결합니다.

 

다른 HTTP 요청과 마찬가지로 브라우저는 연결을 시작하지만 WebSocket으로 연결을 업그레이드하고 싶다고 지시합니다.
서버가 이를 수락하면 프로토콜을 전환합니다.
이 연결은 한 쪽이 닫기로 결정할 때까지 열린 상태로 유지됩니다.
이제 양측이 데이터를 전송할 수 있는 양방향 연결이 열립니다.
 
이것은 서버가 이제 클라이언트에 선택적 업데이트를 푸시할 수 있다는 주요 이점이 있습니다.
여러 사용자가 동일한 데이터를 보고 있고 한 사용자가 업데이트하는 경우 매우 유용할 수 있습니다.
일반적으로 다른 클라이언트는 적극적으로 다시 가져올 때까지 해당 업데이트를 볼 수 없습니다.
WebSocket을 사용하면 이러한 업데이트를 실시간으로 즉시 푸시할 수 있습니다.

리액트 쿼리와 통합

React Query는 주로 클라이언트 측 비동기 상태 관리 라이브러리이므로
서버에서 WebSocket을 설정하는 방법에 대해서는 이야기하지 않겠습니다.
솔직히 한 번도 해본 적이 없으며 백엔드에서 사용하는 기술에 따라 다릅니다.
express로 구현해보기 : https://www.npmjs.com/package/express-ws

React Query에는 WebSocket을 위해 특별히 내장된 것이 없습니다.

그렇다고 WebSocket이 지원되지 않거나 라이브러리에서 제대로 작동하지 않는다는 의미는 아닙니다.
React Query는 데이터를 가져오는 방법은 신경쓰지 않습니다.
필요한 것은 rejected 혹은 resolved된 Promise뿐입니다.
나머지는 사용자에게 달려 있습니다.
 

스텝 바이 스텝

일반적인 아이디어는 WebSocket으로 작업하지 않는 것처럼 평소와 같이 쿼리를 설정하는 것입니다.

대부분의 경우 엔터티를 쿼리하고 변경하는 일반적인 HTTP 엔드포인트가 있습니다.

const usePosts = () => useQuery(['posts', 'list'], fetchPosts)

const usePost = (id) =>
  useQuery(['posts', 'detail', id], () => fetchPost(id))
또한 WebSocket 엔드포인트에 연결하는 앱 전체의 useEffect를 설정할 수 있습니다.
작동 방식은 사용 중인 기술에 따라 다릅니다.
여기서는 단순히 브라우저의 기본 WebSocket API를 사용합니다.
 
const useReactQuerySubscription = () => {
  React.useEffect(() => {
    const websocket = new WebSocket('wss://echo.websocket.org/')
    websocket.onopen = () => {
      console.log('connected')
    }

    return () => {
      websocket.close()
    }
  }, [])
}

데이터 소비하기

연결을 설정한 후에는 WebSocket을 통해 데이터가 들어올 때 호출되는 일종의 콜백이 있을 것입니다.
다시 말하지만, 해당 데이터가 무엇인지는 전적으로 설정 방법에 따라 다릅니다.
Tanner Linsley의 이 message에서 영감을 받아 백엔드에서 완전한 데이터 개체 대신 이벤트를 보내게 하겠습니다.
const useReactQuerySubscription = () => {
  const queryClient = useQueryClient()
  React.useEffect(() => {
    const websocket = new WebSocket('wss://echo.websocket.org/')
    websocket.onopen = () => {
      console.log('connected')
    }
    websocket.onmessage = (event) => {
      const data = JSON.parse(event.data)
      // [데이터 엔티티 정보, id]에서 undefined 걸러냄
      const queryKey = [...data.entity, data.id].filter(Boolean)
      queryClient.invalidateQueries(queryKey)
    }

    return () => {
      websocket.close()
    }
  }, [queryClient])
}
(즉, 클라이언트에서 필요한 데이터를 다시 요청함)
이벤트를 수신할 때 리스트 및 디테일뷰를 업데이트하는 데 필요한 것은 이것이 전부입니다.
  • { "entity": ["posts", "list"] }
    • 게시물 목록을 무효화합니다
  • { "entity": ["posts", "detail"], id: 5 }
    • 단일 게시물을 무효화합니다
  • { "entity": ["posts"] } 
    • 포스트와 관련된 모든 쿼리를 무효화합니다
쿼리 무효화(Query Invalidation)는 WebSocket과 함께 사용하면 정말 좋습니다.
이 접근 방식은 현재 관심이 없는 엔터티에 대한 이벤트를 수신하면 아무 일도 일어나지 않을 것이기 때문에
과도하게 푸시하는 문제를 방지합니다.
 
예를 들어, 현재 프로필 페이지에 있고 게시물에 대한 업데이트를 수신하면
invalidateQueries는 다음에 게시물 페이지에 도달할 때 다시 가져오도록 합니다.
활성 관찰자가 없으면 즉시 다시 가져오지 않습니다.
다시 해당 페이지로 이동하지 않는다면 푸시 업데이트가 완전히 불필요할 것입니다.

부분적인 데이터 업데이트

부분 업데이트의 경우 queryClient.setQueryData를 사용하여 쿼리 캐시를 무효화하는 대신 직접 업데이트할 수 있습니다.
이때, 서버에서 payload로 해당 데이터를 넘겨줍니다.
 
동일한 데이터에 대해 여러 쿼리 키가 있는 경우 이 작업은 조금 더 복잡합니다.
쿼리 키의 일부로 여러 필터 기준이 있는 경우
또는 동일한 메시지로 목록 및 세부 정보 보기를 업데이트하려는 경우,
queryClient.setQueryData는 이 사용 사례도 처리할 수 있도록 하는 라이브러리에 비교적 새로 추가된 것입니다.
const useReactQuerySubscription = () => {
  const queryClient = useQueryClient()
  React.useEffect(() => {
    const websocket = new WebSocket('wss://echo.websocket.org/')
    websocket.onopen = () => {
      console.log('connected')
    }
    websocket.onmessage = (event) => {
      const data = JSON.parse(event.data)
      queryClient.setQueriesData(data.entity, (oldData) => {
        const update = (entity) =>
          entity.id === data.id ? { ...entity, ...data.payload } : entity
        return Array.isArray(oldData) ? oldData.map(update) : update(oldData)
      })
    }

    return () => {
      websocket.close()
    }
  }, [queryClient])
}

제 생각에는 너무 동적이고, add 또는 delete를 처리하지 않으며,

TypeScript가 이를 별로 좋아하지 않으므로쿼리 무효화를 고수하고 싶습니다.

 

쿼리 무효화와 부분 업데이트라는 두 가지 타입의 이벤트를 모두 처리하는 코드 샌드박스 예제가 있습니다.

https://codesandbox.io/s/react-query-websockets-ep1op?file=/src/App.tsx 

예제의 커스텀 훅은 동일한 WebSocket을 사용하여 서버 왕복을 시뮬레이션하기 때문에 조금 더 복잡합니다만
실제 서버가 있는 경우에는 이렇지 않습니다.

StaleTime 늘리기

React Query는 default staleTime이 0입니다.즉, 모든 쿼리는 즉시 오래된 것으로 간주됩니다.
이는 새 옵저버(섭스크라이버)가 마운트 되거나 사용자가 창에 초점을 다시 맞출 때 쿼리를 다시 가져옴을 의미합니다.
필요한 경우 데이터를 최신 상태로 유지하는 것을 목표로 합니다.
 
이 목표는 실시간으로 데이터를 업데이트하는 WebSocket과 많이 겹칩니다.
서버가 메시지를 통해 그렇게 하라고 지시했기 때문에 방금 수동으로 무효화했는데 왜 다시 가져와야 합니까?
즉, 웹 소켓을 통해 모든 데이터를 업데이트하는 경우 staleTime을 높게 설정하는 것이 좋습니다.
내 예에서는 Infinity를 사용했습니다.
이는 데이터가 처음에 useQuery를 통해 가져온 다음 항상 캐시에서 가져온다는 것을 의미합니다.
리페치는 명시적 쿼리 무효화를 통해서만 발생합니다.

QueryClient를 생성할 때 전역 쿼리 기본값을 설정하여 이를 가장 잘 달성할 수 있습니다.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: Infinity,
    },
  },
})

반응형