[번역] The Beauty of TanStack Router
TkDodo의 The Beauty of TanStack Router를 번역한 글입니다.
라우터 선택은 아마 우리가 내려야 할 가장 중요한 아키텍처 결정 중 하나일 것입니다. 라우터는 단순히 node_modules
에 있는 또 하나의 의존성이 아닙니다. 그것은 전체 애플리케이션을 연결하는 핵심입니다. 페이지 간 탐색 시 사용자에게 훌륭한 경험을 제공할 뿐 아니라, 점점 더 많은 라우트를 추가해야 하는 상황에서도 개발자의 정신 건강을 지켜주는 훌륭한 DX까지 이상적으로 제공해야 하죠.
작년, 저는 서로 다른 라우팅 기술을 사용하는 두 개의 애플리케이션을 통합하는 임무를 맡았습니다. 우리는 다양한 옵션을 신중히 평가했습니다. 한 라우터의 몇몇 특징은 정말 마음에 들었고, 다른 라우터의 특정 부분도 아주 매력적이었습니다. 하지만 어느 것도 “완벽하게 다 갖춘” 느낌은 아니었어요.
다행히도, 마침 TanStack Router가 몇 달 전 v1에 도달한 상태였고, 우리는 이를 평가 대상에 포함시켰습니다. 결과는? 말 그대로, 모든 세계의 장점을 다 갖춘 최고의 선택이었죠. 🤩
이번 글에서는 TanStack Router가 다른 라우터들과 비교해 왜 돋보이는지, 그 핵심적인 기능들을 간단히 소개하고자 합니다. 각 기능에 대한 자세한 설명은 추후 포스트에서 다룰 예정입니다.
Full Disclosure 현재 저는 TanStack Router 프로젝트에 컨트리뷰터로 참여하고 있습니다. 하지만 처음 이 라우터를 검토하기 시작했을 때는 전혀 그런 입장이 아니었어요. 물론, 메인테이너들과 좋은 관계를 맺고 있는 건 도움이 되었지만, 실제로 프로젝트에 참여하게 된 계기는 제가 직접 겪은 몇 가지 한계를 개선할 수 있을 것 같았기 때문입니다.
Type Safe Routing
앞서 말했듯이, 라우팅은 웹 애플리케이션의 가장 근본적인 구성 요소 중 하나입니다. 그런데 라우팅 관련 타입들은 왜 이토록 … 엉망일까요? 정말, 거의 사후에 억지로 붙인 느낌입니다.
예를 들어보죠:
- •
useParams
훅이 있다고 해도, 반환 타입은 그냥Record<string, string | undefined>
입니다. 나머지는 직접 알아서 하라는 거죠.
- •
여기 <Link> 컴포넌트가 있습니다. 전체 페이지 리로드를 막고 클라이언트 사이드 전환을 해주죠. 좋습니다. 하지만 URL은 직접 만들어야 합니다 — 우리는 어떤 문자열이든 받아들입니다:
<Link to={/issues/${issueId}}>
그 URL이 유효한지 우리는 모릅니다. 그건 전적으로 당신 책임입니다.
마치 TypeScript가 존재하지 않던 시절의 잔재처럼 느껴집니다. 그땐 모든 것이 그냥 순수 자바스크립트로 구현되었고, 나중에 억지로 타입을 덧붙였는데, 그 타입이라는 것도 사실상 any보다 약간 나은 수준에 불과했죠. 솔직히 말하면, 많은 기존 라우터들이 정말로 그런 방식으로 만들어졌습니다. 그리고 그들도 어쩔 수 없이 TypeScript가 나오기 전부터 존재했다는 핸디캡이 있긴 했습니다.
TanStack Router는 TypeScript를 위해 태어났다고 해도 과언이 아닙니다 — 이 둘은 완벽한 궁합을 자랑하죠.
물론 타입 없이도 사용할 수는 있지만, TypeScript 지원이 이렇게 훌륭한데 굳이 그렇게 할 이유가 있을까요? 모든 기능은 완전히 추론 가능한 타입 안전성을 염두에 두고 설계되었습니다. 즉, 수동 타입 단언도 필요 없고, 타입 파라미터를 넘기기 위해 < >
를 사용할 필요도 없으며, 잘못된 사용을 하면 이해하기 쉬운 타입 에러 메시지도 제공합니다.
StrictOrFrom
물론 우리는 여전히 useParams
훅이 필요합니다. 그런데 그걸 타입 안전하게 만들 수 있을까요? 호출되는 위치에 따라 달라지는 거 아닌가요?
예를 들어 /issues/TSR-23
에 있을 때는 라우터로부터 issueId
를 받을 수 있지만, /dashboards/25
에 있을 때는 dashboardId
를 받게 되죠. 그리고 이건 숫자
일 수도 있고요. 🤔
그래서 TanStack Router는 단순히 useParams
훅만 제공하지 않습니다. 그 훅은 존재하긴 하지만, 필수 인자를 받습니다. 이상적으로는, 어디서 왔는지를 명시해주는 방식이죠:
const { issueId } = useParams({ from: '/issues/$issueId' })
// ^? const issueId: string
from
인자는 타입 안전하며, 모든 사용 가능한 경로들의 유니언 타입입니다. 즉, 틀릴 수가 없어요.
이 방식은 해당 컴포넌트가 issue 상세 페이지 전용으로 작성되고, 해당 라우트에서만 사용되는 경우에 가장 적합합니다. 만약 issueId
가 존재하지 않는 경로에서 이 훅을 사용하면 런타임에서 불변성 오류가 발생합니다.
지금쯤 이렇게 생각하실 수도 있겠네요:
“그럼 여러 라우트에서 재사용 가능한 컴포넌트는 어떻게 쓰지? 난 useParams
를 호출해서, 어떤 파라미터가 있는지에 따라 동작을 달리하고 싶은데?”
사실 대부분의 경우, useParams
를 사용하는 컴포넌트는 특정 라우트에 종속되는 경우가 많다고 생각하지만, TanStack Router는 이런 경우도 지원합니다. 단지 strict: false
를 넘기기만 하면 돼요:
const params = useParams({ strict: false })
// ^? const params: {
// issueId: string | undefined,
// dashboardId: number | undefined
// }
이 경우 런타임에서 실패하지 않으며, 반환값도 제가 본 다른 솔루션들보다 훨씬 더 잘 타입이 지정됩니다.
라우터가 모든 존재하는 경로에 대해 알고 있기 때문에, 존재할 수 있는 모든 파라미터의 유니언 타입을 계산할 수 있습니다. 솔직히 이건 정말 놀라운 일입니다. 🤯 그리고 어떤 방식을 사용할지는 명시적으로 선택해야 하므로, TanStack Router 기반 코드베이스는 읽는 것도 즐거운 경험이 됩니다. “이게 제대로 작동할까?” 하고 추측할 필요도 없고, 실제로 라우트를 안심하고 리팩터링할 수 있습니다.
라우트 객체 (The Route Object)
어떤 예제 코드에서는 Route.useParams()
를 직접 호출하는 걸 보셨을 수도 있습니다. 이 경우에는 아무 인자도 넘길 필요가 없습니다.
또한 getRouteApi라는 것도 있는데, 라우트 객체에 직접 접근할 수 없을 때 비슷한 용도로 사용됩니다.
개념적으로 이 둘은 같은 일을 합니다.
차이점이 있다면, 특정 from
에 미리 바인딩되어 있다는 것입니다.
그래서 StrictOrFrom
개념을 이해하고 있다면,Route.useParams()
는 곧 useParams({ from: Route.id })
와 같다고 생각하시면 됩니다.
Links
말할 필요도 없이, Link
컴포넌트도 동일하게 동작합니다. 다시 말해, 라우터는 모든 존재하는 라우트에 대해 알고 있기 때문에, Link
컴포넌트 역시 어떤 라우트가 존재하는지, 그리고 어떤 파라미터를 전달해야 하는지를 알 수 있습니다.
<Link to="/issues/$issueId" params={{ issueId: 'TSR-25' }}>
Go to details
</Link>
여기서는 issueId
를 전달하지 않거나, 다른 id를 전달하거나, 문자열이 아닌 id를 전달하거나, 존재하지 않는 URL로 이동하려고 하면 명확한 타입 오류가 발생합니다. 정말 아름답죠. 😍
검색 파라미터 상태 관리 (Search Param State Management)
“플랫폼을 활용하라”는 원칙은 정말 훌륭한 개념이며, 그중에서도 브라우저의 주소창만큼 플랫폼스러운 것도 없습니다. 주소창은 사용자가 직접 볼 수 있고, 복사해서 다른 사람과 공유할 수 있으며, 뒤로 가기/앞으로 가기 같은 내장된 undo/redo 기능도 제공합니다.
하지만 또다시, URLSearcjParams는 타입 측면에서 엉망입니다. 사용자가 직접 값을 조작할 수 있기 때문에, 안에 뭐가 들어 있는지 알 수 없다는 거죠.
일반적인 합의는 이렇습니다: 사용자 입력은 신뢰할 수 없고, 반드시 검증이 필요하다. 그렇다면, 검색 파라미터가 라우트에 연결되어 있고 신뢰할 수 없기 때문에 검증이 필요하며 검증 없이는 타입 안정성을 얻을 수 없다면 왜 대부분의 라우터는 검색 파라미터를 검증하지 않는 걸까요? 정말 모르겠습니다.
왜냐하면 그게 바로 TanStack Router가 하는 일이거든요. useSearch
훅을 사용하면 검색 파라미터에 접근할 수 있고, 이 역시 path 파라미터와 마찬가지로 StrictOrFrom
원칙에 기반해 동작합니다. 그리고 라우트 정의 안에서 직접 검증할 수 있습니다.
export const Route = createFileRoute('/issues')({
validateSearch: issuesSchema,
})
TanStack Router는 standard schema를 지원하기 때문에, issuesSchema
를 호환되는 어떤 라이브러리로든 작성할 수 있습니다. 직접 검증 함수를 작성해도 괜찮아요. 결국 그건 단지 Record<string, unknown>
을 원하는 타입으로 바꿔주는 함수일 뿐이니까요.
저는 요즘 arktype을 정말 즐겨 쓰고 있습니다.
import { type } from 'arktype'
const issuesSchema = type({
page: 'number > 0 = 1',
filter: 'string = ""',
})
export const Route = createFileRoute('/issues')({
validateSearch: issuesSchema,
})
이제 끝입니다! 이제 useSearch({ from: '/issues' })
를 호출하면 완전히 검증되고 타입이 지정된 결과를 얻을 수 있습니다. /issues
로 이동할 때 (useNavigate
를 사용하든 <Link>
를 사용하든), 타입이 지정된 search 옵션을 사용할 수 있게 되죠.
라우터는 중첩된 객체나 배열이라 해도 값을 올바르게 파싱하고 직렬화하는 작업을 알아서 처리해줍니다. 또한 불필요한 리렌더링을 방지하기 위해, 기본적으로 구조적 공유(Structural Sharing)를 사용하여 매번 새로운 객체를 생성하지 않도록 합니다.
Fine-grained Subscriptions
불필요한 리렌더링 이야기가 나왔으니 말인데요, URL이 한 번만 업데이트돼도 모든 구독 컴포넌트가 리렌더링된다는 점에 대해 우리는 충분히 이야기하지 않는 것 같아요. 페이지 간 내비게이션처럼 단순한 경우에는 큰 문제가 아닐 수 있지만, 여러 개의 중첩된 라우트가 각각 URL을 조작하려는 상황에서는 전체 페이지가 다시 렌더링되는 건 정말 낭비이고, 사용자 경험을 나쁘게 만들 수 있습니다.
실제로 우리 팀에서 겪었던 한 사례를 소개하자면, 많은 필터들이 URL에 저장된 테이블이 있는 라우트가 있었고,
사용자가 행(row)을 클릭하면 서브 라우트가 다이얼로그 형태로 열리는 구조였어요. 그런데 다이얼로그를 열기만 해도 테이블이 항상 리렌더링됐습니다.
왜냐하면 내부에서 useParams
를 사용하고 있었기 때문이죠. 문제는 이 테이블이 무한 스크롤 쿼리(Infinite Query)를 사용하고 있어서, 사용자가 여러 페이지를 로드해둔 상태라면, 다이얼로그를 열 때 눈에 띄게 버벅이는 현상이 생겼습니다.
이건 말 그대로 Redux나 Zustand 같은 글로벌 상태 관리 라이브러리가 등장한 이유와 똑같습니다. 우리는 이런 도구들을 통해 더 큰 상태 중 관심 있는 부분만 구독하고, 해당 부분이 바뀔 때만 컴포넌트를 리렌더링할 수 있었죠. 그렇다면, 왜 URL에서는 이런 방식이 안 되는 걸까요?
물론, The Uphill Battle of Memoization과 싸워볼 수도 있겠지만, TanStack Router는 훨씬 나은 해법을 제공합니다. 바로, selectors
를 통해 말이죠.
const page = useParams({
from: '/issues',
select: (params) => params.page,
})
만약 셀렉터 기반의 상태 관리 라이브러리(혹은 TanStack Query)를 사용해본 적이 있다면, 이 방식이 익숙하게 느껴질 거예요.
Selector는 더 큰 상태 덩어리에서 일부만 명시적으로 구독할 수 있는 방법이며, useParams
, useSearch
, useLoaderData
, useRouterState
같은 다양한 훅에서 사용할 수 있습니다.
저에게 이 기능은 TanStack Router의 최고의 기능 중 하나이며, 다른 라우터들과 가장 차별화되는 지점이라고 생각합니다. 🙌
파일 기반 라우팅 (File-Based Routing)
선언형 라우팅(Declarative Routing)은 제대로 작동하지 않습니다. 앱에서 그냥 <Route>
컴포넌트를 렌더링하기만 하면 된다는 아이디어는 처음엔 그럴싸하게 들릴 수 있어요. 하지만 곧 <Route>
가 여러 중첩된 컴포넌트에 흩어져 있을 경우 유지보수가 악몽이 된다는 사실을 깨닫게 됩니다. 실제로 저는 이런 식의 코드를 수없이 봐왔습니다:
<Route path="settings" element={<Settings />} />
코드베이스에서 이런 코드를 보고는, URL의 경로가 /settings
가 아니라는 걸 뒤늦게 깨닫게 되죠. 실제로는 /organization/settings
일 수도 있고, /user/$id/settings
일 수도 있어요 — 컴포넌트 트리를 위로 거슬러 올라가 보기 전에는 도무지 알 수 없습니다. 정말 끔찍하죠.
그렇다면, 그냥 여러 파일로 쪼개지 말고 한 파일에 몰아넣으면 되지 않냐고요? 물론 그렇게 할 수도 있습니다. 하지만 그렇게 되면 결국 이런 코드가 남습니다:
export function App() {
return (
<Routes>
<Route path="organization">
<Route index element={<Organization />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="user">...</Route>
</Routes>
)
}
이렇게 하면 아마도 거대한 중첩 Route 트리가 만들어질 텐데, 그 자체는 괜찮습니다. 하지만 또 다른 문제가 있습니다: 타입 안전성을 확보하려면 모든 라우트를 사전에 알아야 하는데, 이건 선언형 라우팅(declarative routing)과 근본적으로 호환되지 않습니다.
그래서 다음 단계로 나아간 개념이 바로 코드 기반 라우팅(Code-Based Routing)입니다. 어차피 모든 라우트 정의를 한데 모으고 싶다면, 아예 React 컴포넌트 바깥으로 빼내는 게 낫지 않을까요? 이렇게 하면 어떤 라우트가 존재하는지에 대한 추가적인 타입 정보도 확보할 수 있습니다. 이 방식은 React Routerd의 createBrowserRouter에서 사용되는 방식이고, TanStack Router의 createRouter도 마찬가지입니다.
파일 기반 라우팅(File-Based Routing)은 보통 직접 작성하던 라우트 설정을 파일 시스템 트리로 옮긴 것에 불과합니다. 논란의 여지가 있는 건 알지만, 저는 이 방식이 꽤 마음에 듭니다. 제가 보기엔 이게 가장 빠르게 시작할 수 있는 방법이고 버그 리포트에 나온 URL을 실제 렌더링되는 코드와 연결하기도 가장 쉬운 방법이에요. 또한 라우트 단위의 자동 코드 스플리팅을 구현하는 가장 좋은 방식이기도 합니다. 깊은 디렉토리 구조가 마음에 들지 않는다면, Flat Routes 방식도 쓸 수 있고, Virtual File Routes를 만들어 라우트 파일의 위치를 커스터마이징할 수도 있습니다.
어쨌든, TanStack Router는 코드 기반 라우팅과 파일 기반 라우팅을 모두 지원합니다. 왜냐하면 결국엔 모든 것이 코드 기반이니까요. 🤓
통합된 Suspense 지원 (Integrated Suspense Support)
저는 React와 Suspense와의 관계를 진지한 사이보다는 복잡한 썸 관계(?)처럼 느끼고 있지만, TanStack Router가 이를 기본적으로 지원한다는 점은 정말 마음에 들어요. 기본 설정상, 모든 라우트는 <Suspense>
경계와 <ErrorBoundary>
로 자동으로 감싸지기 때문에, 라우트 컴포넌트 안에서 그냥 useSuspenseQuery
를 사용하기만 하면, 데이터가 확실히 준비된 상태로 보장됩니다.
export const Route = createFileRoute('/issues/$issueId')({
loader: ({ context: { queryClient }, params: { issueId } }) => {
void queryClient.prefetchQuery(issueQuery(issueId))
},
component: Issues,
})
function Issues() {
const { issueId } = Route.useParams()
const { data } = useSuspenseQuery(issueQuery(issueId))
// ^? const data: Issue
}
이 말인즉슨, 로더에서 데이터를 기다리지 않더라도, 데이터가 undefined
일 가능성을 고려하지 않고 컴포넌트는 오직 정상적인 흐름(happy path)만 표현하는 데 집중할 수 있다는 뜻입니다. 🎉
React 전환 (React Transitions)
그리고 아직 더 많은 것들
아직 Route Context, 중첩 라우트, 쿼리 통합, 검색 미들웨어, 모노레포에서 시작하는 방법, 그리고 선택적으로 사용할 수 있는 SSR 계층인 TanStack Start에 대해서는 아직 언급조차 하지 않았습니다.
하지만 분명히 말할 수 있는 건: 이 모든 기능들 역시 당신의 뇌를 날려버릴 만큼 놀라울 거라는 것입니다.
TanStack Router의 가장 큰 문제는, 한 번 써보고 나면 다른 라우터로 돌아가기가 힘들어진다는 점입니다.
엄청난 DX와 타입 안정성에 익숙해져버리거든요. 그리고 이걸 React Query와 함께 쓰면, 저에겐 그야말로 생산성의 판도를 바꿔 놓은 조합이었습니다. 앞으로 이것에 대해 더 많이 공유할 생각에 벌써 기대가 됩니다.
Tanner, Manuel, Sean, Christopher에게 큰 찬사를 보냅니다 — 그들은 정말 아름다운 작품을 만들어냈어요.
세밀한 부분까지 신경 쓴 설계, 뛰어난 DX, 철저한 타입 안정성은 라우터의 모든 부분에서 분명하게 느껴집니다.