🔥 React Query와 웹소켓 사용하기

846자
11분

React Query와 웹소켓을 함께 사용해 실시간 데이터를 처리하는 방법에 대한 질문이 최근 많이 들어왔습니다. 이에 대해 직접 실험해보고 그 결과를 공유하고자 합니다.

웹소켓이란?

웹소켓은 서버에서 클라이언트(브라우저)로 실시간 메시지를 보낼 수 있게 해주는 기술입니다. 일반적인 HTTP 통신에서는 클라이언트가 서버에 데이터를 요청하고, 서버가 응답한 후 연결이 종료됩니다. 이 방식으로는 서버가 업데이트된 정보를 클라이언트에 즉시 알려주기 어렵습니다.

웹소켓은 이런 한계를 극복합니다. 브라우저가 먼저 연결을 시작하지만, 웹소켓으로 연결 방식을 전환하자고 요청합니다. 서버가 이를 수락하면 프로토콜이 변경되고, 연결은 어느 한쪽이 끊을 때까지 유지됩니다. 이로써 양방향 통신이 가능해집니다.

이 방식의 가장 큰 장점은 서버가 필요한 업데이트만 선택적으로 클라이언트에 보낼 수 있다는 것입니다. 여러 사용자가 같은 데이터를 보고 있을 때 한 사용자가 수정을 하면, 다른 사용자들은 보통 직접 새로고침을 해야 변경 사항을 볼 수 있습니다. 하지만 웹소켓을 사용하면 이런 업데이트를 실시간으로 즉시 전달할 수 있습니다.

React Query 연동하기

React Query는 주로 클라이언트 측 비동기 상태 관리 라이브러리이기 때문에, 서버 측 웹소켓 설정에 대해서는 다루지 않겠습니다. 서버 기술에 따라 구현 방법이 다르기 때문입니다.

React Query에는 웹소켓을 위한 특별한 기능이 없습니다. 하지만 이는 웹소켓을 지원하지 않는다는 뜻이 아닙니다. React Query는 데이터를 가져오는 방식에 대해 매우 유연합니다. 단지 Promise가 해결되거나 거부되는 것만 필요할 뿐, 나머지는 개발자의 몫입니다.

단계별 구현

기본 아이디어는 웹소켓을 사용하지 않을 때처럼 쿼리를 평소대로 설정하는 것입니다. 대부분의 경우 엔티티를 조회하고 수정하는 일반적인 HTTP 엔드포인트가 있을 것입니다.

const usePosts = () =>
  useQuery({ queryKey: ['posts', 'list'], queryFn: fetchPosts })
 
const usePost = (id) =>
  useQuery({
    queryKey: ['posts', 'detail', id],
    queryFn: () => fetchPost(id),
  })
 
javascript

추가로, 앱 전체에서 사용할 useEffect를 설정해 웹소켓 엔드포인트에 연결할 수 있습니다. 이 과정은 사용하는 기술에 따라 다릅니다. HasuraFirebase를 사용한 예제들이 있습니다. 여기서는 브라우저의 기본 WebSocket API를 사용하겠습니다:

const useReactQuerySubscription = () => {
  React.useEffect(() => {
    const websocket = new WebSocket('wss://echo.websocket.org/')
    websocket.onopen = () => {
      console.log('connected')
    }
 
    return () => {
      websocket.close()
    }
  }, [])
}
 
javascript

데이터 소비하기

연결을 설정한 후에는 웹소켓으로 데이터가 들어올 때 호출될 콜백이 필요합니다. 이 데이터의 형태는 개발자가 원하는 대로 설정할 수 있습니다. Tanner Linsley이 메시지에서 영감을 받아, 백엔드에서 완전한 데이터 객체 대신 _이벤트_를 보내는 방식을 선호합니다:

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)
      const queryKey = [...data.entity, data.id].filter(Boolean)
      queryClient.invalidateQueries({ queryKey })
    }
 
    return () => {
      websocket.close()
    }
  }, [queryClient])
}
 
javascript

