TanStack Router 파일 기반 라우팅과 타입 안전하게 React 구조 잡기

작성일:2026.05.09|수정일:2026.05.09|조회수:16

TanStack Router 파일 기반 라우팅과 타입 안전하게 React 구조 잡기

React 앱의 라우팅은 처음에는 별일 아닌 것처럼 보인다. /about으로 가면 About 화면을 보여주고, /posts/1로 가면 1번 글을 보여주면 된다. 문제는 앱이 조금만 커져도 URL이 더 이상 주소표시줄의 문자열로만 남아 있지 않다는 데 있다. URL은 권한의 경계가 되고, 데이터 로딩의 시작점이 되고, 필터와 페이지네이션 상태를 들고 다니는 작은 데이터베이스 비슷한 것이 된다. 작다고 무시했다가 나중에 제일 끈질기게 따라오는 종류의 데이터베이스 말이다.

TanStack Router를 좋아하는 이유는 이 불편함을 꽤 정직하게 다루기 때문이다. 이 라이브러리는 라우팅을 “어떤 컴포넌트를 렌더링할 것인가”에서 끝내지 않고, “이 URL에서 어떤 params와 search를 신뢰할 수 있으며, 어떤 데이터를 언제 준비해야 하는가”까지 끌고 온다. 그래서 처음 설정은 React Router보다 조금 더 엄격하게 느껴질 수 있다. 대신 그 엄격함은 규모가 커졌을 때 문자열 경로와 수동 타입 선언이 여기저기 흩어지는 일을 막아준다.

파일 기반 라우팅은 폴더 정리가 아니라 계약이다

TanStack Router의 file-based routing을 단순히 src/routes 아래에 파일을 나누는 기능으로 보면 조금 아깝다. 중요한 것은 파일명이 라우트 계약이 되고, 생성기가 그 계약을 타입으로 바꿔준다는 점이다. 사람이 /posts/$postId라는 문자열을 여기저기 복사해 맞추는 대신, 라우트 파일과 생성된 routeTree.gen.ts가 앱 전체의 기준점이 된다.

가장 작은 구조는 보통 이렇게 시작한다. 루트 라우트는 전체 앱의 공통 껍데기를 만들고, 개별 파일 라우트는 자신이 맡은 URL 조각을 선언한다.

TSX
// src/routes/__root.tsx
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'

export const Route = createRootRoute({
  component: () => (
    <>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/posts">Posts</Link>
      </nav>
      <Outlet />
    </>
  ),
})

여기서 Outlet은 단순한 자리표시자가 아니다. 루트가 공통 레이아웃과 에러 경계, 인증 전후의 큰 흐름을 잡고, 그 아래 자식 라우트가 자기 화면을 끼워 넣는 지점이다. 라우팅을 화면 목록으로만 보면 Outlet은 귀찮은 중첩 문법처럼 보이지만, 레이아웃과 URL 계층을 같은 구조로 읽게 해준다고 생각하면 훨씬 납득하기 쉽다.

개별 페이지는 createFileRoute로 만든다. 공식 문서에서도 강조하듯, file route는 Route라는 이름으로 export되어야 생성기가 제대로 인식한다. 이름을 바꿔도 동작할 것 같은 유혹이 있지만, 이런 유혹은 보통 빌드가 조용히 삐끗한 뒤에야 비용을 청구한다.

TSX
// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({
  loader: ({ params }) => fetchPost(params.postId),
  component: PostDetail,
})

function PostDetail() {
  const post = Route.useLoaderData()
  const { postId } = Route.useParams()

  return <article>{post.title} — {postId}</article>
}

이 예시에서 핵심은 postId를 내가 문자열로 해석하고 있다는 사실이 아니라, 라우트 경로의 $postIdparams.postId 타입으로 이어진다는 점이다. 파일명과 route path, loader, component hook이 같은 계약을 공유한다. 그래서 경로를 바꾸면 관련 코드가 같이 흔들리고, 그 흔들림은 런타임 404가 아니라 에디터의 빨간 줄로 먼저 온다.

routeTree.gen.ts는 손대지 않는 파일이다

파일 기반 라우팅을 쓰면 routeTree.gen.ts 같은 생성 파일이 생긴다. 이 파일은 앱 전체 라우트 구조와 타입 정보를 묶어주는 결과물이다. 중요한 파일이지만, 그래서 더더욱 직접 수정하면 안 된다. 자동 생성 파일을 손으로 고치는 일은 프린터에서 나온 계약서에 연필로 조항을 추가하는 일과 비슷하다. 잠깐은 마음이 편하지만 다음 출력 때 사라진다.

