junyeokk
Blog
React Ecosystem·2025. 11. 15

React Query useSuspenseQuery

React Query(TanStack Query)로 데이터를 페칭하면 항상 같은 패턴이 반복된다. isLoading이면 로딩 스피너, isError면 에러 메시지, 그 외에 데이터 렌더링. 컴포넌트마다 이 분기를 작성하다 보면 코드가 지저분해지고, 더 큰 문제는 TypeScript에서 data가 항상 T | undefined로 추론된다는 점이다.

tsx
const { data, isLoading, error } = useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
});

// data는 User | undefined
// 아래에서 data.name을 쓰려면 항상 옵셔널 체이닝이나 가드가 필요
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <div>{data?.name}</div>; // data!.name으로 단언하거나 ?.으로 우회

React 18이 Suspense for Data Fetching 개념을 도입하면서, 로딩 상태와 에러 상태를 컴포넌트 바깥으로 위임하는 것이 가능해졌다. React Query v5에서는 이 패턴을 공식 지원하는 useSuspenseQuery 훅을 제공한다.


useQuery와 무엇이 다른가

useSuspenseQueryuseQuery와 동일한 queryKey/queryFn 인터페이스를 사용하지만, 동작 방식이 근본적으로 다르다.

구분useQueryuseSuspenseQuery
로딩 처리컴포넌트 내부에서 isLoading 분기가장 가까운 <Suspense> fallback에 위임
에러 처리컴포넌트 내부에서 error 분기가장 가까운 ErrorBoundary에 위임
data 타입T | undefinedT (항상 정의됨)
enabled 옵션사용 가능사용 불가
placeholderData사용 가능사용 불가

핵심은 관심사의 분리다. 데이터를 사용하는 컴포넌트는 오직 데이터가 있는 상태만 다루고, 로딩과 에러는 상위 경계(Boundary)가 처리한다. 이렇게 하면 컴포넌트 코드가 훨씬 깔끔해지고, dataundefined가 아님이 타입 레벨에서 보장된다.

tsx
import { useSuspenseQuery } from '@tanstack/react-query';

function UserProfile({ id }: { id: string }) {
  const { data } = useSuspenseQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id),
  });

  // data는 User 타입. undefined 체크 필요 없음
  return <div>{data.name}</div>;
}

Suspense와 ErrorBoundary 구성

useSuspenseQuery가 동작하려면 반드시 상위에 <Suspense>와 ErrorBoundary가 있어야 한다. React의 <Suspense>는 로딩 상태를, ErrorBoundary는 에러 상태를 가로챈다.

tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

function UserPage({ id }: { id: string }) {
  return (
    <ErrorBoundary fallback={<div>에러 발생!</div>}>
      <Suspense fallback={<Spinner />}>
        <UserProfile id={id} />
      </Suspense>
    </ErrorBoundary>
  );
}

이 구조에서 UserProfile이 데이터를 페칭하는 동안에는 <Spinner />가 표시되고, 에러가 발생하면 "에러 발생!" 메시지가 표시된다. UserProfile 내부에서는 이런 분기를 전혀 신경 쓸 필요가 없다.

Suspense 경계의 위치가 중요하다

Suspense 경계를 어디에 두느냐에 따라 UX가 완전히 달라진다.

컴포넌트마다 개별 Suspense:

tsx
<div>
  <Suspense fallback={<Skeleton />}>
    <UserProfile id={id} />
  </Suspense>
  <Suspense fallback={<Skeleton />}>
    <UserPosts userId={id} />
  </Suspense>
</div>

두 컴포넌트가 병렬로 페칭되고, 각자 준비되는 대로 렌더링된다. 프로필이 먼저 로드되면 프로필부터 보여주고, 게시물은 여전히 로딩 중일 수 있다.

하나의 Suspense로 묶기:

tsx
<Suspense fallback={<PageSkeleton />}>
  <UserProfile id={id} />
  <UserPosts userId={id} />
</Suspense>

