npm i @tanstack/react-query @tanstack/react-query-devtools react-error-boundary
포인트
- queries.suspense: true → 데이터 없으면 자동으로
throw
해서 Suspense에 걸림- queries.useErrorBoundary: “이 에러는 경계로 올릴지?” 분기(예: 5xx만 경계로)
- QueryErrorResetBoundary와 궁합이 좋아서, “다시 시도”가 깔끔하게 동작
app/providers.tsx
(전역 Provider)'use client';
import { PropsWithChildren, useState } from 'react';
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// 5xx는 에러바운더리로, 4xx는 화면 내 처리(토스트/문구) 예시
function shouldUseBoundary(error: any) {
const status = error?.status ?? error?.response?.status;
if (typeof status === 'number') {
return status >= 500;
}
// status 없으면 네트워크/예상치 못한 오류 → 바운더리로
return true;
}
export default function Providers({ children }: PropsWithChildren) {
const [client] = useState(() => new QueryClient({
defaultOptions: {
queries: {
suspense: true, // 전역 Suspense
useErrorBoundary: shouldUseBoundary,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
retry: 2, // 5xx 등 재시도
refetchOnWindowFocus: false,
},
mutations: {
// 입력 검증/권한 오류(4xx)는 보통 화면에서 안내하므로 경계까지는 X
useErrorBoundary: false,
retry: 0,
},
},
}));
return (
<QueryClientProvider client={client}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
App Router에서는
app/layout.tsx
안에서Providers
를 감싸주면 전역 적용 끝.
// app/layout.tsx
import Providers from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
페이지 전체는 Next의 loading.tsx / error.tsx
로 커버하되,
부분 위젯에선 아래 AsyncBoundary
로 미세 제어하면 좋아.
// components/boundaries/AsyncBoundary.tsx
'use client';
import { ReactNode, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
type Props = {
children: ReactNode;
pendingFallback?: ReactNode; // 로딩 스피너 등
errorFallback?: (args: { error: Error; reset: () => void }) => ReactNode;
};
export default function AsyncBoundary({
children,
pendingFallback = <div>불러오는 중…</div>,
errorFallback,
}: Props) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) =>
errorFallback
? errorFallback({ error, reset: resetErrorBoundary })
: (
<div style={{ padding: 12 }}>
<p>문제가 발생했습니다: {error.message}</p>
<button onClick={() => resetErrorBoundary()}>다시 시도</button>
</div>
)
}
>
<Suspense fallback={pendingFallback}>{children}</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
App Router에서 서버 컴포넌트는 데이터를 직접 await
할 수도 있지만,
React Query로 일관하려면 서버에서 prefetch → dehydrate → 클라에서 HydrationBoundary 패턴을 쓴다.
// query/keys.ts
export const qk = {
product: {
detail: (id: string | number) => ['product', 'detail', String(id)] as const,
},
};
// libs/api.ts
export async function fetchProduct(id: string) {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_HOST}/products/${id}`, {
// 정말 매번 새로 받고 싶으면:
cache: 'no-store',
});
if (res.status === 404) {
// 404는 페이지 레벨 not-found로 보낼 수도 있음(선호에 따라)
// throw Object.assign(new Error('NOT_FOUND'), { status: 404 });
}
if (!res.ok) {
const err = new Error('상품 조회 실패');
// 서버/클라 공통 에러 정책 위해 status 넣어줌
(err as any).status = res.status;
throw err;
}
return res.json();
}
// app/(shop)/products/[id]/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { qk } from '@/query/keys';
import { fetchProduct } from '@/libs/api';
import ProductClient from './ProductClient'; // 클라 컴포넌트
export default async function Page({ params }: { params: { id: string } }) {
const qc = new QueryClient();
await qc.prefetchQuery({
queryKey: qk.product.detail(params.id),
queryFn: () => fetchProduct(params.id),
});
const state = dehydrate(qc);
return (
<HydrationBoundary state={state}>
<ProductClient id={params.id} />
</HydrationBoundary>
);
}
// app/(shop)/products/[id]/ProductClient.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { qk } from '@/query/keys';
import { fetchProduct } from '@/libs/api';
import AsyncBoundary from '@/components/boundaries/AsyncBoundary';
export default function ProductClient({ id }: { id: string }) {
// 전역으로 suspense=true, useErrorBoundary=shouldUseBoundary 이므로 여기선 간단
const Detail = () => {
const { data } = useQuery({
queryKey: qk.product.detail(id),
queryFn: () => fetchProduct(id),
// 필요 시 개별 오버라이드 가능:
// suspense: true,
// useErrorBoundary: (err) => ...
});
return (
<main>
<h1>{data.name}</h1>
<p>₩{data.price.toLocaleString('ko-KR')}</p>
</main>
);
};
return (
<AsyncBoundary
pendingFallback={<div>상품 정보를 불러오는 중…</div>}
// 4xx는 토스트/가이드, 5xx는 재시도 유도 같은 커스텀 UI도 가능
errorFallback={({ error, reset }) => (
<div>
<p>불러오기에 실패했습니다. {error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
)}
>
<Detail />
</AsyncBoundary>
);
}
세그먼트 기준으로 스트리밍 로딩/에러/404를 공짜로 얻는다.
(페이지 전체가 느려지거나 죽는 상황을 막음)
// app/(shop)/products/[id]/loading.tsx
export default function Loading() {
return <div>페이지 로딩 중…</div>;
}
// app/(shop)/products/[id]/error.tsx
'use client';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<h2>페이지에서 오류가 발생했어요.</h2>
<p>{error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}
// app/(shop)/products/[id]/not-found.tsx
export default function NotFound() {
return <div>상품을 찾을 수 없습니다.</div>;
}
팁:
reset()
은 해당 세그먼트 subtree를 재실행(재요청)시켜요.
다만 서버fetch
가 기본 캐시(force-cache
)면 캐시 적중으로 네트워크 요청이 안 나갈 수 있음 → **cache: 'no-store'
**나 revalidate/태그 무효화 전략을 병행하세요.
useErrorBoundary
에서 제외 (위 예시처럼 5xx만 바운더리)notFound()
혹은 에러에 status=404를 달아 세그먼트 not-found.tsx
로 유도QueryErrorResetBoundary
+ ErrorBoundary
조합으로 리트라이 시 RQ의 에러 상태 초기화가 자동으로 맞물림retry
, retryDelay
로 자동 재시도 정책(지수 백오프 등) 설정// 예: 가격 갱신
const mutation = useMutation({
mutationFn: (patch: { id: string; price: number }) => api.patch(`/products/${patch.id}`, patch),
onError: (err: any) => {
// 4xx → 필드 에러/토스트, 5xx → 전역 토스트
},
onSuccess: (_, vars) => {
queryClient.invalidateQueries({ queryKey: qk.product.detail(vars.id) });
},
// mutations.useErrorBoundary는 전역 false 유지(권장)
});
suspended
/error
/fresh
로 기대대로 바뀌는지reset()
/재진입 시 정책대로 다시 나가는지not-found.tsx
**와 error.tsx
/부분 경계가 정확히 뜨는지error.tsx
에 'use client'
누락 → 세그먼트 에러 바운더리 미동작queries.suspense: true
인데 Suspense로 감싸지 않은 위젯이 있으면 렌더 중 throw → 상위(세그먼트) loading.tsx
가 받긴 하지만, 부분 로딩 UX가 망가짐 → 부분 위젯엔 AsyncBoundary
로 감싸기reset()
눌러도 요청이 안 나감 → 서버 fetch가 캐시 응답(기본값)이라서. cache: 'no-store'
또는 revalidate/태그 무효화 설계 필요try/catch
또는 별도 핸들러 처리