useQuery vs useSuspenseQuery 그리고 use()

밍글·2025년 2월 17일
5

FE스터디

목록 보기
6/8
post-thumbnail

TanStack Query가 v5버전을 정식 출시하면서 주요 변경 중에 suspense를 지원하는 useSuspenseQuery, useSuspenseInfiniteQuery, useSuspenseQueries가 나오게 되었다. 그러면서 기존의 useQuery의 status도 일부 변경이 되었다. 대학생 때 프로젝트에 도입하면서 느꼈던 점을 정리해보고 최근에 react19버전이 출시되면서 use()라는 새로운 api가 나왔는데 SSR과 폼 작업에 중점을 두면서 서버 및 비동기 코드 작업을 더 쉽게 만들고 있다는 모습을 보여주었었다. 그러면 react-query와 어떻게 되는 것일까 궁금해서 작성해보았다.


⛔ 주의사항
이 포스팅은 사용하기에 초점을 두기보다는 각 hook들에 대한 속성 및 설명에 초점을 두었다.
또한 SSR에 관한 부분을 작성한 것이 아니기 때문에 이 부분은 추후 포스팅에서 다룰 예정이다.

useQuery

v4에서의 변경사항들

콜백들 삭제 + status값 일부 변경

v5에서의 변경사항은 onSuccess, onError, onSettled 콜백들은 이제 사용되지 않는다. 이는 개발자의 의도와 다르게 행동할 수 있기 때문이다.

  1. 상태 동기화를 목적으로 사용했을 때 추가 렌더링 발생 ex) onSuccess 콜백에 로컬 또는 전역 상태 업데이트
  2. 캐시를 사용할 경우, 콜백이 실행되지 않을 가능성이 존재한다.

캐시에서 데이터를 읽어올때 onSuccess 는 문제를 일으킬 수 있다. fetch가 발생해야 onSuccess가 실행되는데 캐시된 데이터를 사용하면 re-fetch되지 않으면서 onSuccess가 호출되지 않을 수 있기 때문이다. 이러한 버그는 이유를 알지 못하면 추적하기 어려웠다.

위와 같은 이유로 콜백함수들을 없앴지만 그럼에도 콜백을 사용해야 한다면 다음과 같은 방법으로 하면 된다.

  1. 전역 콜백으로 처리

    • queryClient 설정을 할 때 queryCache 부분의 onError를 다음과 같이 처리한다.
    const queryClient = new QueryClient({
      queryCache: new QueryCache({
        onError: (error) =>
          toast.error(`Something went wrong: ${error.message}`),
      }),
    })
  2. useEffect 사용

      React.useEffect(() => {
        if (query.data) {
          // ... 
        }
      }, [query.data])
    • 캐시된 데이터든 새로 fetch한 데이터든 실행이 된다. useEffect의 dependency가 동작하기 때문
  3. useQuery의 select사용

    export const useTodosQuery = () =>
      useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
        select: (data) => data.map((todo) => todo.name.toUpperCase()),
      })
    • 데이터가 있을 때마다 실행되는 option이다. 이럴 경우 undefined인 경우를 고려할 필요가 없다.
  4. isLoading, isPending, isFetching, isError 등으로 컴포넌트 내에서 처리한다. 이런 값들을 이용해서 컴포넌트에서 적절한 UI처리가 가능하기도 하다.

    function TodoList() {
      const todos = useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos
      })
    
      if (todos.isPending) {
        return 'Loading...'
      }
    
      // 에러 처리 방식
      //  todos.status === 'error' 로도 확인할 수 있다. 여기서의 todos는 queryKey로 보면 된다. 
      if (todos.isError) {
        return 'An error occurred'
      }
    
      return (
        <div>
          {todos.data.map((todo) => (
            <Todo key={todo.id} {...todo} />
          ))}
        </div>
      )
    }

react query의 status로도 상태를 체크할 수 있다.
status에 pending, error, success이 있기 때문이다.

const { status } = useQuery({ 
  queryKey: ['todos'], 
  queryFn: fetchTodos 
})
if (status === 'loading') {
  return <div>Loading...</div>
}
if (status === 'error') {
  return <div>Error!</div>
}
if (status === 'success') {
  return <div>Data loaded!</div>
}

이러면 isLoading, isError등을 활용하는 것과 동일한 결과를 얻어낼 수 있다.

status 변경

loading → pending

isLoading → isPending

isPending && isFetching의 기능인 isInitialLoading → isLoading

remove메서드 삭제

캐시에서 쿼리를 제거하는 remove메서드를 이제 사용하지 않는다. 쿼리를 제거한 다음 렌더에는 새로운 로딩 상태로 이어지기 때문에 활성화 되어 있는 쿼리를 제거하는 것은 맞지 않다고 하기 때문이다. 쿼리를 제거해야 하는 경우에는 queryClient.removeQueries({ queryKey: key })를 사용해야 한다고 한다.

