React 애플리케이션에서 데이터를 조회하다 보면 항상 마주치는 문제가 있습니다.
처음에는 대부분 아래와 같은 방식으로 구현합니다.
function TodoList() {
const { data: todos, isPending, isError } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
if (isPending) return <Loading />;
if (isError) return <Error />;
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}이 방식은 직관적이고 이해하기 쉽지만, 여기엔 문제가 있습니다.
컴포넌트의 본래 책임은 "데이터를 화면에 그리는 것"인데 로딩·에러·성공 상태까지 함께 처리하게 됩니다.
결국 성공 상태 UI가 예외 처리 코드 사이에 묻히고, 컴포넌트의 관심사가 뒤섞이게 됩니다.
이 문제를 해결하기 위해 등장한 개념이 Suspense와 ErrorBoundary입니다.
ErrorBoundary는 에러를 잡습니다. 그럼 Suspense는 무엇을 잡는 걸까요? 정답은 Promise입니다.
Suspense는 하위 컴포넌트가 비동기 작업 때문에 멈추는 '중단 상태’를 감지하는 역할을 합니다.
예를 들어 서버 컴포넌트에서 다음과 같은 코드가 있다고 가정해 보겠습니다.
async function TodoList() {
const response = await fetch('/api/todos');
const todos = await response.json();
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}React 입장에서는 await fetch()가 끝나기 전까지 컴포넌트를 완성할 수 없습니다.
따라서 해당 컴포넌트는 일시적으로 중단 상태가 됩니다.
이때 가장 가까운 Suspense가 이를 감지하고 fallback UI를 렌더링합니다.
<Suspense fallback={<Loading />}>
<TodoList />
</Suspense>즉 Suspense는 로딩 상태를 감지하는 것이 아니라, Promise가 해결되지 않은 상태를 감지하는 것입니다.
ErrorBoundary는 Promise가 아니라 Error를 감지합니다.
예를 들어 다음과 같은 코드가 있다고 가정해 보겠습니다.
function TodoList() {
throw new Error('할 일 목록을 불러올 수 없습니다.');
return <div>Todo List</div>;
}이 에러는 상위 컴포넌트로 전파되고, 가장 가까운 ErrorBoundary가 이를 포착합니다.
<ErrorBoundary fallback={<ErrorPage />}>
<TodoList />
</ErrorBoundary>그 결과 React 애플리케이션 전체가 크래시되는 대신 fallback UI가 렌더링됩니다.
한 가지 짚고 넘어가면,<ErrorBoundary>는 React 내장 컴포넌트가 아닙니다.getDerivedStateFromError를 가진 클래스 컴포넌트로 직접 만들거나react-error-boundary같은 라이브러리를 사용해야 합니다. (Next.js App Router에서는error.tsx파일 규칙이나unstable_catchError로 제공됩니다.)
여기서 중요한 포인트가 있습니다.
ErrorBoundary는 렌더링 도중 throw된 에러만 잡습니다. 기준은 "비동기냐 아니냐"가 아니라 "렌더 단계에서 throw되느냐"입니다.
이 기준으로 보면 앞에서 본 서버 컴포넌트의 await fetch()는 React가 렌더의 일부로 다루기 때문에, 그 요청이 실패하면 에러가 ErrorBoundary까지 전파됩니다.
반면 클라이언트 컴포넌트에서 useQuery로 데이터를 가져오는 경우는 다릅니다.
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });queryFn은 React 렌더 바깥(TanStack Query가 관리하는 비동기 작업)에서 실행됩니다. 따라서 거기서 발생한 reject는 렌더 단계의 throw가 아니므로 ErrorBoundary가 감지하지 못합니다.
그래서 TanStack Query는 throwOnError 옵션을 제공합니다.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
throwOnError: true,
});이 옵션을 사용하면 Query가 에러를 렌더 시점에 다시 throw해 줍니다. 그 결과 ErrorBoundary가 정상적으로 포착할 수 있게 됩니다.
TanStack Query v4에서는 useQuery와 Suspense를 함께 사용하고 싶을 때, 다음과 같이 사용했습니다.
useQuery({
suspense: true,
});하지만 v5에서는 이 방식이 제거되었습니다. 대신 전용 훅 useSuspenseQuery를 사용합니다.
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});이 훅은 다음을 보장합니다.
즉 컴포넌트 내부 코드가 매우 단순해집니다.
function TodoList() {
const { data: todos } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}하지만 useSuspenseQuery에는 한 가지 제약이 있습니다.
useSuspenseQuery({
enabled: isLoggedIn, // ❌ 지원되지 않음
});이런 방식은 불가능합니다.
useSuspenseQuery는 데이터가 필요하면 즉시 Suspense 흐름에 진입해야 합니다.
반면 enabled는 "조건이 만족될 때까지 쿼리를 시작하지 않는다"는 의미입니다.
그래서 두 개념이 충돌하며, useSuspenseQuery에서는 enabled를 지원하지 않습니다.
그래서 저는 조건부 패칭을 컴포넌트 레벨에서 해결했습니다.
<When condition={isLoggedIn} fallback={<div>로그인이 필요합니다</div>}>
<ErrorBoundary fallback={<div>ErrorBoundary</div>}>
<Suspense fallback={<div>Suspense</div>}>
<TodoList />
</Suspense>
</ErrorBoundary>
</When>When 컴포넌트는 매우 단순합니다.
function When({ condition, fallback, children }) {
if (!condition) {
return fallback;
}
return children;
}조건이 만족되지 않으면 children 컴포넌트인 TodoList 자체를 렌더링하지 않습니다.
React 훅은 컴포넌트가 렌더(마운트)될 때만 실행되므로, 컴포넌트를 렌더 트리에서 빼면 그 안의 useSuspenseQuery도 호출되지 않습니다. 즉 enabled: false와 유사한 효과를 얻습니다.
이 구조를 사용하면 상태가 계층적으로 분리됩니다.
When
├─ 조건 미충족 (isLoggedIn)
│ └─ 로그인이 필요합니다
└─ 조건 충족
↓
ErrorBoundary
↓
Suspense
↓
Success UI각 계층은 자신의 역할만 수행하고, 컴포넌트는 오직 성공 상태만 신경 쓰면 됩니다.
Suspense와 ErrorBoundary의 핵심은 생각보다 단순합니다.
TanStack Query의 useSuspenseQuery는 이 두 메커니즘을 활용해 컴포넌트에서 로딩과 에러 상태를 제거하고 성공 상태에만 집중할 수 있게 만들어 줍니다.
처음에는 isPending, isError 기반으로 구현하는 것이 더 익숙하고 직관적일 수 있습니다.
하지만 프로젝트 규모가 커질수록 로딩, 에러, 성공 상태를 Boundary 단위로 분리하는 방식이 훨씬 선언적이고 유지보수하기 쉬운 구조가 된다는 점을 경험할 수 있었습니다!