이것만으로 이벤트를 받았을 때 목록과 상세 뷰를 업데이트할 수 있습니다.

  • { "entity": ["posts", "list"] }는 게시물 목록을 무효화합니다.
  • { "entity": ["posts", "detail"], id: 5 }는 단일 게시물을 무효화합니다.
  • { "entity": ["posts"] }는 게시물 관련 모든 것을 무효화합니다.

쿼리 무효화는 웹소켓과 잘 어울립니다. 이 방식은 과도한 푸시 문제를 피할 수 있습니다. 현재 관심 없는 엔티티에 대한 이벤트를 받으면 아무 일도 일어나지 않기 때문입니다. 예를 들어, 프로필 페이지에 있을 때 게시물 업데이트를 받으면, invalidateQueries는 다음에 게시물 페이지로 갈 때 데이터를 다시 가져오도록 합니다. 하지만 즉시 다시 가져오지는 않습니다. 활성 관찰자가 없기 때문입니다. 그 페이지로 다시 가지 않으면 푸시된 업데이트는 완전히 불필요했을 것입니다.

부분 데이터 업데이트

물론 큰 데이터셋에 작지만 잦은 업데이트가 있다면, 웹소켓으로 부분 데이터만 푸시하고 싶을 수 있습니다.

게시물 제목이 바뀌었나요? 제목만 푸시하세요. 좋아요 수가 변경되었나요? 그것만 푸시하세요.

이런 부분 업데이트를 위해 queryClient.setQueryData를 사용해 쿼리 캐시를 직접 업데이트할 수 있습니다. 무효화하는 대신 말이죠.

같은 데이터에 대해 여러 쿼리 키가 있다면, 예를 들어 쿼리 키에 여러 필터 기준이 포함되어 있거나 같은 메시지로 목록과 상세 뷰를 모두 업데이트하고 싶다면 이 방법이 좀 더 복잡해집니다. queryClient.setQueriesData는 이런 경우를 처리할 수 있는 라이브러리의 비교적 새로운 추가 기능입니다:

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])
}
 
javascript

이 방법은 제 취향에는 너무 동적이고, 추가나 삭제를 처리하지 않으며, TypeScript와도 잘 맞지 않습니다. 그래서 개인적으로는 쿼리 무효화 방식을 선호합니다.

그럼에도 불구하고, 여기 코드샌드박스 예제에서 무효화와 부분 업데이트 두 가지 이벤트 타입을 모두 처리하는 방법을 보여드렸습니다. (참고: 이 예제에서는 서버 왕복을 시뮬레이션하기 위해 같은 웹소켓을 사용하므로 커스텀 훅이 좀 더 복잡합니다. 실제 서버가 있다면 이 부분은 신경 쓰지 않아도 됩니다).

staleTime 늘리기

React Query는 기본 staleTime이 _0_입니다. 이는 모든 쿼리가 즉시 오래된 것으로 간주되어, 새 구독자가 마운트되거나 사용자가 윈도우에 포커스를 맞출 때 다시 가져온다는 뜻입니다. 이는 데이터를 최대한 최신 상태로 유지하는 것이 목표입니다.

이 목표는 실시간으로 데이터를 업데이트하는 웹소켓의 목표와 많이 겹칩니다. 서버가 전용 메시지로 방금 무효화하라고 알려줬는데 왜 다시 가져와야 할까요?

그래서 모든 데이터를 웹소켓으로 업데이트한다면 staleTime을 높게 설정하는 것을 고려해보세요. 예제에서는 그냥 Infinity를 사용했습니다. 이는 데이터를 처음에 useQuery로 가져온 후 항상 캐시에서 가져온다는 뜻입니다. 명시적인 쿼리 무효화를 통해서만 다시 가져오게 됩니다.

이를 가장 잘 적용하려면 QueryClient를 만들 때 전역 쿼리 기본값을 설정하면 됩니다.

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

이렇게 하면 React Query와 웹소켓을 효과적으로 사용해 실시간 데이터를 처리할 수 있습니다. 각 기술의 장점을 살리면서 데이터를 최신 상태로 유지할 수 있습니다.