Back to blog
May 27, 2026
8 min read

[번역] TanStack Router and Query

TanStack Router에는 내장 캐시가 있는데, 왜 TanStack Query가 필요할까요? 물론 이유가 있습니다...

원문 : https://tkdodo.eu/blog/tan-stack-router-and-query

TanStack Router가 TanStack Query와 잘 통합된다는 것은 놀라운 일이 아닙니다. 결국 같은 스택에서 나왔으니까요. 그런데 잠깐 — TanStack Router는 이미 캐싱을 지원하지 않나요?

Loader Data
export const Route = createFileRoute('/dashboard/$dashboardId')({
  component: Dashboard,
  staleTime: 10_000,
  loader: ({ params }) => fetchDashboard(params.dashboardId),
});
 
function Dashboard() {
  const dashboard = Route.useLoaderData();
  return <h1>{dashboard.title}</h1>;
}

이제 Route.useLoaderData를 통해 dashboard 데이터에 접근할 수 있으며, 사용자가 라우트를 벗어났다가 설정된 staleTime(10초) 내에 다시 돌아오면 캐시된 데이터를 볼 수 있습니다.

이는 라우트 특정 데이터에 대해서 훌륭하게 동작합니다. /dashboard/$dashboardId 라우트나 /dashboard/$dashboardId/widget/$widgetId 같은 자식 라우트만이 이슈 상세 데이터를 필요로 한다면, 라우터 캐시는 완벽한 선택입니다.

하지만 많은 경우 여러 라우트에 걸쳐 데이터가 필요합니다. 예를 들어 사용자 데이터를 처리할 때가 그렇습니다. 내장 라우터 캐시는 라우트별로 저장되기 때문에, 다른 라우트들은 해당 데이터에 접근할 수 없어 각자 데이터를 가져오고 캐싱해야 합니다.

반면 Query 캐시는 진정으로 전역적이며, 고유한 queryKey를 통해 모든 라우트와 라우트 로더에서 접근 가능합니다. 이것이 클라이언트 사이드 애플리케이션에서 TanStack Query가 많이 사용되는 이유 중 하나입니다.

Query와 Router 함께 사용하기

Query는 훅을 통해 컴포넌트에서 사용할 수 있고, 라우터에는 로더가 있습니다. Query를 사용한다면 로더가 왜 필요할까요? 이에 대해서는 React Query Meets React Router에서 이미 다룬 적이 있습니다. 라우터는 다르지만 개념은 동일합니다.

간단히 말하면, 라우트 로더에서 데이터 패칭을 시작하는 것은 거의 항상 좋은 아이디어입니다. 컴포넌트가 렌더링되기 전에 실행되기 때문에, 데이터를 가능한 한 일찍 사용할 수 있게 해줍니다. 심지어 컴포넌트의 JS 번들이 다운로드되고 평가되기 전에 실행될 수도 있습니다. 그리고 TanStack Router의 prefetch: 'intent' 기능 덕분에, 사용자가 링크를 클릭하기 전에도 라우트 로더를 트리거할 수 있습니다. 즉, 라우트 로더를 사용하면 “무료로” 호버 시 프리패치를 얻을 수 있습니다.

Query in Loader
export const Route = createFileRoute('/dashboard/$dashboardId')({
  loader: async ({ context, params }) => {
    await context.queryClient.ensureQueryData(dashboardQueryOptions(params.dashboardId));
  },
  component: Dashboard,
});
 
function Dashboard() {
  const params = Route.useParams();
  const { data } = useSuspenseQuery(dashboardQueryOptions(params.dashboardId));
}

라우트 로더에서 어떤 queryClient 함수를 사용해야 할까요?

로더에서 queryClient.prefetchQuery를 사용하는 버전을 본 적이 있을 겁니다. 심지어 await 없이 사용하는 경우도 있습니다. 하지만 여기서는 queryClient.ensureQueryData를 사용하고 await도 붙이고 있습니다. 왜 그럴까요?

이 질문에 대한 답은 복잡하지만, 결론적으로 말하면 로더에서 무엇을 하든 큰 차이가 없을 가능성이 높습니다! 이 명령형 패칭 함수들은 동작에서 아주 미세한 차이만 있기 때문에, 이를 단일 메서드 queryClient.query로 통합하려는 RFC가 올라와 있습니다. 차이가 궁금하다면 RFC에서 자세히 설명하고 있습니다.

여기서 라우트 로더는 단순히 Query를 트리거해서 패칭을 일찍 시작할 수 있도록 합니다. 이후 컴포넌트가 렌더링될 때, 로더에서 await를 사용했다면 캐시에 이미 데이터가 있거나, 진행 중인 Promise를 이어받게 됩니다. 어느 경우든 요청은 단 한 번만 발생하고, 가능한 한 일찍 시작됩니다.

추가적으로 몇 가지 사항을 염두에 두어야 합니다:

QueryClient를 Router Context에 추가하기

로더 같은 라우터 전용 메서드에서 queryClient에 접근하려면 이 작업이 필요합니다. 예제에서 이미 이를 처리하고 있지만, 연결하지 않으면 동작하지 않습니다:

