React TanStack Query v5 useSuspenseQuery + ErrorBoundary: 선언적 비동기 UI 패턴 실전 적용
isLoading/isError 분기를 컴포넌트에서 완전히 지우는 TypeScript 실전 가이드
솔직히 말하면, 저도 처음에 비동기 컴포넌트를 만들 때마다 비슷한 코드를 반복해서 작성했습니다. isLoading이면 스피너, isError면 에러 메시지, 그것도 아니면 드디어 데이터 렌더링. 컴포넌트 하나에서는 별로 나빠 보이지 않는데, 문제는 규모입니다. 컴포넌트가 50개가 되면 각각에 상태 분기가 붙고, 리팩터링할 때 빠진 분기 하나가 프로덕션 버그가 됩니다. 실제로 제가 참여한 프로젝트에서 data가 undefined인지 체크하는 코드를 빠뜨려서 화이트 스크린을 맞닥뜨린 경험이 있는데, 그때 "이걸 구조적으로 막을 방법이 없을까"라는 생각을 처음 했습니다.
TanStack Query v5의 useSuspenseQuery와 React의 Suspense, ErrorBoundary를 조합하면 로딩·에러·성공 세 가지 상태를 컴포넌트 트리의 서로 다른 경계로 완전히 분리할 수 있고, data가 항상 T 타입으로 보장됩니다. 경계를 설정하는 구조가 처음에는 낯설게 느껴질 수 있지만, 한 번 손에 익으면 왜 지금까지 안 썼나 싶은 패턴입니다.
이 글은 React와 TanStack Query를 어느 정도 사용해본 분을 대상으로 합니다.
QueryClient,QueryClientProvider기본 설정은 이미 갖춰져 있다고 가정하고 진행합니다.useSuspenseQuery사용법, React Suspense 패턴, TanStack Query v5 마이그레이션을 찾고 있다면 바로 이어서 보시면 됩니다.
TL;DR
useSuspenseQuery→data가 항상T(undefined 없음, 방어 코드 불필요)<Suspense fallback={...}>→ 로딩 상태 처리<ErrorBoundary>+QueryErrorResetBoundary→ 에러 처리 + 재시도
핵심 개념
명령형 vs 선언형: 무엇이 달라지는가
같은 기능을 두 방식으로 나란히 보는 것이 가장 직관적입니다.
명령형 방식 (기존 useQuery)
function UserProfile({ id }: { id: string }) {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});
if (isLoading) return <Spinner />;
if (isError) return <ErrorMessage message={error.message} />;
if (!data) return null; // TypeScript를 달래기 위한 방어 코드
return (
<div className="profile">
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}선언형 방식 (useSuspenseQuery)
function UserProfile({ id }: { id: string }) {
const { data } = useSuspenseQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});
// isLoading, isError, if (!data) 분기가 모두 사라졌습니다
return (
<div className="profile">
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}isLoading, isError, if (!data) 세 줄이 사라졌습니다. 로딩과 에러는 어디로 갔을까요? 컴포넌트 트리의 상위 경계(boundary)로 위임됐습니다.
| 상태 | 담당 |
|---|---|
| 로딩 | <React.Suspense fallback={...}> |
| 에러 | <ErrorBoundary> |
| 성공 | 실제 컴포넌트 (data는 항상 정의된 값) |
useSuspenseQuery — data가 항상 T인 훅
TanStack Query v4까지는 useQuery에 suspense: true 옵션을 실험적으로 끼워 넣는 방식이었는데, v5에서 전용 훅이 공식 안정 API로 분리됐습니다. 핵심은 반환 타입의 변화입니다.
// useQuery: data는 User | undefined
const { data } = useQuery<User>({ queryKey: ['user', id], queryFn: ... });
// → data.name 접근 전에 undefined 체크 필요
// useSuspenseQuery: data는 항상 User
const { data } = useSuspenseQuery<User>({ queryKey: ['user', id], queryFn: ... });
// → data.name 바로 접근 가능 — 컴파일러와 런타임 모두 안전핵심 동작: 로딩 중에는 컴포넌트 자체가 렌더링되지 않고 가장 가까운
Suspense경계가fallback을 보여줍니다. 컴포넌트가 실행될 시점에는 데이터가 이미 준비된 상태이므로undefined가 될 수 없습니다.
가장 마음에 드는 부분은 TypeScript strict 모드에서도 경고 없이 data.name을 바로 쓸 수 있다는 점입니다. 컴파일러와 런타임이 같은 보장을 하는 상황이 이렇게 깔끔하게 만들어지는 게 인상적이었습니다.
useSuspenseQuery와 throwOnError의 차이
useQuery에서도 throwOnError: true 옵션을 주면 에러를 ErrorBoundary로 전파할 수 있습니다. 두 방식이 혼동될 수 있는데, 차이는 명확합니다.
// throwOnError: true를 사용하는 경우
const { data, isLoading } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
throwOnError: true,
});
// data는 여전히 User | undefined
// isLoading 분기와 undefined 체크가 여전히 필요합니다throwOnError: true는 에러만 ErrorBoundary로 보내줄 뿐, data가 T로 좁혀지지 않고 isLoading 분기도 그대로 남아 있습니다. useSuspenseQuery는 로딩·에러·타입 세 가지를 한 번에 해결해주는 것이 차이점입니다.
staleTime과 Suspense의 상호작용
저도 처음에 헷갈렸던 부분인데, useSuspenseQuery도 캐시가 fresh 상태라면 Suspend하지 않습니다. 예를 들어 staleTime: Infinity를 설정해두면 캐시에 데이터가 있을 때 로딩 화면 없이 바로 렌더링됩니다. "왜 staleTime을 높였더니 로딩 스켈레톤이 안 뜨지?"라고 의아하다면 이 동작 때문일 수 있습니다. 의도한 동작인지 확인 후 staleTime 값을 조정하시면 됩니다.
enabled 옵션 대안: skipToken
useSuspenseQuery는 enabled: false를 지원하지 않습니다. 가장 단순한 방법은 컴포넌트를 조건부로 렌더링하는 것(isLoggedIn && <UserProfile />)이지만, v5에서는 skipToken을 공식 지원합니다. prop drilling이 깊거나 컴포넌트 마운트/언마운트 비용이 있는 상황에서 더 실용적인 선택입니다.
import { skipToken, useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ id }: { id: string | null }) {
const { data } = useSuspenseQuery({
queryKey: ['user', id],
queryFn: id ? () => fetchUser(id) : skipToken, // id가 없으면 쿼리 skip
});
return <div>{data.name}</div>;
}QueryErrorResetBoundary — 에러 후 재시도
에러가 발생했을 때 '재시도' 버튼을 구현하려면 QueryErrorResetBoundary가 필요합니다. ErrorBoundary가 리셋될 때 경계 내의 쿼리 에러 상태도 함께 초기화해주는 역할입니다.
이게 없으면 ErrorBoundary를 리셋해도 쿼리 에러 상태가 그대로 남아 있어서 즉시 다시 에러 화면으로 돌아오는 문제가 생깁니다. 실무에서 재시도 버튼을 만들었는데 눌러도 에러 화면이 사라지지 않는다면 이 컴포넌트가 빠진 것일 가능성이 높습니다.
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';
function UserProfilePage({ id }: { id: string }) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
FallbackComponent={({ error, resetErrorBoundary }) => (
<div>
<p>오류가 발생했어요: {error.message}</p>
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
)}
>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile id={id} />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
react-error-boundary: React의 ErrorBoundary는 현재까지 클래스형 컴포넌트로만 네이티브 구현이 가능합니다.react-error-boundary라이브러리는 이를 함수형 컴포넌트 친화적으로 래핑해주며,FallbackComponent,resetKeys,onReset등의 편의 API를 제공합니다.
실전 적용
예시 1: 기본 상태 분리 패턴
실무에서 가장 자주 쓰이는 기본 구성입니다. 재사용 가능한 ErrorFallback 컴포넌트를 하나 만들어두면 여러 곳에서 바로 활용할 수 있습니다.
import { useSuspenseQuery, QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';
// 재사용 가능한 에러 폴백 컴포넌트
function ErrorFallback({
error,
resetErrorBoundary,
}: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div role="alert" className="error-container">
<p>오류가 발생했어요: {error.message}</p>
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
);
}
// ✅ 성공 상태만 집중하는 컴포넌트
function UserProfile({ id }: { id: string }) {
const { data } = useSuspenseQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});
return (
<div className="profile">
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
// ✅ 로딩·에러 경계를 선언하는 상위 컴포넌트
function UserProfilePage({ id }: { id: string }) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile id={id} />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}| 코드 부분 | 역할 |
|---|---|
UserProfile |
성공 상태 UI만 담당, isLoading/isError 분기 없음 |
Suspense fallback |
데이터 로딩 중 보여줄 스켈레톤/스피너 |
ErrorBoundary |
쿼리 실패 시 에러 화면과 재시도 처리 |
QueryErrorResetBoundary |
재시도 시 쿼리 에러 상태 초기화 동기화 |
예시 2: 중첩 Suspense로 독립적 로딩 UI 구현
처음 대시보드를 만들 때 피드 데이터가 느린 탓에 헤더까지 스켈레톤에 가려진 걸 보고 경계를 쪼개기 시작했습니다. 실무에서 자주 맞닥뜨리는 상황인데, Suspense 경계를 영역별로 나눠주면 한 영역이 로딩 중이어도 다른 영역은 이미 표시된 상태를 유지할 수 있습니다.
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';
function Dashboard() {
return (
<div className="dashboard">
{/* 헤더는 독립적으로 로딩·에러 처리 */}
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
<div className="grid grid-cols-2 gap-4">
{/* 통계와 피드는 서로 독립적 */}
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</div>
</div>
);
}만약 모든 걸 하나의 Suspense로 감싸면, 피드 데이터가 늦게 도착하는 동안 이미 로딩 완료된 헤더와 통계도 스켈레톤으로 덮여버립니다. 경계를 세분화하는 것만으로 UX가 크게 달라지는 걸 체감할 수 있습니다.
QueryErrorResetBoundary + ErrorBoundary + Suspense조합이 반복되어 코드가 길어진다면,Suspensive라이브러리의<SuspenseQuery />컴포넌트나 커스텀 래퍼로 추상화하는 것도 좋은 방법입니다.
예시 3: 병렬 쿼리 — useSuspenseQueries
하나의 컴포넌트에서 여러 데이터를 동시에 가져와야 할 때는 useSuspenseQueries를 활용할 수 있습니다. 두 쿼리가 병렬로 실행되고, 둘 다 완료되어야 컴포넌트가 렌더링됩니다.
import { useSuspenseQueries } from '@tanstack/react-query';
function ProductDetail({ id }: { id: string }) {
const [{ data: product }, { data: reviews }] = useSuspenseQueries({
queries: [
{
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
},
{
queryKey: ['reviews', id],
queryFn: () => fetchReviews(id),
},
],
});
// product, reviews 모두 항상 정의된 값
return (
<div>
<h1>{product.title}</h1>
<p>{product.description}</p>
<ReviewList reviews={reviews} />
</div>
);
}무한 스크롤 구현이 필요한 상황이라면 useSuspenseInfiniteQuery도 같은 방식으로 활용할 수 있습니다. 반환 타입 보장과 Suspense 연동 방식은 동일합니다.
예시 4: Next.js App Router에서 스트리밍 SSR
Next.js 13+ App Router를 사용하고 있다면, loading.tsx와 error.tsx 파일 컨벤션이 바로 이 패턴을 프레임워크 수준에서 채택한 것입니다. 직접 Suspense를 써서 더 세밀한 제어도 가능합니다.
// app/users/[id]/page.tsx (Next.js 14 기준)
import { Suspense } from 'react';
import { UserDetail } from './UserDetail';
import { UserSkeleton } from './UserSkeleton';
export default function UserPage({
params,
}: {
// Next.js 15+에서는 params가 Promise<{ id: string }>로 변경됨
// 사용 중인 Next.js 버전을 반드시 확인해보시는 것이 좋습니다
params: { id: string };
}) {
return (
// HTML 스트리밍: Suspense 경계 단위로 청크가 전송됨
<Suspense fallback={<UserSkeleton />}>
<UserDetail id={params.id} />
</Suspense>
);
}Render-as-You-Fetch: 기존의 Fetch-on-Render(렌더링 후 fetch 시작) 방식과 달리, 서버에서 미리
prefetchQuery를 실행해두고 클라이언트에서 Suspense로 받는 패턴입니다. fetch와 렌더링이 병렬로 진행되어 초기 로딩 시간을 단축할 수 있습니다. 이 패턴의 실전 적용은 다음 글에서 자세히 다룰 예정입니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 관심사 분리 | 성공 컴포넌트는 "데이터가 있을 때"만 집중, 상태 분기 로직 불필요 |
| 타입 안전성 | data가 T | undefined에서 T로 확정되어 방어 코드 제거 |
| 코드 간소화 | if (isLoading), if (isError) 분기가 컴포넌트에서 사라짐 |
| 재사용성 | ErrorBoundary·Suspense 경계를 컴포지션으로 자유롭게 조합 가능 |
| 버그 감소 | 로딩 중에 data를 접근하는 실수 등 상태 동기화 오류 원천 차단 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
enabled 옵션 미지원 |
useSuspenseQuery에서 enabled: false 불가 |
skipToken 사용(v5 공식 지원) 또는 조건부 렌더링으로 컴포넌트 분리 |
| 스테일 데이터의 에러 처리 | 캐시 데이터 존재 시 refetch 실패해도 ErrorBoundary 미전파 | result.isStale && result.error일 때 직접 throw하는 패턴 구현 |
staleTime과의 상호작용 |
fresh 캐시가 있으면 Suspend하지 않아 로딩 화면이 표시되지 않음 | 의도한 동작인지 확인 후 staleTime 값 조정 |
| 경계 범위 과대 설정 | 너무 큰 단위의 경계는 일부 실패가 전체 UI를 숨김 | 영역별로 세분화해 독립적 실패/로딩 처리 |
| 서버 컴포넌트 혼용 | Next.js 서버 컴포넌트에서 TanStack Query 훅 직접 사용 불가 | 클라이언트 컴포넌트로 분리, 서버/클라이언트 경계 명확히 |
실무에서 가장 흔한 실수
QueryErrorResetBoundary없이 재시도 구현: ErrorBoundary를 리셋해도 쿼리 에러 상태가 남아 있어 즉시 다시 에러 화면으로 돌아오는 문제가 발생합니다. 재시도 기능을 만들 때는 반드시 함께 사용하는 것을 권장합니다.- 경계를 너무 큰 단위로 설정: 페이지 전체를 하나의 Suspense로 감싸버리면 사이드바 데이터가 느려도 본문까지 블로킹됩니다. 독립적인 영역은 독립적인 경계로 분리하는 것이 좋습니다.
- 스테일 캐시 데이터를 고려하지 않은 에러 처리: 이전에 성공한 적 있는 쿼리가 재시도 중 실패해도, 캐시된 데이터가 있으면 ErrorBoundary가 트리거되지 않습니다. 이 동작이 의도와 다르다면 명시적으로 처리 로직을 추가하는 것이 좋습니다.
마치며
useSuspenseQuery + Suspense + ErrorBoundary의 조합은 "각 컴포넌트가 자신이 맡은 하나의 상태에만 집중한다"는 단순한 원칙 위에 서 있습니다. 이 패턴이 가져오는 가장 실질적인 변화는 개인 생산성만이 아닙니다. 팀 전체가 동일한 방식으로 비동기 상태를 처리하게 되면서 코드 리뷰가 쉬워지고, 새 팀원이 컴포넌트 구조를 파악하는 시간도 크게 줄어듭니다. "이 컴포넌트는 어디서 로딩 처리를 하고 있지?"라는 질문 자체가 사라지는 경험을 해보시면 그 가치를 실감할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
- 패키지 설치 및 버전 확인:
pnpm add @tanstack/react-query@^5 react-error-boundary로 설치하고, 실습 중 Suspend 상태를 시각적으로 확인하려면pnpm add @tanstack/react-query-devtools도 함께 추가하는 것이 좋습니다.useSuspenseQuery는 v5 이상에서만 안정 API로 사용할 수 있습니다. - 기존 컴포넌트 하나를 전환해보기: 가장 단순한 컴포넌트를 골라
useQuery→useSuspenseQuery로 바꾸고, 상위에Suspense와ErrorBoundary를 추가해보시면 됩니다. 컴포넌트 내부의isLoading/isError분기가 사라지는 걸 직접 확인할 수 있습니다. - 재시도 흐름 테스트: 네트워크 탭에서 요청을 강제로 실패시켜보고, ErrorBoundary의 재시도 버튼이
QueryErrorResetBoundary와 함께 정상적으로 동작하는지 확인해보시면 전체 흐름이 완성됩니다.
참고 자료
- Suspense | TanStack Query 공식 가이드
- useSuspenseQuery API 레퍼런스 | TanStack Query
- QueryErrorResetBoundary 레퍼런스 | TanStack Query
- Suspense 공식 예제 | TanStack Query
- Next.js Suspense Streaming 예제 | TanStack Query
- react-error-boundary | GitHub (bvaughn)
- Suspensive 공식 문서
- SuspenseQuery 컴포넌트 | Suspensive
- <Suspense> 공식 문서 | React
다음 글: TanStack Query의
prefetchQuery와 Next.js App Router를 결합해 Render-as-You-Fetch 패턴으로 초기 로딩을 최소화하는 스트리밍 SSR 실전 적용기