TanStack Router 완벽 가이드 — 타입 안전한 라우팅

TanStack Router 완벽 가이드 — 타입 안전한 라우팅

이 글의 핵심

TanStack Router는 React 생태계에서 경로(pathname), 검색 파라미터(search params), 라우트별 데이터 로딩(loader) 까지 TypeScript 타입으로 끝까지 연결하도록 설계된 라우팅 라이브러리입니다. «문자열로만 정의되는 URL»과 «런타임에야 터지는 쿼리 파싱 오류»를 줄이고, 링크·네비게이션·검증 로직을 한 타입 그래프 안에서 유지하는 것이 목표입니다.

이 글에서는 핵심 개념(루트 트리·컨텍스트·Outlet), Vite 기반 파일 기반 라우팅, validateSearch와 Zod로 검색 파라미터 고정, loader·beforeLoad로 데이터 페칭과 가드, 코드 스플리팅, React Router와의 비교, 마지막으로 실무 SPA에서의 라우팅 설계 패턴을 순서대로 정리합니다. 예제는 TanStack Router 1.x 계열과 공식 권장 설정을 전제로 하며, 세부 API는 프로젝트에 설치한 버전의 릴리스 노트를 함께 확인하는 것이 안전합니다.


1. TanStack Router가 지향하는 것

1.1 «URL을 애플리케이션 상태의 일부」로 취급

전통적인 SPA 라우팅은 «경로만» 다루는 경우가 많습니다. 그러나 실제 제품에서는 페이지네이션, 필터, 정렬, 탭, 모달 깊이처럼 pathname과 독립적으로 변하는 상태를 URL에 담아야 공유·북마크·뒤로 가기가 자연스럽습니다. TanStack Router는 검색 파라미터를 1급 시민으로 두고, JSON 친화적 직렬화검증 파이프라인을 기본 경로에 포함합니다.

1.2 타입 안전성의 의미

여기서 말하는 타입 안전성은 단순히 Linkto 문자열을 좁히는 수준을 넘어, 다음을 한 번에 묶는 것에 가깝습니다.

  • 경로 파라미터($postId 등)의 존재와 형태
  • 검색 파라미터의 필수·선택 필드, 기본값, 변환(문자열 → 숫자·불리언)
  • loader가 반환하는 데이터가 하위 컴포넌트·자식 라우트에 전달될 때의 형태

이렇게 하면 «라우트 정의 한 곳»에서 계약이 고정되고, 화면 컴포넌트는 파싱·캐스팅 지옥에서 벗어납니다.

1.3 핵심 빌딩 블록

개념적으로는 다음 네 가지를 이해하면 나머지는 조합 문제에 가깝습니다.

개념역할
Route Tree부모·자식 관계로 연결된 라우트 정의. 한 트리가 앱의 네비게이션 모델 전체를 표현합니다.
Root Route레이아웃·전역 컨텍스트·에러 처리의 기준점이 되는 최상위 라우트입니다.
Router 인스턴스트리와 히스토리·스크롤 복원 등 런타임 동작을 묶는 객체입니다.
<RouterProvider />React 트리에 라우터를 주입하고, 하위에서 훅·링크가 동작하도록 합니다.

파일 기반 라우팅을 쓰면 이 트리가 빌드 타임에 생성되어, 수동으로 createRoute를 길게 나열하는 부담을 줄일 수 있습니다.


2. 설치와 최소 구성(코드 기반 관점)

프레임워크 없이 개념을 익힐 때는 코드로 라우트 트리를 직접 구성하는 흐름이 이해에 도움이 됩니다. 실제 프로덕션에서는 다음 절의 파일 기반 라우팅으로 옮기는 경우가 많습니다.

// 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} />;
}

위 예시에서 주목할 점은 두 가지입니다. 첫째, 부모·자식 관계getParentRouteaddChildren으로 명시됩니다. 둘째, TypeScript가 라우터 인스턴스를 전역 타입 레지스트리에 등록하도록 Register 인터페이스 보강(augmentation) 을 하는 패턴이 공식적으로 안내됩니다. 이렇게 해야 Link, useNavigate, useParams 등에서 경로 문자열과 파라미터 타입이 서로 맞물립니다.

