React Query + ErrorBoundary

Wonhyo LEE·2025년 9월 18일
0

0) 필요한 라이브러리

npm i @tanstack/react-query @tanstack/react-query-devtools react-error-boundary

1) 공통 셋업 (Provider + 전역 기본옵션)

포인트

  • queries.suspense: true → 데이터 없으면 자동으로 throw해서 Suspense에 걸림
  • queries.useErrorBoundary: “이 에러는 경계로 올릴지?” 분기(예: 5xx만 경계로)
  • QueryErrorResetBoundary와 궁합이 좋아서, “다시 시도”가 깔끔하게 동작

1-1) 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>
  );
}

2) 범용 경계 컴포넌트 (클라이언트 위젯용)

페이지 전체는 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>
  );
}

3) 서버/클라이언트 연동 (SSR 프리패치 + 하이드레이션)

App Router에서 서버 컴포넌트는 데이터를 직접 await할 수도 있지만,
React Query로 일관하려면 서버에서 prefetch → dehydrate → 클라에서 HydrationBoundary 패턴을 쓴다.

3-1) 공통 쿼리키 & API

// 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();
}

3-2) 서버에서 프리패치(Dehydrate)

// 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>
  );
}

3-3) 클라에서 소비 (Suspense + ErrorBoundary 자동 적용)

// 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>
  );
}

4) 라우트 세그먼트 레벨 경계 (Next 전용)

세그먼트 기준으로 스트리밍 로딩/에러/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/태그 무효화 전략을 병행하세요.


5) “각각의 처리” 가이드 (실전 분기)

A. 4xx(클라이언트 책임)

  • 전역 useErrorBoundary에서 제외 (위 예시처럼 5xx만 바운더리)
  • 폼 검증 실패, 권한 없음(401/403), 리소스 없음(404)은 화면에서 토스트/문구로 처리
  • 404를 페이지 레벨로 보내고 싶으면 서버에서 notFound() 혹은 에러에 status=404를 달아 세그먼트 not-found.tsx로 유도

B. 5xx/네트워크

  • 에러 바운더리로 올려 사용자에게 “다시 시도” 버튼 제공
  • QueryErrorResetBoundary + ErrorBoundary 조합으로 리트라이 시 RQ의 에러 상태 초기화가 자동으로 맞물림
  • retry, retryDelay로 자동 재시도 정책(지수 백오프 등) 설정

C. 뮤테이션

// 예: 가격 갱신
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 유지(권장)
});

6) 검증 체크리스트

  • React Query Devtools에서 쿼리 상태가 suspended/error/fresh로 기대대로 바뀌는지
  • Network 탭: 캐시가 있을 땐 즉시 렌더 + 요청 없음, reset()/재진입 시 정책대로 다시 나가는지
  • 404/500 각각에서 **not-found.tsx**와 error.tsx/부분 경계가 정확히 뜨는지
  • 4xx는 경계로 안 올리고 화면 내 안내로 끝나는지

7) 흔한 함정

  • error.tsx'use client' 누락 → 세그먼트 에러 바운더리 미동작
  • 전역 queries.suspense: true인데 Suspense로 감싸지 않은 위젯이 있으면 렌더 중 throw → 상위(세그먼트) loading.tsx가 받긴 하지만, 부분 로딩 UX가 망가짐 → 부분 위젯엔 AsyncBoundary로 감싸기
  • reset() 눌러도 요청이 안 나감 → 서버 fetch가 캐시 응답(기본값)이라서. cache: 'no-store' 또는 revalidate/태그 무효화 설계 필요
  • 클라이언트 이벤트 핸들러 내부 예외는 에러 바운더리로 안 잡힘try/catch 또는 별도 핸들러 처리

profile
프론트마스터를 꿈꾸는...

0개의 댓글