두 컴포넌트가 모두 준비될 때까지 전체 영역이 fallback을 보여준다. 주의할 점은, Suspense 안에서 여러 useSuspenseQuery가 있으면 순차적(serial)으로 페칭된다. 첫 번째 쿼리가 resolve되어야 두 번째 컴포넌트가 마운트되면서 페칭을 시작하기 때문이다. 이를 워터폴(waterfall) 문제라고 한다.


워터폴 문제와 useSuspenseQueries

같은 Suspense 경계 안에서 순차적으로 suspend하면 성능이 떨어진다. 이 문제를 해결하려면 같은 컴포넌트에서 여러 쿼리를 동시에 선언해야 한다.

tsx
import { useSuspenseQueries } from '@tanstack/react-query';

function UserDashboard({ id }: { id: string }) {
  const [
    { data: user },
    { data: posts },
  ] = useSuspenseQueries({
    queries: [
      {
        queryKey: ['user', id],
        queryFn: () => fetchUser(id),
      },
      {
        queryKey: ['posts', id],
        queryFn: () => fetchPosts(id),
      },
    ],
  });

  // 두 쿼리가 병렬로 페칭된 후 동시에 준비됨
  return (
    <div>
      <h1>{user.name}</h1>
      <PostList posts={posts} />
    </div>
  );
}

useSuspenseQueries는 전달된 모든 쿼리를 병렬로 시작하고, 전부 완료되면 한 번에 resume한다. 워터폴을 피하면서도 Suspense의 이점을 유지할 수 있다.

또 다른 방법은 앞에서 본 것처럼 개별 Suspense 경계로 분리하는 것이다. 각 컴포넌트가 독립적으로 suspend하므로 병렬 페칭이 자연스럽게 이루어진다.


enabled 옵션이 없는 이유

useQuery에서 흔히 쓰는 패턴 중 하나가 의존적 쿼리(dependent query)다.

tsx
// useQuery에서는 이렇게 했다
const { data: user } = useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
});

const { data: projects } = useQuery({
  queryKey: ['projects', user?.orgId],
  queryFn: () => fetchProjects(user!.orgId),
  enabled: !!user?.orgId, // user가 로드될 때까지 비활성화
});

useSuspenseQuery에서는 enabled 옵션이 존재하지 않는다. 이유는 간단하다: Suspense가 이미 의존성을 해결해준다. 첫 번째 쿼리가 suspend하면 두 번째 쿼리를 선언한 코드 자체가 실행되지 않는다.

tsx
function ProjectList({ id }: { id: string }) {
  // 이 쿼리가 resolve될 때까지 아래 코드는 실행되지 않음
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id),
  });

  // user가 반드시 존재하는 상태에서 실행됨
  const { data: projects } = useSuspenseQuery({
    queryKey: ['projects', user.orgId],
    queryFn: () => fetchProjects(user.orgId),
  });

  return <div>{projects.map(p => <ProjectCard key={p.id} project={p} />)}</div>;
}

이 코드에서 두 쿼리는 자연스럽게 순차적으로 실행된다. 첫 번째가 suspend → resolve → 두 번째 실행 → suspend → resolve. 코드 흐름이 동기적으로 읽히기 때문에 enabled 같은 조건부 로직이 필요 없다.

단, 이 경우 두 쿼리가 워터폴로 실행된다는 점은 인지해야 한다. 의존적 쿼리에서는 어차피 순차적으로 실행해야 하므로 문제가 되지 않지만, 독립적인 쿼리라면 useSuspenseQueries나 Suspense 경계 분리를 써야 한다.


에러 처리: QueryErrorResetBoundary

Suspense 모드에서 에러가 발생하면 가장 가까운 ErrorBoundary가 잡는다. 문제는 에러 복구다. ErrorBoundary를 리셋해도 React Query의 쿼리 상태가 여전히 에러이면 또 같은 에러를 throw한다. 쿼리의 에러 상태도 함께 리셋해줘야 한다.