코드 기반 라우팅에서는 useParams({ from: postRoute.id })처럼 라우트 id로 앵커링하는 방식이 일반적입니다. 파일 기반 라우팅을 쓰면 createFileRoute가 만들어 주는 Route 객체에서 Route.useParams(), Route.useSearch(), Route.useLoaderData() 등을 바로 쓸 수 있어, 동일한 타입 이점을 더 적은 보일러플레이트로 얻는 경우가 많습니다.


3. 파일 기반 라우팅

3.1 왜 파일 기반인가

규모가 커질수록 createRoute 호출이 수십 개로 늘어납니다. 파일 시스템 규칙으로 URL 구조를 표현하면 다음 이점이 있습니다.

  • 폴더 = 중첩 라우트, $이름.tsx = 동적 세그먼트처럼 팀 규약이 시각적으로 드러납니다.
  • 라우트 추가 시 병합 지점을 수동으로 찾을 필요가 줄어듭니다.
  • 빌드 플러그인이 routeTree.gen.ts 를 생성해 트리와 타입을 자동 동기화합니다.

3.2 Vite 플러그인 설정

공식 문서는 @tanstack/router-plugin/vite 를 사용하는 흐름을 권장합니다. 플러그인 순서는 문서 기준으로 TanStack Router 플러그인이 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(),
  ],
});

3.3 관례적인 파일 이름

흔한 규칙은 다음과 같습니다.

  • __root.tsx: 루트 레이아웃. 전역 내비·테마·토스트 래퍼 등을 둡니다.
  • index.tsx: 특정 경로의 인덱스 라우트.
  • $paramName.tsx: 동적 세그먼트. 예: posts/$postId.tsx/posts/:postId.
  • .lazy.tsx 접미사(또는 플러그인 설정에 따른 lazy 규칙): 해당 라우트 UI를 지연 로드합니다.

각 라우트 파일은 createFileRoute('/경로')({ ... }) 형태로 내보내며, 플러그인이 이를 모아 단일 트리를 만듭니다.

// src/routes/posts/$postId.tsx — 개념 예시
import { createFileRoute } from '@tanstack/react-router';

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const res = await fetch(`/api/posts/${params.postId}`);
    if (!res.ok) throw new Error('not found');
    return res.json() as Promise<{ title: string; body: string }>;
  },
  component: PostDetail,
});

function PostDetail() {
  const { postId } = Route.useParams();
  const data = Route.useLoaderData();
  return (
    <article>
      <h1>{data.title}</h1>
      <p>{data.body}</p>
    </article>
  );
}

loader의 반환 타입이 곧 useLoaderData()의 타입으로 이어지므로, 페칭 계약이 라우트 정의에 고정됩니다. 에러는 라우트의 errorComponent 나 상위 onError, 혹은 React 에러 바운더리로 흡수하는 식으로 정책을 통일하면 됩니다.


4. 검색 파라미터 타입 안정성

4.1 validateSearch의 역할

검색 파라미터는 사용자 입력·외부 링크·북마크를 통해 항상 반쯤 신뢰할 수 없는 값입니다. TanStack Router는 validateSearch 를 통해 «URL에서 읽힌 값 → 앱이 안전하게 쓰는 값» 변환을 라우트 계층에서 끝내게 합니다. Zod와 함께 쓰는 패턴이 널리 쓰입니다.

// src/routes/search/index.tsx — 개념 예시
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';

const searchSchema = z.object({
  q: z.string().optional().default(''),
  page: z.coerce.number().int().positive().default(1),
  sort: z.enum(['new', 'old']).default('new'),
});

export const Route = createFileRoute('/search/')({
  validateSearch: searchSchema,
  component: SearchPage,
});

function SearchPage() {
  const { q, page, sort } = Route.useSearch();
  // 이 시점에서 page는 number, sort는 union으로 좁혀짐
  return (
    <div>
      검색어: {q}, 페이지: {page}, 정렬: {sort}
    </div>
  );
}

핵심은 “검증 실패 시 어떻게 할 것인가” 입니다. 스키마에서 예외를 던지면 라우트 로딩이 실패로 처리되므로, 제품 요구에 맞게 기본값으로 폴백할지, 에러 UI를 보일지, redirect로 안전한 URL로 보낼지를 팀 규약으로 정하는 것이 좋습니다.

4.2 네비게이션과 검색 파라미터

