라우터를 타입 안전하게 조립하기
설치부터 타입 안전 Link, search 검증, loader까지 최소 코드로 훑어요.
1편에서 왜 이 라우터인지 세 자리로 봤으니, 이제 손으로 만들 차례예요. 설치 명령 한 줄부터 loader 로 데이터를 미리 싣는 데까지 짚어볼게요. TanStack Router 의 타입 안전은 딱 한 줄, 라우터 타입을 전역에 등록하는 선언에서 켜지고 그 뒤로 Link 도 search 도 loader 도 전부 그 타입을 물려받아요.
설치하고 라우터 조립하기
설치는 패키지 하나예요.
npm install @tanstack/react-router라우터를 코드로 직접 조립하는 방법부터 보죠. 루트 라우트를 만들고, 그 밑에 자식 라우트를 붙이고, 트리를 라우터에 넘기는 순서예요.
루트 라우트
createRootRoute로 최상위 라우트를 만들어요. 모든 라우트의 부모 자리예요.
자식 라우트
createRoute에 getParentRoute로 부모를 명시해요. 이 연결이 타입 추론을 이어줘요.
트리 조립
addChildren으로 라우트들을 하나의 트리로 묶어요.
라우터 생성
createRouter에 그 트리를 넘기면 라우터 인스턴스가 나와요.
코드로 옮기면 이렇게 돼요.
import {
createRootRoute,
createRoute,
createRouter,
} from "@tanstack/react-router";
const rootRoute = createRootRoute();
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: "about",
});
const routeTree = rootRoute.addChildren([aboutRoute]);
const router = createRouter({ routeTree });여기까지 만든 다음, 딱 한 줄을 더 적어요. 이 라우터의 타입을 모듈 전역에 등록하는 선언이에요.
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}이 Register 등록이 1편에서 말한 타입 안전의 스위치예요. 이게 있어야 Link 나 useParams 가 우리 라우트 트리를 알아보거든요. 마지막으로 앱을 이 라우터로 감싸면 끝이에요.
import { RouterProvider } from "@tanstack/react-router";
function App() {
return <RouterProvider router={router} />;
}파일 하나가 곧 라우트가 될 때
방금은 라우트를 손으로 배열에 담았는데, 공식 문서가 더 권하는 건 파일 기반이에요. 파일 경로 자체가 라우트가 되거든요. src/routes/posts.$postId.tsx 라는 파일을 만들면 그게 /posts/:postId 라우트예요.
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/posts/$postId")({
component: PostComponent,
});$postId 처럼 $ 가 붙은 세그먼트가 동적 파라미터예요. 화면 구조가 파일 트리에 그대로 드러나서, 화면을 닮은 모양으로 코드를 두는 방향과도 잘 맞고요.
한 가지 짚을 게 있어요. 파일 기반은 Vite 플러그인이나 CLI 가 routeTree.gen.ts 라는 파일을 자동으로 만들어주는 걸 전제로 해요. 1편에서 createRouter 에 넘긴 그 routeTree 가 바로 이 자동 생성 파일이죠. 그래서 파일만 만들면 트리 조립은 도구가 대신 해줘요.
파일 기반이 편하지만, 도구 없이 돌려보거나 라우트 구성을 세밀하게 제어하고 싶으면 코드 기반도 그대로 쓸 수 있어요. 둘을 섞어도 되고요.
목적지가 틀리면 컴파일이 막아요
이제 타입 안전이 체감되는 자리예요. Link 로 다른 페이지에 갈 때 목적지를 문자열로 적는데, 그 문자열이 실제 라우트가 아니면 컴파일 타임에 걸려요.
import { Link } from "@tanstack/react-router";
// 존재하는 라우트 + 필요한 params까지 타입으로 요구해요
<Link to="/posts/$postId" params={{ postId: "42" }}>
글 보기
</Link>;
// to에 없는 경로를 적거나 params를 빠뜨리면 타입 에러to 에 오타를 내거나 /posts/$postId 처럼 params 가 필요한 경로에서 params 를 빠뜨리면 빌드가 막혀요. 링크가 깨진 걸 배포 전에 알게 되는 거죠. 컴포넌트 안에서 그 파라미터를 꺼낼 때도 타입이 따라와요.
function PostComponent() {
// postId는 수동 제네릭 없이 자동으로 string
const { postId } = Route.useParams();
return <h1>글 {postId}</h1>;
}Route.useParams() 가 돌려주는 postId 는 별도로 타입을 붙이지 않아도 string 이에요. 라우트 정의에서 이미 추론이 시작됐으니까요.
search params를 검증된 상태로 두기
1편에서 URL 쿼리를 검증된 상태로 본다고 했죠. 그 검증을 붙이는 자리가 validateSearch 예요. 라우트를 정의할 때 스키마를 넘기면, 그 스키마를 통과한 값만 컴포넌트로 들어와요. Zod 스키마를 그대로 쓸 수 있고요.
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const searchSchema = z.object({
page: z.number().catch(1),
filter: z.string().optional(),
});
export const Route = createFileRoute("/posts")({
validateSearch: searchSchema,
component: PostList,
});page 가 없거나 이상한 값이면 catch(1) 이 1 로 메워줘요. 주소창을 직접 건드린 사용자가 ?page=abc 로 들어와도 앱이 안 깨지죠. 이렇게 경계에서 한 번 검증해두면 안쪽 코드가 타입을 믿고 쓸 수 있는데, 이건 받아온 데이터에 타입을 입히는 것과 같은 발상이에요.
컴포넌트에서는 Route.useSearch() 로 꺼내요. 이미 검증됐으니 page 는 확실한 number 예요.
function PostList() {
const { page } = Route.useSearch();
const navigate = Route.useNavigate();
// 이전 search를 받아서 필요한 값만 바꿔요
const goNext = () =>
navigate({ search: (prev) => ({ ...prev, page: prev.page + 1 }) });
return <button onClick={goNext}>다음 ({page})</button>;
}페이지를 넘길 때 navigate 의 search 에 함수를 넘겨요. 이전 search 를 받아서 바꿀 값만 갈아끼우는 방식이라, 나머지 쿼리는 그대로 유지돼요.
loader로 데이터를 미리 싣기
컴포넌트가 마운트된 뒤에 fetch 를 시작하면 화면이 한 박자 늦게 채워지죠. TanStack Router 는 라우트에 loader 를 달아서, 그 라우트로 진입하는 시점에 데이터를 미리 받아둘 수 있어요.
export const Route = createFileRoute("/posts/$postId")({
loader: async ({ params }) => fetchPost(params.postId),
component: PostComponent,
});loader 안의 params 도 자동으로 타입이 붙어서 params.postId 를 바로 쓸 수 있어요. 받아온 데이터는 컴포넌트에서 Route.useLoaderData() 로 꺼내고요.
function PostComponent() {
const post = Route.useLoaderData();
return <article>{post.title}</article>;
}search 값에 따라 다른 데이터를 불러와야 하면 loaderDeps 로 loader 의 캐시 키를 search 에 연결해요. 이 값이 바뀌면 loader 가 다시 돌고, 안 바뀌면 캐시된 값을 그대로 써요.
export const Route = createFileRoute("/posts")({
validateSearch: searchSchema,
loaderDeps: ({ search: { page } }) => ({ page }),
loader: ({ deps: { page } }) => fetchPosts(page),
component: PostList,
});이 캐시는 1편에서 말한 SWR 방식이라, 라우트마다 staleTime 으로 얼마나 오래 신선하게 볼지, gcTime 으로 언제 버릴지 조절할 수 있어요.
두 편을 지나며
1편에서 짚은 세 자리, 타입 추론과 search params 검증과 내장 loader 가 코드에서는 이렇게 생겼어요. 시작은 결국 Register 등록 한 줄이었고, Link 도 useParams 도 useSearch 도 useLoaderData 도 전부 그 등록에서 타입을 물려받았죠.
여기까지가 최소 골격이에요. 중첩 라우트나 route context, prefetch 같은 건 여기서 다루지 않았지만, 지금 만든 라우터 위에 하나씩 얹으면 되는 것들이에요. 공식 문서의 예제를 따라가면 지금 잡은 감이 그대로 이어질 거예요.
자주 묻는 질문
답을 펼치기 전에 스스로 답해보세요