const queryClient = useQueryClient();
 const query = useQuery({ queryKey, queryFn });
- query.remove()
+ queryClient.removeQueries({ queryKey })

suspense 삭제

useQuery에서 사용되던 suspense: boolean 옵션은 제거되었다. 이러면 useQuery에서의 suspense는 어디로갔냐할 수 있지만 뒤에 후술할 훅들이 suspense를 사용해 데이터 패칭을 하게 된다.

useSuspenseQuery

v5부터는 useSuspenseQuery, useSuspenseInfiniteQuery와 useSuspenseQueries를 사용함으로써 안정적으로 suspense를 사용해 데이터 패칭을 할 수 있다. 새로 추가된 suspense hook은 로딩과 에러 상태를 Suspense와 ErrorBoudnary가 처리하기 때문에 status가 언제나 success인 data 값을 반환한다.

기존 useQuery와의 차이점

  • 성공한 결과만을 전달해줘서 반환이 undefined일 때를 생각하지 않아도 된다.
  • 이 훅을 사용하려면 Promise가 발생하는 부모의 컴포넌트에서 Suspense로 반드시 묶어줘야한다.
  • isPending, pending, isLoading 등을 사용하지 않아도 된다.

Options

대부분은 useQuery와 동일하지만 다음 옵션들은 제거된다.

  • throwOnError(에러를 throw할지 여부)
    • throwOnError의 기본값을 보다시피 데이터가 undefined가 될 수 있어서 제거되었다고 볼 수 있다.

      throwOnError: (error, query) => typeof query.state.data === 'undefined'
  • enabled(쿼리 활성화 여부)
  • placeholderData(임시 데이터)

반환값

대부분은 비슷하지만 아래의 차이점이 있는데 그 이유는 Suspense가 로딩 상태를 처리하므로 컴포넌트가 렌더링되는 시점에는 이미 데이터가 있는 상태로 보기 때문이다.

  • 위에 서술되었다시피 data는 항상 보장된다. 즉, undefined일 수 없다는 것이다.
  • isPlaceholderData가 없다. (임시 데이터를 사용하지 않는다.)
  • status는 항상 success이다.

취소가 작동하지 않는다.

suspense의 작동 방식 때문에 요청 취소 기능을 사용할 수 없다.

⭐ Suspense의 작동 방식

  1. Suspense는 Promise가 resolve될 때까지 렌더링을 중단(suspend)한다.
  2. 한번 Promise가 시작되면, React는 그 Promise의 완료를 기다린다.
  3. 이 시점에서 Promise를 취소하더라도 Suspense는 여전히 원래 Promise의 결과를 기다리게 된다.

이러기 때문에 suspense 모드를 사용할 때는 상태값과 에러 객체가 필요하지 않으며, 대신 React.Suspense 컴포넌트를 사용한다. 쿼리를 조건부로 활성화/비활성화 할 수가 없다. 일반적으로 의존적인 쿼리의 경우 이는 필요하지 않고 suspense를 사용하면 컴포넌트 내의 모든 쿼리가 순차적으로 가져와지기 때문이다.

SSR에서 무작정 사용하기X

App router가 14까지도 안정성에 있어 문제가 있어왔어서 app router 대신에 page router를 활용해서 useSuspenseQuery를 무작정 활용하려고 했을 때 바로 에러가 나왔었다…

Next.js의 SSR에서 왜 바로 사용을 못할까?

기본적으로 따로 CSR을 설정해주지 않는 이상 Next.js는 SSR형태이다.

여러 글을 읽게 되었고 그 중 인상적인 것을 보았다.

  1. Suspense는 서버 사이드에서 지원되지 않는다.
  2. 서버에서 렌더링 될 때 Suspense를 읽는다.

⭐ Page Router의 SSR 동작방식
1. 서버에서 전체 페이지를 한 번에 렌더링
2. HTML을 클라이언트로 전송
3. 클라이언트에서 hydration
이 과정에서 서버에서 Suspense를 만나면 제대로 처리하지 못하며 Suspense 내부의 데이터 페칭이 끝날 때까지 전체 렌더링이 블로킹된다.

Solution

SSR을 활용할 경우에는 prefetchQuery, Hydration API를 활용하여 해야한다.

CSR로 처리를 하거나 ReactQueryStreamedHydration의 기능을 활용해야 한다.

⭐ ReactQueryStreamedHydration?
컴포넌트에서 useSuspenseQuery를 호출하는 것만으로도 서버(클라이언트 컴포넌트 내)에서 데이터를 가져올 수 있게 해준다. SuspenseBoundaries가 해결됨에 따라 결과가 서버에서 클라이언트로 스트리밍 된다. useSuspenseQuery를 Suspense 경계로 감싸지 않고 호출하면, fetch가 완료될 때까지 HTML 응답이 시작되지 않는다. 이를 구현하려면 먼저 실험적인 패키지를 설치하고 앱을 ReactQueryStreamedHydration 컴포넌트로 감싸면 된다.