Linknavigate에서 이전 search를 기반으로 부분 갱신할 수 있습니다. 목록 화면에서 필터만 바꾸고 pathname은 유지하는 패턴이 대표적입니다. 타입이 연결되어 있으면 «존재하지 않는 필드를 넣는 실수»를 컴파일 단계에서 줄일 수 있습니다.


5. Loader와 데이터 페칭

5.1 loader가 적합한 것

loader는 해당 라우트가 활성화될 때 서버나 클라이언트 API에서 필요한 데이터를 준비하는 훅입니다. 다음이 일반적입니다.

  • 경로 파라미터·검색 파라미터가 확정된 뒤 실행된다는 점에서, 화면 컴포넌트 내부 useEffect 페칭보다 데이터 의존성이 명확합니다.
  • 라우트 단위로 캐시·중복 제거·재검증 정책을 걸기 좋습니다.

5.2 beforeLoad와 인증·권한

beforeLoadloader보다 앞서 실행되어 리다이렉트·권한 거부를 처리하기 좋습니다. 예를 들어 세션이 없으면 로그인 라우트로 보내고, 역할이 맞지 않으면 403 화면으로 보내는 식입니다. 이때 인증 상태는 라우터 컨텍스트나 상위에서 주입한 스토어에서 읽는 구성이 흔합니다.

5.3 TanStack Query와의 역할 분담

많은 앱에서 서버 상태 캐시·백그라운드 재검증은 TanStack Query가 담당하고, 라우터는 «언제·어떤 키로·어느 라우트 맥락에서» 불릴지를 정리합니다. loader 안에서 queryClient.ensureQueryData를 호출해 첫 페칭을 라우트 진입 시점에 보장하고, 컴포넌트에서는 useQuery로 구독만 하는 식의 이중 레이어가 자주 보입니다. 이 경우에도 검색 파라미터 타입이 흔들리지 않으면 쿼리 키 구성이 안정적입니다.


6. 코드 스플리팅

6.1 라우트 단위 지연 로딩

SPA가 커질수록 초기 번들에 모든 페이지를 넣는 것은 비용이 큽니다. TanStack Router는 라우트 컴포넌트를 동적 import로 분리하는 패턴을 공식적으로 지원합니다. 파일 기반 라우팅에서는 .lazy.tsx 같은 규칙으로 UI만 지연하고, loader는 가볍게 유지하거나 공유 모듈로 빼는 식으로 튜닝합니다.

Vite 플러그인의 autoCodeSplitting: true 는 이런 분리를 자동화하는 옵션으로 이해하면 됩니다. 팀에서는 «어떤 라우트까지는 초기 번들에 포함» 같은 예외 규칙을 두기도 합니다(랜딩·온보딩 등).

6.2 주의할 점

지연 로딩은 로딩 스켈레톤·에러 경계·스크롤 위치 이슈와 함께 봐야 합니다. 특히 검색 파라미터가 자주 바뀌는 화면에서는 불필요한 레이아웃 리마운트가 없는지 React DevTools로 확인하는 것이 좋습니다.


7. React Router vs TanStack Router

두 라이브러리 모두 React에서 널리 쓰이지만, 설계 중심이 다릅니다. 단순히 “호환되는가”보다 제품이 URL을 얼마나 엄격하게 다루는지로 고르는 편이 맞습니다.

구분React RouterTanStack Router
타입 스토리v6.4+ 데이터 라우터·타입 생성 도구 등으로 개선 중. 팀 설정에 따라 강도가 달라짐.검색 파라미터·loader·링크를 타입 중심으로 설계한 라이브러리.
검색 파라미터useSearchParams 등으로 처리 가능하나, 앱 전반의 스키마·검증을 일관되게 묶는 경험은 팀 구현 비중이 큼.JSON-first·검증 파이프라인이 기본 스토리에 가깝게 포함.
데이터 로딩Loader 패턴 지원. 프레임워크(예: Remix)와 조합 시 강점이 큼.라우트 loader·beforeLoad라우터 중심 흐름이 명확.
생태계·채용압도적으로 넓음. 레거시 자료 많음.상대적으로 작으나 성장 중. TanStack Query와 시너지 언급이 많음.
마이그레이션기존 코드베이스가 이미 RR 기반이면 비용 대비 이득을 따져야 함.URL·상태 모델을 재정의할 여유가 있을 때 유리.

