라우터를 타입 안전하게 조립하기
설치부터 타입 안전 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 같은 건 여기서 다루지 않았지만, 지금 만든 라우터 위에 하나씩 얹으면 되는 것들이에요. 공식 문서의 예제를 따라가면 지금 잡은 감이 그대로 이어질 거예요.
자주 묻는 질문
답을 펼치기 전에 스스로 답해보세요
참고 자료
관련 글
받아온 데이터에 타입을 입히는 법
fetch 응답에 as 를 붙이면 거짓말이 돼요. 경계에서 검증해 타입을 얻어내요.
cloneElement 가 자꾸 as any 를 부르던 이유
children 에 props 를 꽂으려는 순간, React 19 의 unknown 이 막아서요.
2시간 뒤 터질 버그를 미리 잡기
타입스크립트의 타입은 런타임에 사라져요. 컴파일 타임에만 검사된다는 걸 알면 버그도 같이 보여요.
vanilla-extract의 세 가치는 어디서 새는가
globalStyle과 var() 문자열이 VE의 세 가치를 어디서 흔드는지 살펴봐요.