Vite를 쓴다면 보통 router plugin을 먼저 등록한다. 생성과 코드 분할을 맡는 플러그인이 React 플러그인보다 앞에 있어야 라우트 트리 생성 흐름이 안정적이다.

TS
// vite.config.ts
import { tanstackRouter } from '@tanstack/router-plugin/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    tanstackRouter({
      autoCodeSplitting: true,
    }),
    react(),
  ],
})

autoCodeSplitting은 라우트 단위로 코드를 나누는 기본 흐름을 만들어준다. 모든 프로젝트에서 무조건 성능이 좋아진다고 말할 수는 없지만, 페이지 단위로 진입점이 나뉘는 일반적인 앱에서는 초기에 켜두는 편이 나중에 구조를 바꾸는 것보다 덜 피곤하다. 성능 최적화는 대부분 “나중에 하자”라고 적어둔 순간부터 기술 부채의 얼굴을 하기 시작한다.

생성된 트리는 router를 만들 때 들어간다. 그리고 타입 안전성을 제대로 얻으려면 Register에 router 타입을 등록해야 한다. 이 부분을 빼먹으면 TanStack Router를 쓰고 있으면서도 제일 맛있는 부분을 남겨두고 나오는 셈이 된다.

TSX
// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export const router = createRouter({
  routeTree,
  defaultPreload: 'intent',
})

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

defaultPreload: 'intent'는 사용자가 링크에 관심을 보이는 시점에 다음 라우트의 데이터를 미리 준비하는 전략이다. 라우터가 단순히 클릭 이후의 이동만 처리하는 게 아니라, 이동할 가능성이 생긴 순간부터 데이터 준비를 시작할 수 있다는 뜻이다. 이 작은 차이가 앱 전체의 체감 속도를 바꾸기도 한다.

search params는 문자열이 아니라 외부 입력이다

실무에서 라우팅 버그가 가장 끈질기게 남는 곳은 search params다. 처음에는 ?page=2&sort=latest 정도라서 별일 아닌 것처럼 보인다. 그런데 필터가 늘고, 탭 상태가 붙고, 공유 가능한 URL을 만들기 시작하면 search params는 거의 화면 상태 저장소처럼 행동한다. 문제는 이 저장소가 사용자의 주소창에서 직접 들어온다는 점이다.

그래서 TanStack Router에서는 validateSearch를 라우트 경계의 검문소처럼 두는 편이 좋다. URL에서 들어온 값은 아직 믿을 수 없는 값이고, validateSearch를 지나면서 앱 내부에서 다룰 수 있는 타입으로 바뀐다.

TSX
// src/routes/products.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'

const productSearchSchema = z.object({
  page: z.coerce.number().int().positive().catch(1),
  q: z.string().catch(''),
  sort: z.enum(['latest', 'price']).catch('latest'),
})

export const Route = createFileRoute('/products')({
  validateSearch: productSearchSchema.parse,
  loaderDeps: ({ search }) => ({
    page: search.page,
    q: search.q,
    sort: search.sort,
  }),
  loader: ({ deps }) => fetchProducts(deps),
  component: ProductsPage,
})

여기서 validateSearchloaderDeps를 나눠 보는 게 중요하다. validateSearch는 “무엇을 믿을 것인가”를 정하고, loaderDeps는 “어떤 값이 바뀌면 데이터를 다시 불러올 것인가”를 정한다. 이 둘을 대충 섞어두면 URL은 바뀌었는데 목록은 예전 데이터를 보여주는 이상한 화면을 만들기 쉽다. 이상한 화면은 대체로 재현이 안 된다는 말과 함께 돌아온다.

컴포넌트에서는 같은 라우트 객체에서 search와 loader data를 읽는다. 라우트 파일 안에서 사용하는 hook은 이미 이 라우트의 문맥을 알고 있으므로, 타입 힌트가 훨씬 정확해진다.

TSX
function ProductsPage() {
  const search = Route.useSearch()
  const products = Route.useLoaderData()

  return (
    <section>
      <h1>Products</h1>
      <p>{search.q ? `검색어: ${search.q}` : '전체 상품'}</p>
      <ProductList products={products} />
    </section>
  )
}

이 흐름의 장점은 URL과 데이터가 따로 놀지 않는다는 데 있다. search params를 React state처럼 다루되, 그 state가 주소창에 있고, loader의 재실행 조건에도 명시적으로 연결된다. 공유 가능한 URL과 예측 가능한 데이터 갱신을 동시에 얻는 셈이다.

