React Router + React Query: 데이터 로딩과 에러 처리 전략 완벽 가이드

양정규·2025년 4월 4일
1
post-thumbnail

React 프로젝트에서 라우팅과 데이터 패칭을 다룰 때,
react-router-dom@tanstack/react-query(구: React Query)를 조합하면
🔥 강력하면서도 유연한 데이터 흐름을 만들 수 있습니다.

하지만 동시에 이런 질문들이 생기죠:

  • loader와 React Query는 언제 어떤 기준으로 써야 하지?
  • 에러는 어디서 어떻게 잡지? Suspense랑 ErrorBoundary는 어떻게 엮어야 해?
  • 캐싱은 누가, 어디서 해주는 거야?

이 포스팅에서는 이 모든 흐름을 실전 기준으로 정리해봅니다.


📍 loader vs React Query: 언제 어떤 걸 써야 할까?

항목loaderReact Query
실행 시점페이지 진입 전에 실행됨 (라우팅 전)컴포넌트 렌더 후 실행
캐싱❌ 없음✅ 있음 (staleTime, cacheTime)
UX❌ 재방문 시에도 fetch✅ 캐시로 빠르게 표시 가능
용도인증/404 등 라우팅 결정이 필요한 경우대부분의 데이터 패칭은 이걸로 충분

✅ 3가지 케이스 요약표

케이스설명구성 요소
🔐 인증/404 판단페이지 접근을 사전 차단하고 싶을 때loader + errorElement
🔁 일반 리스트빠른 렌더, 캐싱 중심 UXReact Query 단독
🚀 빠른 진입 + 캐싱진입 속도 + 캐시 동시 잡고 싶을 때loader + React Query + hydrate

1. 🔐 인증, 404 판단 → loader + errorElement

✔️ 목적

  • 로그인하지 않은 사용자는 로그인 페이지로 리다이렉트
  • 존재하지 않는 ID면 404 페이지 표시
  • 데이터는 컴포넌트에서 따로 fetch

💡 라우터 코드

<Route
  path="/admin/posts/:id"
  loader={protectedPostLoader}
  element={<AdminPost />}
  errorElement={<ErrorPage />}
/>

💡 loader 예시

// protectedPostLoader.ts
export async function protectedPostLoader({ params }) {
  const user = await checkAuth();
  if (!user) {
    return redirect('/login'); // 🔐 인증 체크
  }

  const res = await fetch(`/api/posts/${params.id}`);
  if (res.status === 404) {
    throw new Response('Not Found', { status: 404 }); // ❗ 존재하지 않으면 404
  }

  return null;
}

💡 컴포넌트 예시

export default function AdminPost() {
  const { id } = useParams();

  const { data} = useQuery({
    queryKey: ['post', id],
    queryFn: () => fetchPost(id), // ✅ 실제 데이터 요청은 여기서
    suspense: false,
    useErrorBoundary: false,
  });

  return (
    <div>
      <h1>{data.title}</h1>
      <p>{data.content}</p>
    </div>
  );
}

2. 🔁 일반 페이지 → React Query 단독

✔️ 목적

  • 필터링, 탭, 리스트 페이지
  • 인증, 404 등의 판단 불필요
  • 빠른 캐싱과 상태 관리가 목적

💡 라우터 코드

<Route
  path="/products"
  element={
    <ErrorBoundary fallback={<ErrorPage />}>
      <Suspense fallback={<Loading />}>
        <ProductList />
      </Suspense>
    </ErrorBoundary>
  }
/>

💡 컴포넌트 예시

function ProductList() {
  const { data } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
    suspense: true,
    useErrorBoundary: true,
    staleTime: 60_000, // 1분 캐시
  });

  return (
    <ul>
      {data.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

3. 🚀 빠른 퍼스트 로딩 + 캐싱 → loader + React Query 조합

✔️ 목적

  • 페이지 진입 전에 loader로 먼저 fetch
  • 컴포넌트는 React Query 캐시 재사용
  • UX + 퍼포먼스 균형

💡 라우터 코드

<Route
  path="/posts"
  loader={postsLoader}
  element={
    <ErrorBoundary fallback={<ErrorPage />}>
      <Suspense fallback={<Loading />}>
      	<PostsRoute />
      </Suspense>
    </ErrorBoundary>
  }
  errorElement={<ErrorPage />}
/>

💡 loader

export async function postsLoader() {
  const queryClient = new QueryClient();

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    staleTime: 10_000,
  });

  return {
    dehydratedState: dehydrate(queryClient),
  };
}

