TanStack Router: 경로·검색 파라미터·로더를 타입으로 묶는 라우팅, React Router와 비교
이 글의 핵심
TanStack Router로 pathname·search·loader를 타입으로 묶는 방법을 다룹니다. 파일 기반 라우팅, Zod 검증, 히스토리·매칭 내부 관점, 프로덕션 트러블슈팅까지 포함합니다.
들어가며
TanStack Router는 경로(pathname), 검색 파라미터(search params), 라우트별 데이터 로딩(loader)을 TypeScript 타입으로 끝까지 연결하도록 설계된 React 라우팅 라이브러리입니다. URL을 문자열로만 다루다 보면 링크 오타나 쿼리 파싱 오류가 런타임에야 드러나는데, TanStack Router는 링크, 네비게이션, 검증 로직이 같은 타입 정보를 공유하게 해서 이런 오류를 컴파일 단계로 앞당깁니다.
예제는 TanStack Router 1.x와 공식 권장 설정을 전제로 합니다. 세부 API는 버전마다 조금씩 바뀌므로 설치한 버전의 문서와 릴리스 노트를 함께 확인하는 것이 안전합니다.
TanStack Router가 지향하는 것
URL을 애플리케이션 상태의 일부로 취급
전통적인 SPA 라우팅은 경로만 다루는 경우가 많습니다. 하지만 실제 제품에서는 페이지네이션, 필터, 정렬, 탭처럼 pathname과 독립적으로 바뀌는 상태도 URL에 담아야 공유, 북마크, 뒤로 가기가 자연스럽게 동작합니다. TanStack Router는 검색 파라미터를 경로와 같은 수준으로 다루며, 기본적으로 JSON 기반으로 직렬화하고 라우트마다 검증 함수를 둘 수 있게 합니다.
타입 안전성이 묶는 범위
여기서 말하는 타입 안전성은 Link의 to 문자열을 존재하는 경로로 좁히는 데서 끝나지 않습니다. $postId 같은 경로 파라미터의 존재와 형태, 검색 파라미터의 필수·선택 필드와 기본값과 타입 변환, loader가 반환하는 데이터의 형태가 모두 라우트 정의 한 곳에서 정해지고, 컴포넌트와 링크는 그 타입을 그대로 받습니다. 그래서 화면 컴포넌트에서 문자열을 파싱하고 캐스팅하는 코드가 사라집니다.
핵심 구성 요소
| 개념 | 역할 |
|---|---|
| Route Tree | 부모·자식 관계로 연결된 라우트 정의. 한 트리가 앱의 네비게이션 모델 전체를 표현합니다. |
| Root Route | 레이아웃, 전역 컨텍스트, 에러 처리의 기준점이 되는 최상위 라우트입니다. |
| Router 인스턴스 | 트리와 히스토리, 스크롤 복원 같은 런타임 동작을 묶는 객체입니다. |
<RouterProvider /> | React 트리에 라우터를 주입해 하위에서 훅과 링크가 동작하게 합니다. |
파일 기반 라우팅을 쓰면 이 트리가 빌드 시점에 자동으로 생성되어, createRoute를 길게 나열할 필요가 없어집니다.
코드 기반 최소 구성
개념을 익힐 때는 라우트 트리를 코드로 직접 구성해 보는 것이 이해에 도움이 됩니다. 실제 프로젝트에서는 다음 절의 파일 기반 라우팅으로 옮기는 경우가 많습니다.
// app/router.tsx
import {
createRouter,
createRootRoute,
createRoute,
RouterProvider,
Outlet,
useParams,
} from '@tanstack/react-router';
const rootRoute = createRootRoute({
component: () => (
<>
{/* 자식 라우트가 렌더될 위치 */}
<Outlet />
</>
),
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <p>홈</p>,
});
const postRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'posts/$postId',
component: PostPage,
});
function PostPage() {
// from에 라우트 id를 넘겨 해당 라우트의 path params 타입을 고정
const { postId } = useParams({ from: postRoute.id });
return <p>글 {postId}</p>;
}
const routeTree = rootRoute.addChildren([indexRoute, postRoute]);
const router = createRouter({ routeTree });
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
export function App() {
return <RouterProvider router={router} />;
}
부모·자식 관계는 getParentRoute와 addChildren으로 명시합니다. 맨 아래의 Register 인터페이스 보강(module augmentation)은 라우터 타입을 전역에 등록하는 단계로, 이것이 있어야 Link, useNavigate, useParams가 앱의 실제 라우트 목록을 알고 경로 문자열과 파라미터 타입을 검사합니다. 이 선언이 빠지면 오류 없이 타입이 느슨해지므로 놓치기 쉽습니다.
파일 기반 라우팅에서는 createFileRoute가 만든 Route 객체에서 Route.useParams(), Route.useSearch(), Route.useLoaderData()를 바로 쓸 수 있어, from을 일일이 넘기지 않고 같은 타입 이점을 얻습니다.
파일 기반 라우팅
왜 파일 기반인가
라우트가 늘어나면 createRoute 호출이 수십 개가 되고 트리를 조립하는 코드도 길어집니다. 파일 기반 라우팅에서는 폴더 구조가 중첩 라우트를, $이름.tsx가 동적 세그먼트를 나타내므로 URL 구조가 파일 트리에서 바로 보입니다. 빌드 플러그인이 routeTree.gen.ts를 생성해 트리와 타입을 자동으로 맞춰 주므로, 라우트를 추가할 때 조립 코드를 손댈 필요도 없습니다.
Vite 플러그인 설정
공식 문서는 @tanstack/router-plugin/vite 사용을 권장하며, 이 플러그인을 @vitejs/plugin-react보다 앞에 두라고 안내합니다. 라우트 파일을 변환하는 단계가 React 변환보다 먼저 일어나야 하기 때문입니다.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { tanstackRouter } from '@tanstack/router-plugin/vite';
export default defineConfig({
plugins: [
tanstackRouter({
target: 'react',
autoCodeSplitting: true, // 아래 코드 스플리팅 절 참고
}),
react(),
],
});
파일 이름 규칙
__root.tsx는 루트 레이아웃으로 전역 내비게이션, 테마, 토스트 래퍼 등을 둡니다. index.tsx는 해당 경로의 인덱스 라우트이고, $paramName.tsx는 동적 세그먼트입니다. 예를 들어 posts/$postId.tsx는 /posts/:postId에 해당합니다. .lazy.tsx 접미사를 붙인 파일은 해당 라우트의 UI를 지연 로드합니다.
각 라우트 파일은 createFileRoute('/경로')({ ... })로 Route를 내보내고, 플러그인이 이를 모아 하나의 트리를 만듭니다.
// src/routes/posts/$postId.tsx
import { createFileRoute, notFound } from '@tanstack/react-router';
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const res = await fetch(`/api/posts/${params.postId}`);
if (res.status === 404) throw notFound();
if (!res.ok) throw new Error(`요청 실패: ${res.status}`);
return res.json() as Promise<{ title: string; body: string }>;
},
notFoundComponent: () => <p>글을 찾을 수 없습니다.</p>,
errorComponent: ({ error }) => <p>오류: {error.message}</p>,
component: PostDetail,
});
function PostDetail() {
const data = Route.useLoaderData();
return (
<article>
<h1>{data.title}</h1>
<p>{data.body}</p>
</article>
);
}
loader의 반환 타입이 곧 useLoaderData()의 타입이 되므로 페칭 계약이 라우트 정의에 고정됩니다. 존재하지 않는 리소스는 notFound()를 던져 notFoundComponent로, 그 밖의 실패는 일반 에러를 던져 errorComponent로 보내면 “없는 글”과 “네트워크 실패”를 다른 화면으로 다룰 수 있습니다.
검색 파라미터 타입 안정성
validateSearch의 역할
검색 파라미터는 사용자 입력, 외부 링크, 오래된 북마크를 통해 들어오므로 항상 신뢰할 수 없는 값입니다. validateSearch는 URL에서 읽은 값을 앱이 안전하게 쓸 수 있는 값으로 바꾸는 작업을 라우트 계층에서 끝내게 합니다. Zod와 함께 쓰는 패턴이 널리 쓰입니다.
// src/routes/search/index.tsx
import { createFileRoute } from '@tanstack/react-router';
import { zodValidator, fallback } from '@tanstack/zod-adapter';
import { z } from 'zod';
const searchSchema = z.object({
q: fallback(z.string(), '').default(''),
page: fallback(z.number().int().positive(), 1).default(1),
sort: fallback(z.enum(['new', 'old']), 'new').default('new'),
});
export const Route = createFileRoute('/search/')({
validateSearch: zodValidator(searchSchema),
component: SearchPage,
});
function SearchPage() {
const { q, page, sort } = Route.useSearch();
// page는 number, sort는 'new' | 'old'로 좁혀짐
return (
<div>
검색어: {q}, 페이지: {page}, 정렬: {sort}
</div>
);
}
TanStack Router는 검색 파라미터를 JSON으로 파싱하므로 ?page=2의 page는 이미 숫자 2로 들어옵니다. 그래서 z.coerce가 필요하지 않습니다. 어댑터(@tanstack/zod-adapter)를 쓰는 이유는 두 가지입니다. 첫째, Zod 스키마를 그대로 넘기면 .default()가 붙은 필드도 입력 타입에서 필수로 잡혀 이 라우트로 가는 모든 Link에 search를 적어야 하는데, 어댑터는 입력과 출력 타입을 구분해 이를 해결합니다. 둘째, fallback()은 값이 스키마에 맞지 않을 때 에러 대신 지정한 값으로 대체합니다. Standard Schema를 지원하는 최신 Zod와 라우터 버전에서는 스키마를 직접 넘길 수도 있으므로, 사용 중인 버전의 문서를 확인합니다.
검증에 실패했을 때의 동작은 미리 정해 두어야 합니다. fallback 없이 검증이 실패하면 라우트 로딩이 에러로 처리되어 errorComponent가 보입니다. 사용자가 잘못된 링크를 공유받는 일은 흔하므로, 기본값으로 대체할지, 에러 화면을 보일지, redirect로 안전한 URL로 보낼지를 팀 규칙으로 정합니다.
네비게이션과 검색 파라미터
Link나 navigate의 search에 함수를 넘기면 이전 검색 파라미터를 바탕으로 일부만 바꿀 수 있습니다. 목록 화면에서 pathname은 유지한 채 필터나 페이지만 바꾸는 경우가 대표적입니다.
<Link to="/search" search={(prev) => ({ ...prev, page: prev.page + 1 })}>
다음 페이지
</Link>
검색 파라미터 타입이 라우트와 연결되어 있으므로, 스키마에 없는 필드를 넣거나 page에 문자열을 넣으면 컴파일 단계에서 오류가 납니다.
Loader와 데이터 페칭
loader가 맡는 일
loader는 라우트가 활성화될 때 필요한 데이터를 준비하는 함수입니다. 경로와 검색 파라미터가 확정된 뒤에 실행되므로, 컴포넌트 안의 useEffect 페칭과 달리 렌더링 전에 데이터를 준비할 수 있고 데이터 의존성이 라우트 정의에 드러납니다. TanStack Router는 loader 결과를 라우트별로 캐시하며(stale-while-revalidate 방식), staleTime 같은 옵션으로 재검증 시점을 조절할 수 있습니다.
검색 파라미터에 따라 데이터가 달라진다면 loaderDeps로 의존성을 선언해야 합니다. loader는 search를 직접 받지 않으며, loaderDeps가 반환한 값이 캐시 키의 일부가 되어 그 값이 바뀔 때 loader가 다시 실행됩니다.
export const Route = createFileRoute('/search/')({
validateSearch: zodValidator(searchSchema),
loaderDeps: ({ search }) => ({ q: search.q, page: search.page }),
loader: ({ deps }) => fetchResults(deps.q, deps.page),
});
loaderDeps에 검색 파라미터 전체를 넣으면 화면 표시용 파라미터(예: 펼침 상태)가 바뀔 때도 다시 요청하므로, 실제로 데이터에 영향을 주는 값만 넣습니다.
beforeLoad와 인증·권한
beforeLoad는 loader보다 먼저 실행되어 리다이렉트나 권한 거부를 처리하기 좋습니다. 세션이 없으면 throw redirect({ to: '/login', search: { redirect: location.href } })로 로그인 화면에 보내고, 역할이 맞지 않으면 권한 없음 화면으로 보내는 식입니다. 인증 상태는 createRootRouteWithContext로 정의한 라우터 컨텍스트에 넣어 두고 beforeLoad의 context에서 읽는 구성이 일반적입니다. 상위 레이아웃 라우트의 beforeLoad에 가드를 두면 그 아래 모든 라우트가 보호됩니다.
다만 라우터 가드는 사용자 경험과 불필요한 요청 방지를 위한 것이지 보안 경계가 아닙니다. 클라이언트 코드는 우회할 수 있으므로 실제 권한 검사는 API 서버에서 해야 합니다.
TanStack Query와의 역할 분담
서버 상태 캐시와 백그라운드 재검증을 TanStack Query에 맡기는 경우, loader에서 queryClient.ensureQueryData를 호출해 라우트 진입 시점에 첫 페칭을 보장하고, 컴포넌트에서는 useQuery(또는 useSuspenseQuery)로 같은 쿼리를 구독하는 구성이 흔합니다. 이때 쿼리 키에 검색 파라미터가 빠지면 서로 다른 검색 결과가 같은 캐시 항목을 덮어쓰고, 데이터와 무관한 값까지 넣으면 불필요한 재요청이 생깁니다. loaderDeps와 쿼리 키에 같은 값을 쓰도록 맞추면 두 캐시가 어긋나지 않습니다.
코드 스플리팅
SPA가 커질수록 모든 페이지를 초기 번들에 넣는 비용이 커집니다. TanStack Router는 라우트 정의를 두 부분으로 나눕니다. 경로 매칭과 데이터 로딩에 필요한 부분(loader, beforeLoad, validateSearch 등)은 링크 이동 전에도 필요하므로 메인 번들에 두고, component, errorComponent 같은 UI는 지연 로드합니다. 파일 기반 라우팅에서는 .lazy.tsx 파일에 createLazyFileRoute로 UI만 분리하거나, Vite 플러그인의 autoCodeSplitting: true로 이 분리를 자동화할 수 있습니다.
지연 로딩을 쓰면 청크를 받는 동안의 대기 화면(pendingComponent), 청크 로드 실패 시의 에러 처리, 스크롤 위치를 함께 점검해야 합니다. 랜딩 페이지처럼 첫 진입에서 거의 항상 쓰이는 라우트는 분리해도 이득이 적으므로 예외로 두기도 합니다.
React Router와 비교
두 라이브러리 모두 React에서 널리 쓰이지만 설계 중심이 다릅니다. 호환 여부보다 제품이 URL을 얼마나 엄격하게 다루는지를 기준으로 고르는 편이 맞습니다.
| 구분 | React Router | TanStack Router |
|---|---|---|
| 타입 | v7 프레임워크 모드에서 라우트별 타입을 코드 생성으로 제공. 라이브러리 모드에서는 경로 문자열 검사가 약함. | 경로, 검색 파라미터, loader, 링크를 처음부터 타입 중심으로 설계. |
| 검색 파라미터 | useSearchParams로 문자열 URLSearchParams를 다룸. 스키마와 검증은 직접 구성. | JSON 직렬화와 라우트별 검증이 기본 기능. |
| 데이터 로딩 | loader/action 지원. Remix가 v7 프레임워크 모드로 통합되어 SSR과의 조합이 강점. | loader·beforeLoad·loaderDeps와 내장 캐시로 클라이언트 중심 흐름이 명확. |
| 생태계 | 사용자와 자료가 매우 많음. | 상대적으로 작지만 TanStack Query와의 조합이 자주 언급됨. |
URL을 상태의 기준으로 삼고 검증, 타입, 데이터 로딩을 라우트 계층에 모으려는 SPA라면 TanStack Router가 설계 목적에 가깝습니다. 반대로 기존 코드베이스가 React Router에 깊이 의존하고 있다면, 옮기는 비용의 대부분이 URL 스키마와 상태 모델을 다시 정의하는 일이라는 점을 감안해야 합니다. 라우트 단위로 점진적으로 옮기거나, 당장은 React Router에 머물며 Zod 검증과 래퍼 컴포넌트로 타입을 보강하는 것도 현실적인 선택입니다.
Next.js App Router처럼 프레임워크가 이미 라우팅, 서버 컴포넌트, 캐시 규칙을 갖춘 환경에서 TanStack Router를 함께 쓰면 역할이 겹칩니다. Vite 기반 SPA나 TanStack Router 위에 만든 TanStack Start에서 이점이 분명합니다. 대규모 팀이라면 공식 ESLint 플러그인(@tanstack/eslint-plugin-router)의 라우트 옵션 순서 규칙도 도입을 검토할 만합니다. 옵션 순서에 따라 타입 추론 결과가 달라지는 경우가 있기 때문입니다.
실전 SPA 라우팅 설계
레이아웃과 중첩
대시보드, 설정, 상세처럼 공통 화면 틀을 유지하려면 부모 라우트에 레이아웃을 두고 자식에서 본문만 바꾸는 중첩 라우트가 자연스럽습니다. 부모 loader에서 워크스페이스 정보 같은 공통 데이터를 가져오고 자식은 세부 리소스만 가져가면 중복 요청을 줄일 수 있습니다. TanStack Router는 매칭된 라우트들의 loader를 병렬로 실행하므로, 자식 loader가 부모 데이터에 의존하지 않게 설계하면 요청 워터폴도 생기지 않습니다. 부모 loader가 무거워지면 그 아래 모든 화면이 늦게 뜨므로, 느린 데이터는 await하지 않은 Promise로 반환하고 컴포넌트에서 <Await>로 기다리며 스켈레톤을 보여 주는 방식을 검토합니다.
에러·404·리다이렉트
라우터 전역의 defaultNotFoundComponent, 라우트별 notFoundComponent와 errorComponent를 조합해 존재하지 않는 리소스와 복구 가능한 오류(네트워크 실패 등)를 구분하면 운영이 수월합니다. errorComponent는 reset 함수를 받으므로 재시도 버튼을 붙일 수 있습니다.
프리로드와 체감 속도
목록에서 상세로 자주 이동한다면 createRouter({ defaultPreload: 'intent' })나 Link의 preload="intent"로 마우스를 올렸을 때 다음 라우트의 코드와 loader를 미리 실행할 수 있습니다. 명령형으로는 router.preloadRoute를 씁니다. 모바일이나 데이터 요금이 신경 쓰이는 환경에서 과한 프리로드는 쓰지 않을 요청까지 보내므로, 프리로드 대상과 defaultPreloadStaleTime을 함께 조절합니다.
내부 동작: 히스토리·매칭·스크롤
TanStack Router는 @tanstack/history 패키지로 브라우저 History API(pushState, popstate)를 감싸고, 그 위에서 현재 위치에 매칭된 라우트 목록과 각 라우트의 로딩 상태를 관리합니다. 경로 매칭은 정적 세그먼트, 동적 세그먼트, 와일드카드(splat) 순으로 구체적인 라우트를 우선합니다. 검색 파라미터의 직렬화와 역직렬화가 라우트 계층에 묶여 있으므로, 쿼리 문자열을 수동으로 파싱하는 코드보다 잘못된 값이 검증 실패로 명시적으로 드러납니다.
스크롤 복원은 createRouter({ scrollRestoration: true })로 켭니다. 기본적으로 window 스크롤만 복원하므로, 레이아웃 안에 별도로 스크롤되는 컨테이너가 있다면 해당 요소를 복원 대상으로 지정해야 위치가 어긋나지 않습니다.
트러블슈팅
| 증상 | 점검 |
|---|---|
validateSearch에서 무한 리다이렉트 | 기본값과 리다이렉트 조건이 서로를 다시 유발하지 않는지 |
loader가 예상보다 자주 호출됨 | 호버 프리로드 후 실제 이동, staleTime 0(기본값)에 의한 재검증, loaderDeps에 불필요한 값 포함 |
| 링크·훅의 타입이 갑자기 느슨해짐 | Register 보강 누락, routeTree.gen.ts 생성 실패나 커밋 누락 |
| 배포 후 새로고침하면 404 | 호스팅에서 모든 경로를 index.html로 돌리는 SPA 폴백, Vite base 설정 |
| 검색 파라미터가 로그에 너무 많이 남음 | 개인정보나 토큰을 URL에 넣지 않았는지 |