React Query는 이를 위해 QueryErrorResetBoundary를 제공한다.

tsx
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';

function App() {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          onReset={reset}
          fallbackRender={({ resetErrorBoundary }) => (
            <div>
              <p>문제가 발생했습니다.</p>
              <button onClick={resetErrorBoundary}>다시 시도</button>
            </div>
          )}
        >
          <Suspense fallback={<Spinner />}>
            <UserProfile id={id} />
          </Suspense>
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

동작 흐름:

  1. 쿼리 에러 발생 → ErrorBoundary가 에러를 잡고 fallback 렌더링
  2. 사용자가 "다시 시도" 클릭 → resetErrorBoundary() 호출
  3. ErrorBoundary의 onResetreset을 실행 → 해당 경계 안의 쿼리 에러 상태 초기화
  4. ErrorBoundary가 리셋되면서 자식 컴포넌트 다시 마운트 → 쿼리 재실행

throwOnError의 기본 동작

useSuspenseQuerythrowOnError는 단순히 true가 아니라 조건부다.

ts
throwOnError: (error, query) => typeof query.state.data === 'undefined'

캐시에 이전 데이터가 있으면 에러를 throw하지 않고 stale 데이터를 보여준다. 예를 들어 첫 번째 페칭은 성공했는데 refetch에서 에러가 발생한 경우, 기존 데이터를 유지하면서 백그라운드에서 에러 상태가 된다. 이렇게 하면 이미 화면에 데이터가 있는 상태에서 갑자기 에러 화면으로 전환되는 좋지 않은 UX를 방지할 수 있다.

모든 에러를 ErrorBoundary로 보내고 싶다면 수동으로 throw해야 한다.

tsx
const { data, error, isFetching } = useSuspenseQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
});

if (error && !isFetching) {
  throw error; // 강제로 ErrorBoundary에 전파
}

startTransition으로 fallback 방지

queryKey가 변경되면(예: 탭 전환, 필터 변경) 새로운 데이터를 페칭하면서 다시 suspend한다. 이때 현재 화면이 fallback 스피너로 대체되는데, 이미 데이터가 보이는 상태에서 갑자기 스피너로 전환되면 사용자 경험이 나빠진다.

React 18의 startTransition으로 이 문제를 해결할 수 있다. Transition으로 감싸진 상태 업데이트는 기존 UI를 유지하면서 백그라운드에서 새 데이터를 준비한다.

tsx
import { useState, useTransition, Suspense } from 'react';

function TabContainer() {
  const [tab, setTab] = useState('profile');
  const [isPending, startTransition] = useTransition();

  const handleTabChange = (newTab: string) => {
    startTransition(() => {
      setTab(newTab);
    });
  };

  return (
    <div>
      <TabButtons activeTab={tab} onChange={handleTabChange} />
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        <Suspense fallback={<Spinner />}>
          {tab === 'profile' && <UserProfile id={id} />}
          {tab === 'posts' && <UserPosts userId={id} />}
        </Suspense>
      </div>
    </div>
  );
}

startTransition 안에서 탭을 변경하면:

  1. 현재 탭의 UI가 그대로 유지됨
  2. isPendingtrue가 됨 (로딩 표시용으로 활용 가능)
  3. 새 탭의 데이터가 준비되면 한 번에 전환

placeholderData가 없는 대신, startTransition이 그 역할을 대체한다. 사실 더 강력한데, 어떤 상태 변경이든 transition으로 감쌀 수 있기 때문이다.


실전 패턴: 재사용 가능한 Boundary 컴포넌트

매번 QueryErrorResetBoundary + ErrorBoundary + Suspense를 중첩하는 건 번거롭다. 이걸 하나의 컴포넌트로 합치면 편하다.

tsx
import { ReactNode, Suspense } from 'react';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';

interface AsyncBoundaryProps {
  children: ReactNode;
  loadingFallback?: ReactNode;
  errorFallback?: (props: FallbackProps) => ReactNode;
}

