
React 프로젝트에서 라우팅과 데이터 패칭을 다룰 때,
react-router-dom과 @tanstack/react-query(구: React Query)를 조합하면
🔥 강력하면서도 유연한 데이터 흐름을 만들 수 있습니다.
하지만 동시에 이런 질문들이 생기죠:
이 포스팅에서는 이 모든 흐름을 실전 기준으로 정리해봅니다.
| 항목 | loader | React Query |
|---|---|---|
| 실행 시점 | 페이지 진입 전에 실행됨 (라우팅 전) | 컴포넌트 렌더 후 실행 |
| 캐싱 | ❌ 없음 | ✅ 있음 (staleTime, cacheTime) |
| UX | ❌ 재방문 시에도 fetch | ✅ 캐시로 빠르게 표시 가능 |
| 용도 | 인증/404 등 라우팅 결정이 필요한 경우 | 대부분의 데이터 패칭은 이걸로 충분 |
| 케이스 | 설명 | 구성 요소 |
|---|---|---|
| 🔐 인증/404 판단 | 페이지 접근을 사전 차단하고 싶을 때 | loader + errorElement |
| 🔁 일반 리스트 | 빠른 렌더, 캐싱 중심 UX | React Query 단독 |
| 🚀 빠른 진입 + 캐싱 | 진입 속도 + 캐시 동시 잡고 싶을 때 | loader + React Query + hydrate |
loader + errorElement<Route
path="/admin/posts/:id"
loader={protectedPostLoader}
element={<AdminPost />}
errorElement={<ErrorPage />}
/>
// 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>
);
}
React Query 단독<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>
);
}
loader + React Query 조합<Route
path="/posts"
loader={postsLoader}
element={
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<Loading />}>
<PostsRoute />
</Suspense>
</ErrorBoundary>
}
errorElement={<ErrorPage />}
/>
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>
);
}
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는 매번 페이지 이동 시마다 fetch 요청을 다시 보냅니다.
즉, 같은 페이지를 왔다 갔다 해도 캐시 재사용 없이 계속 로딩됩니다.
→ 사용자 입장에선 UI가 깜빡이고 느려지는 경험
→ 서버 입장에선 불필요한 요청 증가
| 에러 위치 | 처리 방법 |
|---|---|
| loader 내부 | throw new Response() → errorElement + useRouteError() |
| useQuery 내부 | useErrorBoundary: true → ErrorBoundary에서 catch |
| 로딩 중 | suspense: true → <Suspense fallback={...} /> |
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: 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() |
| 페이지 유형 | 설명 |
|---|---|
| 초기 진입 성능이 중요한 페이지 | 홈, 대시보드, 인기 콘텐츠 등 |
| 재방문 가능성이 높은 리스트 페이지 | 게시글, 검색결과 등 |
| 사용자 맞춤 데이터를 처음부터 보여줘야 하는 페이지 | 마이페이지, 내 활동, 내 문서 |
| SSR UX를 CSR로 구현하고 싶은 페이지 | 성능 + 캐시 다 챙겨야 할 때 |
| 페이지 유형 | 이유 |
|---|---|
| 무한 스크롤, 필터가 많음 | loader로는 처리하기 불편함 |
| 실시간 데이터가 자주 바뀜 | 매번 loader가 fetch하는 건 비효율 |
| URL이 바뀌지 않는 탭 기반 페이지 | loader는 작동안 함, useQuery만 가능 |
loader는 라우팅 제어에,
React Query는 데이터 캐싱과 UX 최적화에 집중!
너무 많이 쓰기보다는, 필요한 곳에만 loader를 정확히 사용하는 게 진짜 최적화입니다.