QueryClient in Router Context
const queryClient = new QueryClient();
 
const router = createRouter({
  routeTree,
  context: {
    queryClient,
  },
});

또한 QueryClientProvider에 전달하는 것과 동일한 queryClient를 사용해야 합니다. 그렇지 않으면 같은 캐시를 공유하지 않습니다. 루트 라우트에는 createRootRoute 대신 createRootRouteWithContext를 사용해야 합니다. 이 모든 내용은 공식 문서에 잘 정리되어 있습니다.

라우터 캐싱 비활성화하기

라우터에는 내장 캐싱과 로더 실행 시점을 결정하는 자체적인 stale-while-revalidate 로직이 있습니다. TanStack Query 같은 외부 캐싱 라이브러리를 사용할 때는 이를 완전히 비활성화하는 것이 좋습니다. 캐싱을 제어하는 주체는 하나여야 하니까요.

조정이 필요한 설정은 defaultPreloadStaleTime 하나뿐입니다. 이 설정은 프리로드된 데이터가 캐시되는 시간을 결정하며, 기본값은 30s입니다. 나머지는 이미 기본값이 0입니다.

defaultPreloadStaleTime
const queryClient = new QueryClient();
 
const router = createRouter({
  routeTree,
  context: {
    queryClient,
  },
  defaultPreloadStaleTime: 0,
});

useQuery를 쓸까, useSuspenseQuery를 쓸까?

완전히 여러분의 선택이지만, 저는 라우터가 제공하는 Suspense 및 Error Boundary와 Query가 통합되는 방식이 정말 마음에 듭니다. 모든 라우트는 기본적으로 자체 boundary로 감싸져 있기 때문에, useSuspenseQuery를 호출하기만 하면 로더에서 사용하는 것과 동일한 boundary를 활용할 수 있습니다. 이는 어차피 설정해야 하는 것입니다. 덕분에 컴포넌트는 정상적인 경우에만 집중할 수 있습니다. 더 좋은 점은, 기본 boundary를 전역으로 정의하면 한 번만 설정하면 된다는 것입니다:

Default Boundaries
const queryClient = new QueryClient();
 
const router = createRouter({
  routeTree,
  context: {
    queryClient,
  },
  defaultPreloadStaleTime: 0,
  defaultPendingComponent: DefaultLoader,
  defaultErrorComponent: DefaultError,
});

로더에서 await를 쓸까, 말까

Query와 Router를 통합할 때 자주 받는 질문이라 한번 정리해보겠습니다. 대부분의 예제에서는 “블로킹” 데이터에 대해 로더에서 await를 사용합니다. Query 통합 없이 사용할 때 하는 방식이고, 그것도 괜찮습니다. 라우터는 로더가 pending 상태인 동안 pendingComponent를 보여주고, 이후에 컴포넌트를 렌더링합니다. 그래서 useLoaderData에서 오는 데이터는 항상 정의되어 있음이 보장됩니다. 지연 데이터 로딩의 경우에는, 로더에서 await 없이 Promise를 반환하고 라우터의 Await 컴포넌트를 사용합니다.

하지만 TanStack Query와 통합할 때는, 로더에서 절대 await하지 않으면 그 결정을 컴포넌트로 미룰 수 있습니다. 블로킹 데이터에는 useSuspenseQuery를, 지연 데이터에는 useQuery를 사용하면 됩니다.

Deferred and Blocking Data
export const Route = createFileRoute('/dashboard/$dashboardId')({
  loader: ({ context, params }) => {
    context.queryClient.prefetchQuery(dashboardQueryOptions(params.dashboardId));
    context.queryClient.prefetchQuery(widgetCountQueryOptions(params.dashboardId));
  },
  component: Dashboard,
});
 
function Dashboard() {
  const params = Route.useParams();
  const { data: dashboardData } = useSuspenseQuery(dashboardQueryOptions(params.dashboardId));
  const { data: widgetCountData } = useQuery(widgetCountQueryOptions(params.dashboardId));
}

저 로더를 보세요! async 함수도 아니고, awaitreturn도 없습니다. 하지만 useSuspenseQuery가 라우터의 boundary와 잘 통합되어 있기 때문에, Dashboard Query를 await한 것과 동일한 동작을 얻을 수 있습니다. 또한 동일한 컴포넌트에서 useSuspenseQuery를 여러 번 호출해도 워터폴이 발생하지 않습니다. 패칭이 이미 시작되었기 때문입니다.

타입 수준에서도 useSuspenseQuery 덕분에 dashboardData는 절대 undefined가 아니며, 논블로킹인 widgetCountnumber | undefined 타입이 됩니다. 덕분에 해당 요청이 pending 상태일 때 인라인 스켈레톤 로더를 보여주는 등의 처리를 할 수 있습니다.

SSR은 어떨까요?

TanStack Router에서 풀스택 프레임워크 TanStack Start로 업그레이드하면, 전체 문서 SSR, 스트리밍, 서버 함수, 서버 컴포넌트 등 다양한 풀스택 기능을 활용할 수 있습니다. 프레임워크 자체는 별도의 블로그 포스트로 다룰 만하지만, Query 통합 관점에서는 거의 아무것도 바뀌지 않는다는 것을 알아두면 좋습니다.