활용 예시코드


// app/providers.tsx
function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // SSR에서는 보통 클라이언트에서 즉시 리페칭하는 것을 피하기 위해
        // staleTime을 0보다 큰 값으로 설정한다.
        staleTime: 60 * 1000,
      },
    },
  })
}
let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
  if (isServer) {
    // Server: 항상 새로운 쿼리 클라이언트를 생성한다.
    return makeQueryClient()
  } else {
    // Browser: 이미 있는 쿼리 클라이언트가 없을 때만 새로 만든다
    // React가 초기 렌더링 중에 일시 중단될 때  새로운 클라이언트를 다시 만들지 않기 위함이다.
    // 만약 쿼리 클라이언트 생성 아래에 suspense 경계가 있다면 이는 필요하지 않을 수 있다.
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}
export function Providers(props: { children: React.ReactNode }) {
  const queryClient = getQueryClient()

  return (
    <QueryClientProvider client={queryClient}>
      <ReactQueryStreamedHydration>
        {props.children}
      </ReactQueryStreamedHydration>
    </QueryClientProvider>
  )
}

하지만 ReactQueryStreamedHydration은 아직 정식출시버전이 아니기 때문에 page-router를 활용할 때에는 csr로 처리를 하여서 같이 사용하는 수밖에 없다. 필자의 경우에는 csr로 돌려서 활용하였다. 다음과 같이 말이다.


const FriendComponents = () => {
  const FriendCardCompo = dynamic(() => import("./FriendCardCompo"), {
    ssr: false,
  });
  return (
    <div>
      <ErrorBoundary>
        <Suspense fallback={<div>loading중...</div>}>
          <FriendCardCompo />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
};
// useSuspenseQuery활용하는 부분
function useGetFriendList({ date, page }: { date: string; page: number }) {
  const fetchGetFriendList = async () => {
    const response = await request<null, IfriendList, FriendParam>({
      uri: //...,
      method: "get",
      params: //...
    });

    return response.data;
  };

  const { data: friendData } = useSuspenseQuery({
    queryKey: ["get-friendList", date, page],
    queryFn: fetchGetFriendList,
  });

  return { friendData };
}

app router의 경우에는 dynamic대신 ‘use client’를 사용해야 한다.

use()

결론만 말하자면 해당 기능을 react-query활용하려면 실험적으로 가능하다고 한다. 이 때는 useSuspenseQuery가 아닌 useQuery와 함께 사용하면서 Suspense를 활용할 수 있다. Suspense를 사용할 수 있기 때문에 ErrorBoundary또한 활용할 수 있다.

useQueryResult?
@tanstack/react-query에서 제공하는 타입으로, useQuery 훅의 반환 타입이다.
아래와 같이 구성되어 있다.

type UseQueryResult<TData> = {
  data: TData
  error: Error | null
  status: 'loading' | 'error' | 'success'
  promise: Promise<TData>  // 여기 promise 속성이 있음
  // ... 기타 속성들
}
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchTodos, type Todo } from './api'

function TodoList({ query }: { query: UseQueryResult<Todo[]> }) {
// 자식 컴포넌트에서 Promise를 받아 resolve된 값을 반환
  const data = React.use(query.promise)

  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}

export function App() {
// 부모 컴포넌트에서 쿼리 실행
  const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

  return (
    <>
      <h1>Todos</h1>
      <React.Suspense fallback={<div>Loading...</div>}>
        <TodoList query={query} />
      </React.Suspense>
    </>
  )
}

use()를 사용하면 data가 undefined가 될 수 있을까?

결론부터 말하자면 아니요다. 그 이유는 use()의 흐름을 보면 되기 때문이다.

  1. Promise가 resolve되면 실제 데이터를 반환한다.
  2. reject되면 가장 가까운 Error Boundary로 에러를 throw한다.
  3. 아직 pending 상태면 Suspense로 중단한다.

즉, 데이터가 undefined인 상황이 발생하기 이전에 Suspense와 Error Boundary로 처리가 되기 때문에 useSuspenseQuery와 마찬가지로 data는 항상 정의되어 있음이 보장된다.


참고자료

SSR 환경에서 Suspense와 useSuspenseQuery가 오작동하는 문제 해결하기

react-query v5에 useQuery의 onSuccess가 사라졌다.

TanStack Query V5 톺아보기

Migrating to TanStack Query v5 | TanStack Query React Docs

profile
예비 초보 개발자의 기록일지

1개의 댓글

comment-user-thumbnail
2025년 2월 24일

매우 좋은 글이네요 항상 로딩을 신경써서 suspense로 skeleton으로 사용했는데 ssr에서 무작정 사용하면 안되는군요

답글 달기

관련 채용 정보