function AsyncBoundary({
  children,
  loadingFallback = <Spinner />,
  errorFallback = DefaultErrorFallback,
}: AsyncBoundaryProps) {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary onReset={reset} fallbackRender={errorFallback}>
          <Suspense fallback={loadingFallback}>
            {children}
          </Suspense>
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

function DefaultErrorFallback({ resetErrorBoundary }: FallbackProps) {
  return (
    <div>
      <p>문제가 발생했습니다.</p>
      <button onClick={resetErrorBoundary}>다시 시도</button>
    </div>
  );
}

사용할 때:

tsx
function UserPage({ id }: { id: string }) {
  return (
    <AsyncBoundary loadingFallback={<UserSkeleton />}>
      <UserProfile id={id} />
    </AsyncBoundary>
  );
}

이 패턴을 프로젝트 전체에서 사용하면 일관된 로딩/에러 처리가 가능하다.


Next.js 서버 컴포넌트에서의 활용

Next.js App Router에서는 @tanstack/react-query-next-experimental 패키지를 통해 서버에서 useSuspenseQuery를 사용할 수 있다. 서버에서 데이터를 페칭하고, 결과를 스트리밍으로 클라이언트에 전달하는 방식이다.

tsx
// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider, isServer } from '@tanstack/react-query';
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental';

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000, // SSR에서는 즉시 refetch 방지
      },
    },
  });
}

let browserQueryClient: QueryClient | undefined;

function getQueryClient() {
  if (isServer) {
    return makeQueryClient(); // 서버는 매 요청마다 새 클라이언트
  }
  if (!browserQueryClient) {
    browserQueryClient = makeQueryClient();
  }
  return browserQueryClient;
}

export function Providers({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient();
  return (
    <QueryClientProvider client={queryClient}>
      <ReactQueryStreamedHydration>
        {children}
      </ReactQueryStreamedHydration>
    </QueryClientProvider>
  );
}

ReactQueryStreamedHydration으로 감싸면 클라이언트 컴포넌트에서 useSuspenseQuery를 호출해도 서버에서 먼저 페칭이 이루어지고, Suspense 경계가 해제될 때 결과가 스트리밍된다. prefetch를 수동으로 관리하지 않아도 되는 편의를 제공한다.


useQuery vs useSuspenseQuery: 언제 무엇을 쓸까

상황추천
조건부 페칭이 필요한 경우 (enabled)useQuery
placeholderData로 즉시 UI를 보여줘야 하는 경우useQuery
컴포넌트 단위로 로딩/에러 상태를 세밀하게 제어해야 하는 경우useQuery
타입 안전성이 중요한 경우 (dataundefined 아님)useSuspenseQuery
선언적 로딩/에러 처리를 원하는 경우useSuspenseQuery
여러 컴포넌트의 로딩 상태를 통합하고 싶은 경우useSuspenseQuery
Next.js Suspense 스트리밍을 사용하는 경우useSuspenseQuery

둘 다 같은 프로젝트에서 함께 사용할 수 있다. 무조건 하나만 고집할 필요는 없고, 상황에 맞게 선택하면 된다.


정리

useSuspenseQuery의 핵심 가치는 로딩과 에러 상태를 컴포넌트 밖으로 들어올리는 것이다. 이를 통해:

  • 컴포넌트는 "데이터가 있는 상태"만 다루면 됨 → 코드 단순화
  • data가 타입 레벨에서 undefined가 아님 → 타입 안전성
  • Suspense 경계의 위치로 UX를 설계할 수 있음 → 선언적 UI
  • startTransition과 결합하여 매끄러운 전환 가능

React의 Suspense 모델이 성숙해지면서 useSuspenseQuery는 점점 더 표준적인 데이터 페칭 패턴이 되어가고 있다. 특히 React Server Components와 스트리밍 SSR이 보편화되면서, Suspense 기반 데이터 페칭은 React 생태계의 주요 방향성과 일치한다.

관련 문서