TanStack Start는 동형(isomorphic) 로더 실행 모델을 제공합니다. 즉, 로더는 SSR 시에는 서버에서, 클라이언트 네비게이션 시에는 클라이언트에서 실행됩니다. 이 모델은 TanStack Query 같은 클라이언트 사이드 캐시와 매우 잘 어울립니다.

첫 페이지 로드 시에는 서버에서 가져온 데이터가 SSR 중에 클라이언트로 스트리밍되어 QueryCache를 초기화합니다. 이후 애플리케이션은 클라이언트 사이드 전환이 이루어지는 SPA가 됩니다. 빠른 서버 렌더링 페이지 로드와 빠른 네비게이션, 두 가지 장점을 모두 누릴 수 있습니다.

또한 컴포넌트와 로더 코드는 전혀 변경할 필요가 없습니다. 유일하게 확인해야 할 것은 서버에서 가져온 데이터가 클라이언트 사이드 캐시에 올바르게 들어오는지입니다. 이를 위해 TanStack Start는 전역으로 연결할 수 있는 간단한 통합을 제공합니다:

setupRouterSsrQueryIntegration
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query';
 
const queryClient = new QueryClient();
 
const router = createRouter({
  routeTree,
  context: {
    queryClient,
  },
  defaultPreloadStaleTime: 0,
});
 
setupRouterSsrQueryIntegration({
  router,
  queryClient,
});

이 통합은 서버에서 가져온 데이터가 자동으로 직렬화(dehydrate)되어 클라이언트로 스트리밍되고, 클라이언트에서 역직렬화(hydrate)되어 클라이언트 사이드 캐시에 저장되도록 보장합니다.

한 가지 주의할 점이 있습니다. 서버가 초기 HTML을 생성하려면, 컴포넌트가 서버에서 렌더링될 때 데이터가 완전히 준비되어 있거나 React Suspense를 사용해야 합니다.

Query 통합에서 useQuery를 사용한다면, 초기 렌더링 시 데이터가 준비될 수 있도록 로더에서 await해야 합니다. 그렇지 않으면 데이터는 나중에 클라이언트 캐시에 채워지지만, 서버 렌더링된 HTML에는 컴포넌트 마크업이 포함되지 않습니다. useSuspenseQuery는 이 추가 단계가 필요 없습니다. Suspense는 스트리밍 SSR과 함께 동작하며, 데이터가 resolve되면 점진적으로 렌더링할 수 있기 때문입니다. Suspense를 사용해야 할 꽤 좋은 이유이죠. 🔥

항상 Query를 사용하세요

라우터는 이미 useLoaderData를 통해 데이터에 접근하는 방법을 제공하지만, Query와 함께 사용할 때는 권장되지 않습니다. TanStack Query는 어떤 쿼리가 활성 사용 중인지 추적하는데, 이를 위해 Query Observer가 필요합니다. Observer는 useQuery (또는 useSuspenseQuery)로 생성됩니다. 이 훅 호출 없이는 쿼리가 “비활성” 상태로 간주되어 여러 문제가 발생합니다:

  • 창 포커스나 네트워크 재연결 시 자동 재패칭이 동작하지 않습니다. 이는 쿼리가 활성 사용 중임을 전제로 하기 때문입니다.
  • Query Invalidation이 쿼리를 재패칭하지 않습니다. 이 역시 쿼리가 활성 사용 중임을 전제로 합니다 (refetchType을 변경하지 않는 한).
  • 활성 사용 중이 아닌 쿼리는 가비지 컬렉션 대상이 됩니다. “사용 중”임에도 불구하고 캐시에서 제거될 수 있습니다.

이를 알고 나면, useLoaderData에 의존하는 것은 처음에는 동작할 수 있지만 특히 가비지 컬렉션 문제로 인해 시간이 지날수록 문제가 생길 수 있습니다. 이 함정에 빠지지 않는 가장 쉬운 방법은:

로더를 이벤트 핸들러처럼 다루기

로더를 실제로 데이터를 반환하지 않고 단순히 캐시를 초기화하는 fire-and-forget 이벤트 핸들러로 취급한다면, useLoaderDataundefined를 반환하게 되어 사용하기 어렵습니다. 이는 좋은 일입니다. 왜냐하면 그것을 사용해서는 안 되기 때문입니다 — use(Suspense)Query를 사용해야 합니다. 😁

저는 이것이 라우트 로더에 대한 좋은 정신 모델이라고 생각합니다. 로더의 역할을 명확히 정의해주기 때문입니다: 사용자 상호작용(페이지로 이동하거나 그럴 의도를 보임) 후에 데이터 패칭을 시작하는 시점. 이는 페이지 로딩 속도를 높이기 위한 성능 개선으로 점진적으로 도입할 수도 있습니다. 로더가 없어도 페이지는 여전히 동작해야 합니다.


오늘은 여기까지입니다. 질문이 있다면 bluesky로 연락하거나, 아래에 댓글을 남겨주세요. ⬇️