실무 결론은 단순합니다. “URL을 상태의 소스 오브 트루스로 쓰고, 검증·타입·데이터 로딩을 한 라우트 계층에 모으겠다” 면 TanStack Router가 설계 목적에 가깝습니다. 반면 기존 React Router 생태계·레퍼런스·플러그인 의존이 크면 단기적으로는 React Router에 머무르고, 타입은 코드 생성·Zod·래퍼 컴포넌트로 보강하는 전략이 현실적일 수 있습니다.


8. 실전 SPA 라우팅 설계

8.1 레이아웃과 중첩

대시보드 / 설정 / 상세처럼 공통 크롬을 유지하려면 부모 라우트에 레이아웃을 두고 자식에서 본문만 바꾸는 중첩 라우트가 자연스럽습니다. 이때 부모 loader에서 공통 전제 데이터(예: 워크스페이스 정보)를 가져오고, 자식은 세부 리소스만 추가로 가져가면 워터폴을 줄일 수 있습니다. 다만 부모 loader가 무거워지면 전역 스플래시가 길어지므로, 부분 UI 스트리밍·스켈레톤 정책과 함께 설계해야 합니다.

8.2 인증과 라우트 가드

beforeLoad 에서 세션을 검사하고, 실패 시 로그인 경로로 redirect 하는 패턴이 흔합니다. 중요한 점은 “클라이언트만 믿지 말 것” 입니다. 진짜 보호는 API·서버에서 이루어져야 하며, 라우터 가드는 UX와 불필요한 요청 방지에 가깝습니다.

8.3 에러·404·리다이렉트

전역 notFoundComponent, 라우트별 errorComponent, onError 를 조합해 복구 가능한 오류(네트워크 재시도)와 불가능한 오류(존재하지 않는 ID)를 구분하면 운영이 수월합니다. 특히 검색 파라미터 검증 실패는 사용자가 잘못된 링크를 공유했을 때 자주 발생하므로, 사용자 친화적 메시지와 안전한 기본 URL을 준비하는 것이 좋습니다.

8.4 Prefetch와 체감 속도

목록에서 상세로 자주 이동한다면 호버·뷰포트 진입 시점에 router.preloadRoute 를 검토할 수 있습니다. 다만 모바일·저사양 환경에서는 과한 프리로드가 역효과이므로, 우선순위·취소 조건을 명확히 하는 것이 좋습니다.


9. 자주 묻는 질문

Q. Next.js App Router와 함께 쓸 수 있나요?
A. 가능한 조합은 있으나, 프레임워크가 이미 라우팅·서버 컴포넌트·캐시 시맨틱을 갖춘 경우에는 중복·충돌을 피하기 어렵습니다. Vite·SPA 또는 TanStack Start 같은 스택에서 이점이 분명합니다.

Q. 검색 파라미터에 복잡한 객체를 넣어도 되나요?
A. 기술적으로는 가능하지만, URL 길이·캐시·로그·개인정보 노출 측면에서 항상 타당하지는 않습니다. 짧은 토큰·서버 세션·로컬 스토리지와 역할을 나누는 편이 안전한 경우가 많습니다.

Q. React Router에서 옮길 때 가장 큰 작업은 무엇인가요?
A. URL 스키마와 상태 모델을 다시 정의하는 일입니다. 특히 검색 파라미터 규약loader 중심 데이터 흐름을 맞추는 데 시간이 듭니다.

Q. ESLint 지원은?
A. 공식 ESLint 플러그인으로 라우트 속성 순서 등을 강제할 수 있습니다. 대규모 팀일수록 규칙을 초기에 넣는 것이 좋습니다.


10. 정리

TanStack Router는 “URL과 라우트 계약을 타입으로 고정한다” 는 목표 아래, 파일 기반 트리, 검색 파라미터 검증, loader·beforeLoad, 코드 스플리팅을 하나의 이야기로 묶습니다. React Router와의 선택은 순수 기술 우열이라기보다 제품이 URL을 얼마나 엄격하게 다루는지, TanStack Query와 어떻게 역할을 나눌지에 가깝습니다.

새 프로젝트라면 공식 Vite 설치 가이드로 스캐폴딩한 뒤, 작은 도메인 하나validateSearchloader를 끝까지 적용해 보는 것이 학습 곡선을 줄이는 지름길입니다. 배포 전에는 git pushnpm run deploy 절차를 지키고, 라우터·플러그인 메이저 버전은 릴리스 노트를 반드시 확인하십시오.

참고