Link와 navigate는 문자열 조립기가 아니다

라우터를 도입했는데도 링크를 문자열로 조립하기 시작하면 금방 원래 자리로 돌아간다. /posts/ 뒤에 id를 붙이고, query string을 직접 만들고, 어느 순간 encode 처리를 잊는다. 그때부터 라우터는 타입 안전한 도구가 아니라 문자열을 조금 예쁘게 감싸는 장식품이 된다.

TanStack Router의 LinkuseNavigate는 route tree를 알고 있을 때 힘을 낸다. 존재하지 않는 경로, 빠진 params, 맞지 않는 search 구조를 컴파일 단계에서 잡을 수 있기 때문이다.

TSX
import { Link, useNavigate } from '@tanstack/react-router'

function PostCard({ post }: { post: { id: string; title: string } }) {
  return (
    <Link
      to="/posts/$postId"
      params={{ postId: post.id }}
    >
      {post.title}
    </Link>
  )
}

function ProductFilter() {
  const navigate = useNavigate({ from: Route.fullPath })

  return (
    <button
      onClick={() => {
        navigate({
          search: (prev) => ({ ...prev, page: 1, sort: 'latest' }),
          replace: true,
        })
      }}
    >
      최신순
    </button>
  )
}

useNavigate({ from: Route.fullPath })처럼 현재 위치를 좁혀주는 습관도 중요하다. TanStack Router 문서가 말하듯, 앱이 커질수록 모든 라우트의 params와 search를 거대한 union으로 다루는 비용이 생긴다. from이나 to로 문맥을 좁히면 타입 정확도뿐 아니라 TypeScript가 추론해야 할 범위도 줄어든다. 타입 안전성은 엄격함의 문제가 아니라, 에디터가 숨을 덜 헐떡이게 만드는 문제이기도 하다.

인증과 공통 데이터는 라우트 경계에 둔다

인증 처리를 컴포넌트 안에서만 하다 보면 화면이 잠깐 보였다가 사라지거나, 데이터 요청이 먼저 나가고 나서야 권한이 없다는 사실을 깨닫는 일이 생긴다. 이런 흐름은 사용자에게도 이상하고, 개발자에게도 이상하다. “보이면 안 되는 화면인데 아주 잠깐 보였습니다”는 버그 리포트는 읽는 순간부터 기분이 좋지 않다.

TanStack Router에서는 beforeLoad를 라우트가 로드되기 전의 경계로 쓸 수 있다. 특히 pathless layout과 함께 쓰면 URL에는 드러나지 않는 인증 영역을 만들고, 그 아래 라우트 전체에 같은 규칙을 적용하기 좋다.

TSX
// src/routes/_authenticated.tsx
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: { redirect: location.href },
      })
    }
  },
  component: () => <Outlet />,
})

이 예시가 타입까지 자연스럽게 이어지려면 /login 라우트도 redirect search param을 검증할 수 있어야 한다. 보호 라우트에서 search: { redirect: location.href }를 넘기는데 로그인 라우트가 그 search 구조를 모른다면, 라우터 입장에서는 친절하게 길을 알려준 것이 아니라 모르는 짐을 얹어준 셈이 된다.

여기서 context.auth가 타입을 가지려면 루트 라우트에서 context 계약을 먼저 선언해야 한다. React hook을 라우터 바깥에서 직접 호출할 수는 없으므로, 컴포넌트에서 얻은 auth 상태를 RouterProvider의 context로 넘기는 식의 구조가 필요하다. 귀찮아 보여도 이 경계를 만들어두면 인증, QueryClient, feature flag 같은 공통 의존성이 라우트 계층 안에서 훨씬 덜 흐릿해진다.

TSX
// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'

interface RouterContext {
  auth: {
    isAuthenticated: boolean
  }
}

export const Route = createRootRouteWithContext<RouterContext>()({
  component: () => <Outlet />,
})

이 구조를 쓰면 라우터가 작은 DI 컨테이너처럼 동작한다. 부모 라우트에서 정의한 context와 검증된 search, loader 결과가 아래 라우트로 흘러간다. 전역 상태를 아무 곳에서나 꺼내 쓰는 방식보다 조금 엄격하지만, 나중에 “이 데이터가 어디서 왔지?”라고 묻는 시간을 줄여준다.

라우트 근처에 코드를 모으되, 라우트로 오해받지 않게 한다

