TanStack Router + TanStack Query로 SSR 데이터 그리드 깜빡임 없애기 — loaderDeps와 staleTime으로 이중 패칭 차단하기
데이터 그리드를 만들다 보면 어느 순간 이런 상황을 마주하게 됩니다. 페이지 번호를 누를 때마다 테이블이 깜빡이고, 정렬 버튼을 눌렀더니 로딩 스피너가 돌아가고, 심지어 서버에서 열심히 prefetch한 데이터가 클라이언트에서 또 요청되는 걸 Network 탭에서 목격하게 되죠. 저도 처음엔 "이 정도면 괜찮지 않나?" 싶었는데, 실제로 사용자 입장에서 써보면 체감이 꽤 다릅니다.
이 글은 TanStack Router와 TanStack Query를 어느 정도 써본 프론트엔드 개발자를 대상으로 합니다. 두 라이브러리가 처음이라면 공식 문서를 먼저 훑어보시는 것을 권장합니다. 이 글에서는 loaderDeps와 loader를 조합해 URL search params 기반의 데이터를 컴포넌트 렌더 전에 prefetch하고, TanStack Query의 staleTime으로 SSR 환경에서 이중 패칭을 원천 차단하는 방법을 살펴봅니다. 이 패턴을 적용하고 나면 서버 prefetch 이후 클라이언트 재요청이 사라지고, 페이지 전환 시 로딩 스피너 없이 데이터가 바로 표시됩니다.
핵심 개념
네 가지 개념이 순서대로 맞물려 동작합니다. loaderDeps가 어떤 URL 파라미터를 캐시 의존성으로 쓸지 선언하고, loader가 그 의존성을 바탕으로 데이터를 prefetch합니다. staleTime이 서버에서 가져온 데이터를 hydration 이후에도 신선하게 유지해 재요청을 막고, dehydrate/HydrationBoundary가 서버 QueryClient 상태를 클라이언트로 전달하는 다리 역할을 합니다. 이 흐름을 머릿속에 그려두고 읽으면 각 개념이 훨씬 자연스럽게 들어옵니다.
loaderDeps — "이 파라미터가 바뀔 때만 다시 로드해 주세요"
TanStack Router에서 URL search params는 자유롭게 추가할 수 있는데, 문제는 search params 중 하나라도 바뀌면 loader가 재실행될 수 있다는 점입니다. loaderDeps는 이 동작을 정밀하게 제어하는 함수입니다.
loaderDeps가 반환한 객체의 값이 달라져야만 캐시 미스로 처리하고 loader를 다시 실행합니다. 반환값이 같으면 캐시된 데이터를 그대로 사용합니다. 반환된 값은 loader 함수의 deps 인자로 전달되고, 개발자가 이 deps를 사용해 명시적으로 queryKey를 구성하게 됩니다.
export const Route = createFileRoute('/posts')({
validateSearch: (search) => postsSearchSchema.parse(search),
// loader가 실제로 사용하는 파라미터만 추출
loaderDeps: ({ search: { page, pageSize, sort, filters } }) => ({
page,
pageSize,
sort,
filters,
}),
// deps에 loaderDeps가 반환한 값이 그대로 주입됩니다
loader: async ({ context: { queryClient }, deps }) => {
// 개발자가 deps를 사용해 queryKey를 명시적으로 구성하는 부분
await queryClient.ensureQueryData(postsQueryOptions(deps));
},
});핵심:
loaderDeps가 반환한 객체는loader의deps인자로 전달됩니다. queryKey와의 연결은postsQueryOptions(deps)처럼 개발자가 명시적으로 구성하는 것이고, 자동 연결이 아닙니다.
loader — 컴포넌트가 뜨기 전에 데이터를 준비하는 함수
loader는 라우트에 진입하기 전에 실행됩니다. 그리고 링크에 마우스를 올렸을 때(preload)도 실행될 수 있는데, 이는 라우터 설정에서 defaultPreload: 'intent'를 활성화해야 동작합니다. TanStack Query와 함께 쓸 때는 두 가지 방식 중 상황에 맞게 선택할 수 있습니다.
| 함수 | 동작 | 추천 상황 |
|---|---|---|
ensureQueryData |
캐시에 없으면 fetch하고 렌더를 블로킹 | 화면에 반드시 데이터가 있어야 할 때 |
prefetchQuery |
fetch를 시작하되 블로킹 없음 | 스트리밍 / 점진적 로딩 |
대부분의 데이터 그리드 시나리오에서는 ensureQueryData가 자연스럽습니다. 테이블이 빈 상태로 잠깐 보이는 것보다, 데이터가 준비된 뒤에 화면이 그려지는 편이 훨씬 깔끔하게 느껴지기 때문입니다.
staleTime — SSR 이중 패칭을 막는 가장 중요한 설정
솔직히 이 부분에서 많이들 헤맵니다. 서버에서 열심히 prefetch했는데 왜 클라이언트에서 또 요청이 가는 걸까요?
TanStack Query의 기본 staleTime은 0입니다. 서버에서 prefetch한 데이터를 클라이언트가 hydration하는 순간, 그 데이터는 이미 stale 상태로 판정됩니다. 컴포넌트가 마운트되면서 "이 데이터는 오래됐으니 다시 가져올게요"라며 곧바로 재요청이 나가는 것이죠.
export const usersQueryOptions = (params: UsersParams) =>
queryOptions({
queryKey: ['users', params],
queryFn: () => fetchUsers(params), // 별도 API 클라이언트 함수
staleTime: 30 * 1000, // 30초 동안은 재요청 없음
gcTime: 5 * 60 * 1000, // 5분간 캐시 보존
});용어 구분:
staleTime은 데이터의 신선도 기준(이 시간 안에는 재요청 안 함)이고,gcTime은 캐시 자체를 메모리에서 얼마나 유지할지 결정합니다. 컴포넌트가 언마운트되어 캐시 구독이 끊기는 시점부터gcTime타이머가 시작됩니다. 둘은 완전히 독립적으로 동작합니다.
dehydrate / HydrationBoundary — 서버 데이터를 클라이언트로 전달하기
SSR 환경에서는 서버와 클라이언트가 각각 별도의 QueryClient를 가집니다. 서버에서 prefetch한 캐시를 클라이언트에게 전달하지 않으면, 클라이언트는 그 데이터가 있는지조차 모릅니다. dehydrate()가 서버 QueryClient를 직렬화해 HTML에 포함시키고, 클라이언트의 <HydrationBoundary>가 이를 복원합니다.
// app/root.tsx (TanStack Start 기준)
// Next.js는 Server Component에서, Remix는 loader 함수에서 dehydrate 호출 위치가 다릅니다
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
export default function Root() {
const dehydratedState = dehydrate(queryClient);
return (
<HydrationBoundary state={dehydratedState}>
<Outlet />
</HydrationBoundary>
);
}이 패턴이 갖춰지면 서버에서 렌더한 HTML과 클라이언트 hydration 시점의 데이터가 항상 일치해, React의 hydration mismatch 경고도 함께 해결됩니다.
실전 적용
예시 1: 서버 사이드 페이지네이션 데이터 그리드 전체 구성
실무에서 가장 자주 맞닥뜨리는 시나리오입니다. 어드민 페이지의 유저 목록처럼 페이지네이션, 정렬, 검색이 모두 URL에 반영되어야 하는 테이블을 구성해 보겠습니다.
먼저 라우터 설정부터 확인합니다. context: { queryClient } 패턴을 쓰려면 createRouter에서 context를 먼저 등록해야 하고, preload 기능도 이 시점에 켜야 합니다.
// router.ts
import { createRouter } from '@tanstack/react-router';
import { QueryClient } from '@tanstack/react-query';
import { routeTree } from './routeTree.gen';
export function createAppRouter(queryClient: QueryClient) {
return createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent', // 링크 hover 시 자동 preload 활성화
});
}query options 팩토리를 별도 파일로 분리하는 것을 권장합니다. loader와 컴포넌트 양쪽에서 동일한 queryKey를 참조해야 캐시가 올바르게 공유되기 때문입니다. 이 부분이 처음엔 "굳이?"라는 생각이 들 수 있는데, 나중에 캐시가 공유되지 않아 이중 패칭이 생기는 걸 경험하고 나면 습관이 됩니다.
// queries/users.ts
import { queryOptions } from '@tanstack/react-query';
export interface UsersParams {
page: number;
pageSize: number;
sort: 'name' | 'createdAt' | 'email';
order: 'asc' | 'desc';
search: string;
}
export const usersQueryOptions = (params: UsersParams) =>
queryOptions({
queryKey: ['users', params],
queryFn: () => fetchUsers(params), // 별도 API 클라이언트 함수
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
});이어서 라우트 파일입니다. Zod로 타입을 확정하고(validateSearch), loaderDeps로 캐시 의존성을 선언한 뒤, loader에서 prefetch를 실행합니다. Zod에 익숙하지 않은 분은 z.object()로 스키마를 정의하고 .parse()로 검증 및 기본값 처리를 한다는 정도로 이해하면 충분합니다.
// routes/admin/users.tsx
import { createFileRoute } from '@tanstack/react-router';
import { useSuspenseQuery } from '@tanstack/react-query';
import { z } from 'zod';
import { usersQueryOptions } from '../../queries/users';
const searchSchema = z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().default(20),
sort: z.enum(['name', 'createdAt', 'email']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
search: z.string().default(''),
});
export const Route = createFileRoute('/admin/users')({
validateSearch: searchSchema,
loaderDeps: ({ search }) => ({
page: search.page,
pageSize: search.pageSize,
sort: search.sort,
order: search.order,
search: search.search,
}),
loader: async ({ context: { queryClient }, deps }) => {
// 현재 페이지 — 블로킹 prefetch
await queryClient.ensureQueryData(usersQueryOptions(deps));
// 인접 페이지 선제 prefetch — await 없이 실행해 렌더를 막지 않습니다
if (deps.page > 1) {
queryClient.prefetchQuery(
usersQueryOptions({ ...deps, page: deps.page - 1 })
);
}
queryClient.prefetchQuery(
usersQueryOptions({ ...deps, page: deps.page + 1 })
);
},
component: UsersPage, // Route 선언에 컴포넌트를 명시적으로 연결
});
function UsersPage() {
const { page, pageSize, sort, order, search } = Route.useSearch();
const navigate = Route.useNavigate();
const { data } = useSuspenseQuery(
// loader에서 사용한 것과 동일한 queryOptions — 캐시 공유의 핵심
usersQueryOptions({ page, pageSize, sort, order, search })
);
return (
// DataGrid: TanStack Table 등을 기반으로 한 테이블 UI 컴포넌트
<DataGrid
data={data.rows}
totalCount={data.total}
pagination={{ page, pageSize }}
onPaginationChange={({ page, pageSize }) =>
navigate({ search: (prev) => ({ ...prev, page, pageSize }) })
}
onSortChange={(sort, order) =>
navigate({ search: (prev) => ({ ...prev, sort, order, page: 1 }) })
}
/>
);
}| 코드 포인트 | 의도 |
|---|---|
validateSearch: searchSchema |
search params를 Zod로 파싱 — 잘못된 값이 들어오면 기본값으로 대체 |
loaderDeps: ({ search }) => ({ ... }) |
5개 파라미터만 캐시 의존성으로 등록 — 무관한 params 변경은 무시 |
await ensureQueryData(...) |
현재 페이지 데이터 없으면 fetch 후 렌더 블로킹 |
prefetchQuery(page ± 1) |
await 없이 — 렌더를 막지 않고 백그라운드에서 미리 가져옴 |
useSuspenseQuery(...) |
loader가 보장한 데이터이므로 suspense fallback이 실제로 보이지 않음 |
component: UsersPage |
Route 선언에 컴포넌트를 명시적으로 연결 |
예시 2: 정렬 변경 시 page를 1로 리셋하기
이 패턴은 사소해 보이지만 빠뜨리기 쉬운 부분입니다. 정렬 기준이 바뀌었는데 page가 5로 남아있으면, 존재하지 않는 페이지를 요청하거나 엉뚱한 데이터를 보여줄 수 있습니다. 처음에 이걸 빠뜨려서 "왜 데이터가 없지?" 하고 한참 디버깅한 기억이 있습니다.
// 정렬 변경 핸들러 — search 함수형 업데이트로 안전하게 처리
const handleSortChange = (sort: string, order: 'asc' | 'desc') => {
navigate({
search: (prev) => ({
...prev,
sort,
order,
page: 1, // 정렬 기준이 바뀌면 첫 페이지로 초기화
}),
});
};
// 검색어 변경도 동일하게 처리
const handleSearchChange = (keyword: string) => {
navigate({
search: (prev) => ({
...prev,
search: keyword,
page: 1,
}),
});
};navigate의 함수형 업데이트((prev) => ({ ...prev, ... }))를 활용하면 현재 search params를 안전하게 유지하면서 특정 값만 변경할 수 있어, 여러 파라미터가 얽혀 있어도 의도치 않은 초기화를 피할 수 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 타입 안전한 URL 상태 | validateSearch + Zod로 search params 타입이 loader, 컴포넌트까지 end-to-end 추론됨 |
| 자동 preload | defaultPreload: 'intent' 설정 후 링크에 마우스를 올리기만 해도 데이터 prefetch 시작 |
| SSR 데이터 일관성 | dehydrate/hydrate 패턴으로 서버 HTML과 클라이언트 hydration 데이터가 항상 일치 |
| 정밀한 캐시 제어 | loaderDeps로 실제 사용하는 파라미터만 캐시 의존성으로 등록, 불필요한 재요청 방지 |
| 인접 페이지 prefetch | loader에서 다음/이전 페이지를 미리 fetch해 페이지 전환이 즉각적으로 느껴짐 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| loaderDeps 과다 선언 | 불필요한 파라미터 포함 시 loader가 과도하게 재실행됨 | 실제 queryFn에서 사용하는 값만 반환하도록 제한 |
| staleTime 미설정 | SSR prefetch 데이터가 hydration 직후 즉시 stale 처리되어 재요청 발생 | queryOptions에 staleTime 명시 (최소 30초 권장) |
| 서버 메모리/보안 | SSR 환경에서 싱글턴 QueryClient 사용 시 요청 간 데이터 누출 위험 | 요청별 QueryClient 인스턴스 생성 (아래 코드 참고) |
| TanStack Start 베타 | 2025년 기준 아직 베타 단계로 API 변경 가능성 존재 | 프로덕션 적용 시 버전 업 changelog 모니터링 |
"서버 메모리 관리"는 단순한 성능 이슈가 아닙니다. SSR 환경에서 QueryClient를 전역 싱글턴으로 만들면 서로 다른 사용자의 요청이 같은 캐시를 공유하게 됩니다. 개인 정보나 권한이 다른 데이터가 섞일 수 있는 심각한 보안 문제입니다. 반드시 요청별로 인스턴스를 생성하는 것을 권장합니다.
// ❌ 위험: 전역 싱글턴 — 요청 간 데이터 공유됨
const queryClient = new QueryClient();
// ✅ 안전: 요청마다 새 인스턴스 생성
function createQueryClientForRequest() {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 30 * 1000 },
},
});
}실무에서 가장 흔한 실수
-
loaderDeps: ({ search }) => search처럼 전체 search 객체를 반환하는 것 — 관련 없는 파라미터가 하나라도 바뀌면 불필요한 loader 재실행이 발생합니다. 처음엔 "다 넣으면 편하지 않나?" 싶었는데, 다른 UI 상태가 URL에 얹히면서 테이블이 불필요하게 깜빡이는 걸 경험하고 나면 생각이 달라집니다. -
queryOptions에staleTime을 지정하지 않은 채 SSR을 적용하는 것 — Network 탭을 열어보면 서버 prefetch 직후에 클라이언트에서 동일한 요청이 또 나가는 걸 확인할 수 있습니다. 증상만 보면 "prefetch가 왜 안 돼?"처럼 느껴지는데, 원인은 대부분staleTime미설정입니다. -
loader의
ensureQueryData와 컴포넌트의useSuspenseQuery에 서로 다른 queryOptions 객체를 사용하는 것 — queryKey가 조금이라도 다르면 캐시가 공유되지 않습니다. query options 팩토리 함수를 단일 파일에서 export해서 양쪽에서 동일하게 참조하는 것이 좋습니다.
마치며
loaderDeps로 캐시 의존성을 정밀하게 선언하고, staleTime으로 SSR 이중 패칭을 차단하고, query options 팩토리를 loader와 컴포넌트가 공유하면 — URL search params 기반의 데이터 그리드에서 로딩 스피너가 사라집니다. 처음으로 Network 탭에서 서버 prefetch 이후 클라이언트 재요청이 사라진 걸 확인했을 때의 그 시원함이 아직도 기억에 남습니다. URL이 상태의 단일 진실 공급원(single source of truth)이 되는 순간, 북마크와 공유 링크도 자연스럽게 따라옵니다.
지금 바로 시작해볼 수 있는 3단계:
- 기존 라우트에
validateSearch와loaderDeps를 추가해 보시면, 타입 안전한 search params가 loader부터 컴포넌트까지 end-to-end로 추론되는 경험을 바로 체감할 수 있습니다. queries/폴더에queryOptions팩토리 파일을 분리하고staleTime: 30 * 1000을 추가해 보시면, 이후 SSR을 도입할 때 이 파일이 그대로 재사용됩니다.- loader에서 현재 페이지
ensureQueryData이후page + 1을prefetchQuery로 선제 요청해 보시면, 페이지 전환 속도 차이를 Network 탭의 타이밍과 비교해 직접 확인할 수 있습니다.
참고 자료
- TanStack Router — Data Loading 공식 문서
- TanStack Router — Search Params 공식 문서
- TanStack Router — TanStack Query 통합 가이드
- TanStack Router — External Data Loading
- TanStack Query — Server Rendering & Hydration
- TanStack Query — Advanced Server Rendering
- TanStack Query — Prefetching & Router Integration
- TanStack Table — Pagination Guide
- Frontend Masters — Loading Data with TanStack Router: react-query
- Frontend Masters — Loading Data with TanStack Router: Getting Going
- TanStack Start — Selective SSR 가이드