
React 애플리케이션이 커질수록 라우팅은 단순한 화면 전환 문제가 아니라, URL 설계, 데이터 로딩, 권한 처리, 상태 동기화까지 함께 다뤄야 하는 구조의 문제가 된다. TanStack Router는 이 지점을 정면으로 다룬다. 파일 기반 라우팅을 중심에 두고, URL 파라미터와 검색 파라미터, loader 데이터까지 타입 안전하게 연결해서 애플리케이션의 뼈대를 더 예측 가능하게 만든다.
이 글은 TanStack Router를 처음 도입하거나, 이미 써 봤지만 실무 기준의 정리된 원칙이 필요한 개발자를 위한 가이드다. 설정 순서, 파일 구성 규칙, search params 처리 방식, loader의 책임, 흔한 실수까지 실제 프로젝트에서 바로 적용할 수 있는 기준으로 정리했다.
TanStack Router를 보는 관점
TanStack Router는 단순히 라우트 몇 개를 선언하는 라이브러리로 접근하면 장점을 제대로 살리기 어렵다. 이 도구의 핵심은 애플리케이션의 라우팅 계층을 타입 안전하게 설계하고, 데이터 흐름의 기준점을 라우트로 끌어올리는 데 있다.
- 파일 기반 라우팅 우선: 특별한 이유가 없다면 코드 기반 라우팅보다 파일 기반 라우팅을 먼저 선택하는 편이 좋다. 플러그인이 생성과 최적화를 맡아 주기 때문에 구조가 명확해지고 유지보수도 쉬워진다.
- 강한 타입 안전성: URL 파라미터, search params, loader 결과를 컴파일 타임에 검증할 수 있다. 라우팅 경계에서 애매한
any를 남기지 않는 설계에 잘 맞는다. - 데이터 중심 라우팅: 라우트는 화면 매칭만 담당하지 않는다.
loader,validateSearch,beforeLoad같은 지점을 통해 데이터 로딩과 상태 검증, 접근 제어의 중심이 된다.
프로젝트 설정에서 먼저 잡아야 할 기준
Vite 플러그인 순서
TanStack Router를 Vite에서 사용할 때 가장 먼저 확인할 부분은 플러그인 선언 순서다. tanstackRouter 플러그인은 코드 생성을 담당하므로 react()보다 앞에 와야 한다. 이 순서를 틀리면 생성 파일이 기대대로 만들어지지 않거나 개발 경험이 흔들릴 수 있다.
// vite.config.ts
import { tanstackRouter } from '@tanstack/router-plugin/vite';
export default defineConfig({
plugins: [
tanstackRouter({
autoCodeSplitting: true, // 라우트별 자동 청크 분리
}),
react(),
],
});autoCodeSplitting을 켜 두면 라우트 단위로 청크를 나누는 기본 흐름을 자연스럽게 가져갈 수 있다. 규모가 있는 앱에서는 초기에 켜 두는 편이 보통 더 낫다.
Router 인스턴스 생성과 타입 등록
플러그인이 생성하는 routeTree.gen.ts는 애플리케이션 전체 라우트 구조의 기준점이다. 이 파일은 자동 생성 결과물이므로 직접 수정하면 안 된다. 실제로 손댈 파일은 라우트 정의 파일과 Router 생성 코드이다.
// src/main.tsx
import { routeTree } from './routeTree.gen';
const router = createRouter({
routeTree,
defaultPreload: 'intent', // 마우스 호버 시 데이터를 프리로드하여 체감 성능 향상
});
// 타입 안전성을 위해 전역 모듈에 등록
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}defaultPreload: 'intent'는 사용자가 이동할 가능성이 높은 시점에 데이터를 미리 불러와서 체감 성능을 높이는 데 도움이 된다. 또한 모듈 등록을 해 두어야 Link, useNavigate, 라우트 관련 API가 전체 라우트 트리 정보를 타입으로 연결할 수 있다.
파일 기반 라우트 구성 패턴
TanStack Router의 생산성은 파일 시스템 규칙을 얼마나 일관되게 지키느냐에 크게 좌우된다. 기준 디렉터리는 보통 src/routes이며, 각 파일은 반드시 export const Route = ... 형식으로 라우트 객체를 내보내야 생성기가 인식한다.
핵심 파일 규칙
__root.tsx: 모든 라우트의 최상위 부모 레이아웃이다. 전역 네비게이션, 푸터, 공통 에러 UI, 404 처리 지점을 두기 좋다.index.tsx: 루트 경로인/에 대응한다.posts.$postId.tsx:/posts/123같은 동적 파라미터 경로를 표현한다._app.tsx,_dashboard.tsx: URL에는 드러나지 않지만 공통 레이아웃을 적용할 때 쓰는 패스리스 레이아웃이다.
루트 레이아웃 예시
먼저 전체 앱의 공통 뼈대를 __root.tsx에 둔다. 자식 라우트가 들어올 지점은 Outlet으로 명확히 표시한다.
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => (
<>
<nav>
<Link to="/">Home</Link>
<Link to="/posts">Posts</Link>
</nav>
<hr />
<Outlet /> {/* 자식 라우트가 렌더링되는 지점 */}
</>
),
})이 구조의 장점은 단순하다. URL 계층과 레이아웃 계층이 자연스럽게 맞물리고, 앱 전체의 공통 UI를 한 군데에서 관리할 수 있다.
중첩 라우트 예시
부모 레이아웃과 자식 라우트는 URL 구조를 그대로 반영하는 편이 좋다. 이렇게 해야 라우트 파일만 봐도 화면 구성과 데이터 흐름을 빠르게 이해할 수 있다.
// src/routes/posts.tsx
import { Outlet, createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts')({
component: PostsLayout,
})
function PostsLayout() {
return (
<section>
<h1>Posts</h1>
<Outlet />
</section>
)
}
// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: ({ params }) => fetchPost(params.postId),
component: PostDetail,
})여기서 중요한 점은 params.postId가 라우트 경로와 연결된 타입 정보를 기반으로 다뤄진다는 점이다. 동적 세그먼트를 문자열로 임의 처리하는 습관보다 훨씬 안전하다.
패스리스 레이아웃 예시
인증된 사용자 영역이나 앱 셸처럼 URL에는 노출되지 않지만 공통 레이아웃은 필요한 경우가 많다. 이런 구조에 패스리스 레이아웃이 잘 맞는다.
// src/routes/_app.tsx
import { Outlet, createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_app')({
component: AppLayout,
})
function AppLayout() {
return (
<div className="app-shell">
<Sidebar />
<main>
<Outlet />
</main>
</div>
)
}
// src/routes/_app.dashboard.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_app/dashboard')({
component: DashboardPage,
})이 패턴을 쓰면 URL은 깔끔하게 유지하면서도 공통 레이아웃 책임을 라우트 계층 안에 정리할 수 있다.
Route Colocation 규칙
라우트와 관련된 컴포넌트, 훅, 유틸을 라우트 근처에 같이 두고 싶을 때가 많다. 이때 - 접두사를 사용하면 플러그인이 해당 디렉터리를 라우트 파일로 해석하지 않고 건너뛴다.
src/routes/
├── posts/
│ ├── index.tsx
│ ├── $postId.tsx
│ ├── -components/ # 라우트 생성에서 제외됨
│ │ └── PostCard.tsx
│ └── -hooks/
│ └── usePostActions.ts이 규칙은 프로젝트가 커질수록 빛을 발한다. 라우트 단위로 파일을 모아 두되, 생성 규칙은 깨지지 않게 유지할 수 있기 때문이다.
Search Params와 데이터 로딩을 함께 설계하기
TanStack Router를 실무에서 안정적으로 쓰려면 search params를 단순 문자열 쿼리로 다루지 말고, 라우트 경계에서 검증된 애플리케이션 상태로 변환해야 한다. 이때 validateSearch, loaderDeps, loader를 역할별로 분리하는 패턴이 중요하다.
타입 안전한 Search Params
아래 예시는 Zod를 사용해 URL 쿼리 파라미터를 파싱하고, loader가 실제로 의존하는 값만 명시적으로 연결하는 방식이다.
import { z } from 'zod';
const searchSchema = z.object({
page: z.coerce.number().catch(1),
filter: z.string().catch(''),
});
export const Route = createFileRoute('/products')({
validateSearch: (search) => searchSchema.parse(search),
loaderDeps: ({ search: { page, filter } }) => ({ page, filter }), // 의존성 명시
loader: ({ deps }) => fetchProducts(deps), // deps 변경 시 loader 재실행
component: ProductListComponent,
});실무에서는 아래 세 가지를 기준으로 삼으면 흔들림이 적다.
validateSearch는 URL 바깥 세계의 값을 애플리케이션 내부 타입으로 바꾸는 경계다.loaderDeps에는 loader가 실제로 참조하는 값만 넣는다.- Search Params를 직접
loader안에서 읽기보다,loaderDeps -> loader(deps)흐름으로 고정하는 편이 갱신 조건을 추적하기 쉽다.
이 패턴을 지키면 search params는 바뀌었는데 데이터는 갱신되지 않는 식의 버그를 크게 줄일 수 있다.
Navigation과 Link를 문자열이 아닌 구조로 다루기
TanStack Router의 링크와 이동 API는 문자열 기반 라우팅보다 한 단계 더 안전한 추상화를 제공한다. 라우트 트리 등록이 제대로 되어 있다면, 존재하지 않는 경로나 누락된 파라미터를 더 이른 시점에 잡을 수 있다.
// 타입 안전한 링크
<Link
to="/posts/$postId"
params={{ postId: '123' }}
search={{ tab: 'comments' }}
>
View Comments
</Link>
// 프로그래매틱 이동
const navigate = useNavigate()
const updateFilter = (filter: string) => {
navigate({ search: (prev) => ({ ...prev, filter }) })
}특히 search params를 함수형 업데이트로 다루는 방식은 기존 상태를 보존하면서 필요한 값만 바꾸는 데 유용하다. 필터, 정렬, 페이지네이션 같은 UI에서 안정적으로 쓸 수 있다.
Next.js App Router와 비교해서 이해하기
TanStack Router를 처음 접하는 개발자 중에는 Next.js App Router 경험이 있는 경우가 많다. 두 도구가 완전히 같은 추상화를 제공하는 것은 아니지만, 대응 관계를 느슨하게 잡아 두면 개념 전환에 도움이 된다.
page.tsx→index.tsxlayout.tsx→__root.tsx또는_layout.tsxloading.tsx→pendingComponenterror.tsx→errorComponent
다만 이 비교는 어디까지나 개념적 대응이다. 실제 동작 모델과 책임 분리는 다를 수 있으니, 이름만 같다고 동일한 설계라고 보면 곤란하다.
Loader 실행 시점은 프로젝트 형태에 따라 달라진다
Loader는 언제 실행되는가, 이 질문은 생각보다 중요하다. 특히 클라이언트 전용 앱인지, SSR이나 풀스택 구성을 포함하는지에 따라 설계 기준이 달라진다.
- Pure SPA:
loader는 클라이언트 사이드에서 실행된다. - TanStack Start 기반 풀스택 환경:
loader가 서버에서 실행되거나 SSR 과정에서 사전 로딩될 수 있다.
즉, loader를 작성할 때는 단순히 데이터 fetch 함수로만 생각하지 말고, 현재 프로젝트의 실행 컨텍스트를 같이 고려해야 한다.
실무에서 자주 나오는 안티 패턴
TanStack Router는 규칙을 잘 지키면 강력하지만, 몇 가지 흔한 실수를 반복하면 장점이 빠르게 사라진다. 아래 항목은 도입 초기에 특히 자주 마주치는 문제들이다.
- 코드 기반 라우팅 남용 특수한 케이스가 아닌데도 코드 기반 라우팅을 넓게 쓰면 구조 파악이 어려워지고, 파일 기반 생성기의 장점을 잃게 된다.
- loaderDeps 누락 search params는 변했는데 loader 재실행 조건에 연결하지 않으면 화면과 데이터가 어긋나는 버그가 생기기 쉽다.
- Route 객체 export 누락
export const Route = ...형식을 지키지 않으면 파일 기반 생성기가 해당 파일을 무시할 수 있다. - routeTree.gen.ts 수동 수정 자동 생성 파일은 개발 서버 실행이나 빌드 과정에서 덮어써진다. 시간을 들여 수정해도 유지되지 않는다.
정리
TanStack Router의 핵심은 파일 기반 라우팅 자체보다, 라우팅을 데이터와 타입의 관점에서 다시 설계하게 만든다는 점에 있다. 라우트 파일 구조를 일관되게 유지하고, search params를 검증된 상태로 다루고, loader 의존성을 명시적으로 연결하면 라우팅 계층이 훨씬 예측 가능해진다. React 앱이 커질수록 이런 기준은 선택이 아니라 유지보수 비용을 줄이는 기본기 쪽에 가깝다.
더 읽어보기
2026.05.05
Changesets 실무 운영 가이드: 내부 의존성, prerelease, 운영 Q&A
이 글은 Changesets로 모노레포 릴리즈를 관리하는 시리즈의 3편이다. 1편에서는 changeset 파일을 작성하는 방법을, 2편에서는 GitHub Actions로 Version Packages PR과 publish를 자동화하는 흐름을 다뤘다. 이번 글에서는 실제 운영 중 자주 헷…
2026.05.05
Changesets와 GitHub Actions로 릴리즈 자동화하기
이 글은 Changesets로 모노레포 릴리즈를 관리하는 시리즈의 2편이다. 1편에서 changeset 파일로 변경 의도를 기록했다면, 이번 글에서는 그 파일을 기준으로 GitHub Actions가 Version Packages PR을 만들고 publish까지 이어지는 흐름을 다룬다.…
2026.05.05
Changesets로 모노레포 버전 관리 시작하기
이 글은 Changesets로 모노레포 릴리즈를 관리하는 시리즈의 1편이다. 여기서는 Changesets를 왜 도입하는지, 어떤 파일을 남기는지, PR 단계에서 semver 판단을 어떻게 기록하는지에 집중한다. 1편: Changesets로 모노레포 버전 관리 시작하기 2편: Change…
2026.04.17
Code Server
처음 코드 서버를 만들었던 건 아마도 3년쯤 전의 일이다. 맥미니만 있던 탓에 밖에서 개발하는 게 쉽지 않았고, 아이패드로 언제 어디서든 개발을 하고 싶었던 끝에 찾아낸 해결책이었다. 다행히 집에는 Synology NAS가 있었고, Docker를 통해 어렵지 않게 코드 서버를 만들 수…
2026.05.10
프레임워크 밖의 상태를 UI 안으로 들이는 법
외부 상태는 프레임워크 내부에서 만들어진 상태가 아니다. React의 useState, Vue의 ref, Svelte의 $state, Solid의 createSignal처럼 프레임워크가 직접 소유하고 추적하는 값이 아니라, 프레임워크 바깥에서 먼저 존재하고 바깥에서 변하는 값이다. We…
2026.05.10
Scope & Disposal — 인스턴스의 생애주기와 정리
이전 포스트를 통해 우리는 인스턴스가 어떻게 만들어지는지 살펴봤다. 이번 파트는 그 반대 방향, 인스턴스가 어떻게 살고 어떻게 사라지는가를 다룬다. fluo DI의 세 가지 스코프(singleton, request, transient)의 생애주기와 dispose()의 역순 정리 메커니즘…
2026.05.10
Instance Creation — resolve에서 new까지
이전 포스트에서 우리는 “어느 provider를 어느 캐시에 저장할 것인가”를 결정하는 Planning 레이어를 살펴봤다. 이번 파트에서는 그 다음 단계, 즉 실제 인스턴스를 생성하는 Execution 레이어를 따라간다. 출발점은 container.resolve(token)이고, 마지막…
2026.05.10
작은 Store 클래스가 상태 관리의 출발점이 되는 이유
상태 관리 라이브러리를 볼 때 먼저 눈에 들어오는 것은 보통 기능 목록이다. selector가 있는지, devtools와 연결되는지, persistence를 지원하는지, React 훅이 준비되어 있는지 같은 것들 말이다. 그런데 @ilokesto/store의 Store 클래스를 보면 질…
댓글
댓글을 불러오는 중...