💡 컴포넌트

function PostsRoute() {
  const { dehydratedState } = useLoaderData();
  const queryClient = new QueryClient();

  return (
    <QueryClientProvider client={queryClient}>
      <Hydrate state={dehydratedState}>
        <Suspense fallback={<Loading />}>
          <Posts />
        </Suspense>
      </Hydrate>
    </QueryClientProvider>
  );
}

💡 Posts.tsx

function Posts() {
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    suspense: true,
    useErrorBoundary: true,
    staleTime: 10_000,
  });

  return <ul>{data.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

⚠️ loader-only의 단점

loader는 매번 페이지 이동 시마다 fetch 요청을 다시 보냅니다.
즉, 같은 페이지를 왔다 갔다 해도 캐시 재사용 없이 계속 로딩됩니다.

→ 사용자 입장에선 UI가 깜빡이고 느려지는 경험
→ 서버 입장에선 불필요한 요청 증가


🧱 에러 처리 구조: 에러는 어디서 어떻게 처리하나?

에러 위치처리 방법
loader 내부throw new Response()errorElement + useRouteError()
useQuery 내부useErrorBoundary: trueErrorBoundary에서 catch
로딩 중suspense: true<Suspense fallback={...} />

📦 loader에서 발생한 에러 예시

export async function postsLoader() {
  const res = await fetch('/api/posts');

  if (!res.ok) {
    throw new Response('Not Found', { status: 404 });
  }

  return res.json();
}
// ErrorPage.tsx
const error = useRouteError();
if (isRouteErrorResponse(error)) {
  return <p>{error.status} - {error.statusText}</p>;
}

⚠️ errorElement를 쓸 땐 loader가 반드시 있어야 함!

// 잘못된 구조 ❌ (loader 없음 + errorElement 있음 → 작동안함)
<Route
  path="/posts"
  element={<Posts />}
  errorElement={<ErrorPage />} // 작동 안 함!
/>
// 올바른 구조 ✅
<Route
  path="/posts"
  loader={postsLoader}
  element={<Posts />}
  errorElement={<ErrorPage />}
 />

✅ staleTime은 얼마나 주는 게 좋을까?

staleTime: 10000 // 10초 동안 "fresh"로 간주 → fetch 생략

상황별 추천 기준

데이터 성격staleTime
거의 바뀌지 않는 공지사항, 목록1~10분
채팅, 알림처럼 자주 바뀌는 데이터1~5초
상품 상세처럼 정적인 콘텐츠Infinity
그냥 무난하게 UX/성능 잡고 싶을 때10초

🎯 최종 추천 전략 요약

상황추천 방식
로그인, 권한 체크loader + redirect()
존재하지 않는 리소스 (404)loader + throw new Response()
캐시 활용하고 싶은 리스트React Query (staleTime)
빠른 첫 렌더 + 캐시까지loader + React Query 조합
에러 처리 (fetch)useErrorBoundary: true + ErrorBoundary
에러 처리 (라우팅)loader + errorElement + useRouteError()

✅ loader + React Query 조합

페이지 유형설명
초기 진입 성능이 중요한 페이지홈, 대시보드, 인기 콘텐츠 등
재방문 가능성이 높은 리스트 페이지게시글, 검색결과 등
사용자 맞춤 데이터를 처음부터 보여줘야 하는 페이지마이페이지, 내 활동, 내 문서
SSR UX를 CSR로 구현하고 싶은 페이지성능 + 캐시 다 챙겨야 할 때

✅ 반대로 React Query만 쓰는 게 나은 페이지

페이지 유형이유
무한 스크롤, 필터가 많음loader로는 처리하기 불편함
실시간 데이터가 자주 바뀜매번 loader가 fetch하는 건 비효율
URL이 바뀌지 않는 탭 기반 페이지loader는 작동안 함, useQuery만 가능

✅ 마무리

loader는 라우팅 제어에,
React Query는 데이터 캐싱과 UX 최적화에 집중!

너무 많이 쓰기보다는, 필요한 곳에만 loader를 정확히 사용하는 게 진짜 최적화입니다.

profile
롤보다 개발이 재밌는 프론트엔드 개발자입니다 :D

0개의 댓글