React Query useSuspenseQuery
React Query(TanStack Query)로 데이터를 페칭하면 항상 같은 패턴이 반복된다. isLoading이면 로딩 스피너, isError면 에러 메시지, 그 외에 데이터 렌더링. 컴포넌트마다 이 분기를 작성하다 보면 코드가 지저분해지고, 더 큰 문제는 TypeScript에서 data가 항상 T | undefined로 추론된다는 점이다.
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와 무엇이 다른가
useSuspenseQuery는 useQuery와 동일한 queryKey/queryFn 인터페이스를 사용하지만, 동작 방식이 근본적으로 다르다.
| 구분 | useQuery | useSuspenseQuery |
|---|---|---|
| 로딩 처리 | 컴포넌트 내부에서 isLoading 분기 | 가장 가까운 <Suspense> fallback에 위임 |
| 에러 처리 | 컴포넌트 내부에서 error 분기 | 가장 가까운 ErrorBoundary에 위임 |
data 타입 | T | undefined | T (항상 정의됨) |
enabled 옵션 | 사용 가능 | 사용 불가 |
placeholderData | 사용 가능 | 사용 불가 |
핵심은 관심사의 분리다. 데이터를 사용하는 컴포넌트는 오직 데이터가 있는 상태만 다루고, 로딩과 에러는 상위 경계(Boundary)가 처리한다. 이렇게 하면 컴포넌트 코드가 훨씬 깔끔해지고, data가 undefined가 아님이 타입 레벨에서 보장된다.
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는 에러 상태를 가로챈다.
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:
<div>
<Suspense fallback={<Skeleton />}>
<UserProfile id={id} />
</Suspense>
<Suspense fallback={<Skeleton />}>
<UserPosts userId={id} />
</Suspense>
</div>
두 컴포넌트가 병렬로 페칭되고, 각자 준비되는 대로 렌더링된다. 프로필이 먼저 로드되면 프로필부터 보여주고, 게시물은 여전히 로딩 중일 수 있다.
하나의 Suspense로 묶기:
<Suspense fallback={<PageSkeleton />}>
<UserProfile id={id} />
<UserPosts userId={id} />
</Suspense>
두 컴포넌트가 모두 준비될 때까지 전체 영역이 fallback을 보여준다. 주의할 점은, Suspense 안에서 여러 useSuspenseQuery가 있으면 순차적(serial)으로 페칭된다. 첫 번째 쿼리가 resolve되어야 두 번째 컴포넌트가 마운트되면서 페칭을 시작하기 때문이다. 이를 워터폴(waterfall) 문제라고 한다.
워터폴 문제와 useSuspenseQueries
같은 Suspense 경계 안에서 순차적으로 suspend하면 성능이 떨어진다. 이 문제를 해결하려면 같은 컴포넌트에서 여러 쿼리를 동시에 선언해야 한다.
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)다.
// 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하면 두 번째 쿼리를 선언한 코드 자체가 실행되지 않는다.
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를 제공한다.
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>
);
}
동작 흐름:
- 쿼리 에러 발생 → ErrorBoundary가 에러를 잡고 fallback 렌더링
- 사용자가 "다시 시도" 클릭 →
resetErrorBoundary()호출 - ErrorBoundary의
onReset이reset을 실행 → 해당 경계 안의 쿼리 에러 상태 초기화 - ErrorBoundary가 리셋되면서 자식 컴포넌트 다시 마운트 → 쿼리 재실행
throwOnError의 기본 동작
useSuspenseQuery의 throwOnError는 단순히 true가 아니라 조건부다.
throwOnError: (error, query) => typeof query.state.data === 'undefined'
캐시에 이전 데이터가 있으면 에러를 throw하지 않고 stale 데이터를 보여준다. 예를 들어 첫 번째 페칭은 성공했는데 refetch에서 에러가 발생한 경우, 기존 데이터를 유지하면서 백그라운드에서 에러 상태가 된다. 이렇게 하면 이미 화면에 데이터가 있는 상태에서 갑자기 에러 화면으로 전환되는 좋지 않은 UX를 방지할 수 있다.
모든 에러를 ErrorBoundary로 보내고 싶다면 수동으로 throw해야 한다.
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를 유지하면서 백그라운드에서 새 데이터를 준비한다.
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 안에서 탭을 변경하면:
- 현재 탭의 UI가 그대로 유지됨
isPending이true가 됨 (로딩 표시용으로 활용 가능)- 새 탭의 데이터가 준비되면 한 번에 전환
placeholderData가 없는 대신, startTransition이 그 역할을 대체한다. 사실 더 강력한데, 어떤 상태 변경이든 transition으로 감쌀 수 있기 때문이다.
실전 패턴: 재사용 가능한 Boundary 컴포넌트
매번 QueryErrorResetBoundary + ErrorBoundary + Suspense를 중첩하는 건 번거롭다. 이걸 하나의 컴포넌트로 합치면 편하다.
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>
);
}
사용할 때:
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를 사용할 수 있다. 서버에서 데이터를 페칭하고, 결과를 스트리밍으로 클라이언트에 전달하는 방식이다.
// 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 |
타입 안전성이 중요한 경우 (data가 undefined 아님) | useSuspenseQuery |
| 선언적 로딩/에러 처리를 원하는 경우 | useSuspenseQuery |
| 여러 컴포넌트의 로딩 상태를 통합하고 싶은 경우 | useSuspenseQuery |
| Next.js Suspense 스트리밍을 사용하는 경우 | useSuspenseQuery |
둘 다 같은 프로젝트에서 함께 사용할 수 있다. 무조건 하나만 고집할 필요는 없고, 상황에 맞게 선택하면 된다.
정리
useSuspenseQuery의 핵심 가치는 로딩과 에러 상태를 컴포넌트 밖으로 들어올리는 것이다. 이를 통해:
- 컴포넌트는 "데이터가 있는 상태"만 다루면 됨 → 코드 단순화
data가 타입 레벨에서undefined가 아님 → 타입 안전성- Suspense 경계의 위치로 UX를 설계할 수 있음 → 선언적 UI
startTransition과 결합하여 매끄러운 전환 가능
React의 Suspense 모델이 성숙해지면서 useSuspenseQuery는 점점 더 표준적인 데이터 페칭 패턴이 되어가고 있다. 특히 React Server Components와 스트리밍 SSR이 보편화되면서, Suspense 기반 데이터 페칭은 React 생태계의 주요 방향성과 일치한다.