파일 기반 라우팅을 쓰면 라우트와 관련된 컴포넌트, hook, 유틸을 가까이 두고 싶어진다. 이 자체는 좋은 습관이다. 문제는 src/routes 아래의 모든 파일이 라우트 후보처럼 보일 수 있다는 점이다. 가까이 두려다 생성기의 눈에 띄면, 우리는 의도하지 않은 라우트를 만들고 생성기는 자기가 맡은 일을 했을 뿐이라고 말할 것이다.

TanStack Router는 이런 colocation을 위해 ignore prefix를 제공한다. 기본 관례로 -components, -hooks처럼 -로 시작하는 파일이나 디렉터리는 라우트 생성에서 제외하는 식으로 쓸 수 있다.

TXT
src/routes/
  products.tsx
  products/
    $productId.tsx
    -components/
      ProductCard.tsx
    -hooks/
      useProductFilter.ts

이 규칙은 프로젝트가 커질수록 꽤 중요해진다. 라우트 단위로 코드를 모아두면 기능의 경계를 읽기 쉬워지고, - 접두사는 그 경계 안의 보조 파일이 URL 계약으로 승격되는 일을 막아준다. 작은 문자 하나가 폴더 구조의 평화를 지키는 셈이다.

도입할 때 자주 밟는 곳

TanStack Router를 도입할 때 모든 실수가 치명적인 것은 아니다. 다만 몇 가지는 초기에 기준을 세워두지 않으면 나중에 팀 전체의 습관으로 굳는다. 습관이 된 라우팅 실수는 고치기가 어렵다. 라우팅은 앱 전체에 얇게 퍼져 있기 때문이다.

첫째, routeTree.gen.tscreateFileRoute의 path 문자열을 손으로 고치지 않는 편이 좋다. 생성기가 관리하는 값은 생성기에게 맡겨야 한다. 파일을 옮기거나 이름을 바꿨다면 개발 서버나 generate/watch 흐름이 그 변경을 반영하게 두는 게 맞다.

둘째, search params는 항상 검증한다. URL은 외부 입력이고, 외부 입력은 기대와 다르게 들어온다. page=banana 같은 값은 농담처럼 보이지만, 실제 서비스에서는 농담이 아니라 로그에 남는 데이터다.

셋째, loader가 search에 의존한다면 loaderDeps를 명시한다. 컴포넌트에서 search를 읽는 것과 loader가 search 변경에 맞춰 다시 실행되는 것은 다른 문제다. 이 둘을 구분하지 않으면 화면 상태와 서버 데이터가 어긋나는 버그를 만들기 쉽다.

넷째, 공용 컴포넌트에서 router hook을 쓸 때는 from으로 문맥을 좁히는 습관을 들인다. 처음에는 없어도 잘 되는 것처럼 보이지만, 라우트가 늘어나면 타입 추론 비용과 자동완성 품질이 눈에 띄게 달라질 수 있다. 타입 시스템도 체력이 있다.

결국 라우터는 앱의 경계 설계다

TanStack Router를 쓰면 라우트 파일이 조금 더 엄격해지고, 생성 파일이 생기고, RegistervalidateSearch 같은 설정을 신경 써야 한다. 그래서 작은 앱에서는 과하게 느껴질 수 있다. 정말로 과한 경우도 있다. 모든 도구가 그렇듯, 라우터도 프로젝트의 크기와 복잡도에 맞아야 한다.

하지만 URL이 데이터 로딩과 인증, 필터 상태, 레이아웃 경계를 함께 책임지기 시작했다면 이야기가 달라진다. 그때 라우터는 페이지 이동 도구가 아니라 앱의 경계를 설계하는 도구가 된다. TanStack Router의 장점은 바로 그 경계를 타입으로 남긴다는 데 있다. 런타임에서 우연히 맞아떨어지는 구조가 아니라, 파일 구조와 route tree, params, search, loader가 서로 같은 계약을 바라보게 만드는 것이다.

결국 좋은 라우팅 구조는 사용자가 URL을 어떻게 움직이는지보다, 개발자가 그 URL을 얼마나 믿고 다룰 수 있는지에 가깝다. 믿을 수 없는 문자열을 믿을 수 있는 타입으로 바꾸고, 흩어진 데이터 로딩을 라우트 경계에 묶고, 화면 구조와 URL 구조를 함께 읽히게 만드는 것. TanStack Router를 도입한다면 이 지점까지 가져가야 한다. 그래야 단순히 라우터를 바꾼 것이 아니라, 앱의 뼈대를 다시 세운 것이 된다.

댓글

댓글을 불러오는 중...