개발자 경험에 관한 결정
TanStack Router를 처음 사용하면 다음 주제를 중심으로 많은 질문이 생기는 경우가 많습니다.
왜 이런 방식으로 해야 하나요?
왜 저 방식이 아니라 이 방식으로 처리하나요?
이 방식에 익숙한데 왜 바꿔야 하나요?
모두 타당한 질문입니다. 대부분의 경우 사람들은 서로 매우 비슷한 라우팅 라이브러리를 사용하는 데 익숙합니다. 비슷한 API와 개념, 비슷한 작업 방식을 제공하기 때문입니다.
하지만 TanStack Router는 다릅니다. 평범한 라우팅 라이브러리가 아닙니다. 평범한 상태 관리 라이브러리도 아니며, 어느 면에서도 평범하지 않습니다.
[!TIP] 이 가이드의 컴포넌트와 코드 스니펫 예시는 React를 사용하지만 동일한 원칙이 Solid에도 적용됩니다. 프레임워크의 구문과 API만 다를 뿐 기본 개념과 설계 결정은 같습니다.
TanStack Router의 탄생 배경
TanStack Router는 복잡한 대시보드를 구동하는 데 필요한 타입 안전성을 훼손하지 않으면서 최고 수준의 URL 검색 매개변수 경험을 제공하는 클라이언트 측 라우팅 솔루션을 Nozzle.io가 필요로 했던 데서 시작되었다는 점을 기억해야 합니다.
따라서 TanStack Router는 처음부터 타입 안전성과 개발자 경험이 최고 수준이 되도록 설계의 모든 측면을 세심하게 고려했습니다.
TanStack Router는 이를 어떻게 달성하나요?
TypeScript! TypeScript! TypeScript!
TanStack Router의 모든 측면은 최대한 타입 안전하도록 설계되었으며, 이를 위해 TypeScript의 타입 시스템을 최대한 활용합니다. 개발자 경험을 원활하게 만들기 위해 매우 고급스럽고 복잡한 타입, 타입 추론 및 기타 기능을 사용합니다.
하지만 이를 위해 라우팅 분야의 일반적인 관행에서 벗어나는 몇 가지 결정을 내려야 했습니다.
- 라우트 구성 보일러플레이트?: TypeScript가 라우트 타입을 최대한 추론할 수 있는 방식으로 라우트를 정의해야 합니다.
- 라우터의 TypeScript 모듈 선언?: TypeScript의 모듈 선언을 사용해
Router인스턴스를 애플리케이션의 나머지 부분에 전달해야 합니다. - 코드 기반 라우팅보다 파일 기반 라우팅을 권장하는 이유?: 라우트를 정의하는 기본 방식으로 파일 기반 라우팅을 권장합니다.
요약하면, TanStack Router의 개발자 경험에 관한 모든 설계 결정은 라우트 구성의 제어력, 유연성, 유지 관리성을 훼손하지 않으면서 최고 수준의 타입 안전성 경험을 제공하기 위해 이루어졌습니다.
라우터 구성은 왜 이런 방식인가요?
TypeScript의 추론 기능을 최대한 활용하려면 _제네릭_이 매우 유용하다는 것을 곧 알게 됩니다. 따라서 TanStack Router는 라우트 타입이 최대한 추론되도록 모든 곳에서 제네릭을 사용합니다.
즉, TypeScript가 라우트 타입을 최대한 추론할 수 있는 방식으로 라우트를 정의해야 합니다.
JSX로 라우트를 정의할 수 있나요?
라우터의 라우트 구성 타입을 TypeScript가 추론할 수 없으므로 JSX로 라우트를 정의하는 것은 고려할 수 없습니다.
// ⛔️ This is not possible
function App() {
return (
<Router>
<Route path="/posts" component={PostsPage} />
<Route path="/posts/$postId" component={PostIdPage} />
{/* ... */}
</Router>
// ^? TypeScript cannot infer the routes in this configuration
)
}
이렇게 하면 <Link> 컴포넌트의 to prop에 수동으로 타입을 지정해야 하고 런타임이 되기 전까지 오류를 발견할 수 없으므로 실행 가능한 선택지가 아닙니다.
중첩 객체의 트리로 라우트를 정의하면 어떨까요?
// ⛔️ This file will just keep growing and growing...
const router = createRouter({
routes: {
posts: {
component: PostsPage, // /posts
children: {
$postId: {
component: PostIdPage, // /posts/$postId
},
},
},
// ...
},
})
처음 보면 좋은 생각처럼 보입니다. 전체 라우트 계층 구조를 한 번에 시각화하기 쉽기 때문입니다. 하지만 이 방식에는 대규모 애플리케이션에 적합하지 않게 만드는 큰 단점이 몇 가지 있습니다.
- 확장성이 높지 않습니다: 애플리케이션이 커지면 트리도 커져 관리하기 어려워집니다. 또한 모든 것이 한 파일에 정의되므로 유지 관리가 매우 어려워질 수 있습니다.
- 코드 분할에 적합하지 않습니다: 각 컴포넌트를 수동으로 코드 분할한 다음 라우트의
component속성에 전달해야 하므로, 계속 커지는 라우트 구성 파일이 더욱 복잡해집니다.
중첩 컨텍스트, 로더, 검색 매개변수 검증 등 라우터의 기능을 더 많이 사용하기 시작하면 상황은 더욱 나빠집니다.
그렇다면 라우트를 정의하는 가장 좋은 방법은 무엇인가요?
라우트를 정의하는 가장 좋은 방법은 라우트 트리 외부에서 라우트 구성 정의를 추상화하는 것입니다. 그런 다음 라우트 구성을 하나의 일관된 라우트 트리로 연결하고 이를 createRouter 함수에 전달합니다.
이 방식으로 라우트를 정의하는 방법은 코드 기반 라우팅에서 자세히 알아볼 수 있습니다.
[!TIP] 코드 기반 라우팅이 조금 번거롭게 느껴지나요? 파일 기반 라우팅이 라우트를 정의하는 기본 방식인 이유를 확인해 보세요.
타입 추론을 위한 Router 인스턴스 선언
왜
Router를 선언해야 하나요?
이런 선언은 너무 복잡합니다...
라우트를 트리로 구성하고 모든 제네릭이 올바르게 작동하도록 createRouter를 사용해 Router 인스턴스에 전달한 다음에는 이 정보를 애플리케이션의 나머지 부분에 전달해야 합니다.
이를 위해 다음 두 가지 방식을 고려했습니다.
- 가져오기:
Router인스턴스를 만든 파일에서 가져와 컴포넌트에서 직접 사용할 수 있습니다.
import { router } from '@/src/app'
export const PostsIdLink = () => {
return (
<Link<typeof router> to="/posts/$postId" params={{ postId: '123' }}>
Go to post 123
</Link>
)
}
이 방식의 단점은 사용하려는 모든 파일에 전체 Router 인스턴스를 가져와야 한다는 것입니다. 번들 크기가 커지고 관리가 번거로워질 수 있으며, 애플리케이션이 커지고 라우터 기능을 더 많이 사용할수록 상황은 더욱 악화됩니다.
- 모듈 선언: TypeScript의 모듈 선언을 사용해
Router인스턴스를 모듈로 선언하면 가져오지 않고도 애플리케이션 어디에서나 타입 추론에 사용할 수 있습니다.
애플리케이션에서 한 번만 수행하면 됩니다.
// src/app.tsx
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
그러면 가져오지 않고도 앱 어디에서나 자동 완성 기능을 활용할 수 있습니다.
export const PostsIdLink = () => {
return (
<Link
to="/posts/$postId"
// ^? TypeScript will auto-complete this for you
params={{ postId: '123' }} // and this too!
>
Go to post 123
</Link>
)
}
오버헤드와 보일러플레이트가 가장 적으면서 확장성과 유지 관리성이 가장 높은 방식이라고 판단해 모듈 선언을 선택했습니다.
파일 기반 라우팅을 라우트 정의의 기본 방식으로 사용하는 이유
문서에서 파일 기반 라우팅을 권장하는 이유는 무엇인가요?
라우트를 한 파일에 정의하는 데 익숙한데 왜 바꿔야 하나요?
TanStack Router 문서에서 곧 알게 되겠지만 라우트를 정의하는 기본 방법으로 파일 기반 라우팅을 권장합니다. 파일 기반 라우팅이 라우트를 정의하는 가장 확장 가능하고 유지 관리하기 쉬운 방법이라고 판단했기 때문입니다.
앞서 언급했듯이 TanStack Router는 높은 수준의 타입 안전성과 유지 관리성을 요구하는 복잡한 애플리케이션을 위해 설계되었습니다. 이를 위해 TypeScript가 라우트 타입을 최대한 추론할 수 있도록 라우터를 정밀한 방식으로 구성합니다.
TanStack Router로 기본 애플리케이션을 설정할 때의 주요 차이점은 라우트 구성에서 현재 라우트의 부모 라우트를 반환하는 함수를 getParentRoute에 제공해야 한다는 것입니다.
import { createRoute } from '@tanstack/react-router'
import { postsRoute } from './postsRoute'
export const postsIndexRoute = createRoute({
getParentRoute: () => postsRoute,
path: '/',
})
이 단계에서는 postsIndexRoute 정의가 라우트 트리에서 자신의 위치를 인식하고 부모 라우트가 반환하는 context, path params, search params의 타입을 올바르게 추론할 수 있도록 합니다. getParentRoute 함수를 잘못 정의하면 자식 라우트가 부모 라우트의 속성을 올바르게 추론하지 못합니다.
따라서 이는 라우트 구성의 중요한 부분이며 올바르게 처리하지 않으면 실패 지점이 됩니다.
하지만 이는 기본 애플리케이션 설정의 한 부분일 뿐입니다. TanStack Router는 타입 추론을 위해 모듈에 Router 인스턴스를 선언하기 전에 createRouter 함수에 전달할 수 있도록 모든 라우트(루트 라우트 포함)를 라우트 트리로 연결해야 합니다. 이 역시 라우트 구성의 중요한 부분이며 올바르게 처리하지 않으면 실패 지점이 됩니다.
🤯 라우트가 약 40~50개인 애플리케이션에서 이 라우트 트리를 별도 파일에 둔다면 쉽게 700줄 이상으로 늘어날 수 있습니다.
const routeTree = rootRoute.addChildren([
postsRoute.addChildren([postsIndexRoute, postsIdRoute]),
])
중첩 컨텍스트, 로더, 검색 매개변수 검증 등 라우터의 기능을 더 많이 사용하기 시작하면 복잡성은 계속 증가합니다. 따라서 더 이상 라우트를 한 파일에 정의하기가 어렵습니다. 그 결과 사용자는 여러 파일에 라우트를 정의하는 자체적인 반일관적 방식을 만들게 됩니다. 이는 라우트 구성의 불일치와 오류로 이어질 수 있습니다.
마지막으로 코드 분할 문제가 있습니다. 애플리케이션이 커지면 초기 번들 크기를 줄이기 위해 컴포넌트를 코드 분할하고 싶을 것입니다. 라우트를 한 파일 또는 여러 파일에 정의할 때 이를 관리하는 것은 다소 번거로울 수 있습니다.
import { createRoute, lazyRouteComponent } from '@tanstack/react-router'
import { postsRoute } from './postsRoute'
export const postsIndexRoute = createRoute({
getParentRoute: () => postsRoute,
path: '/',
component: lazyRouteComponent(() => import('../page-components/posts/index')),
})
최고 수준의 타입 추론 경험을 제공하는 데 아무리 필수적이라 해도 이 모든 보일러플레이트는 다소 부담스러울 수 있으며 라우트 구성의 불일치와 오류로 이어질 수 있습니다.
... 게다가 이 예시 구성은 코드 분할된 단일 라우트를 렌더링하기 위한 것일 뿐입니다. 40~50개 라우트에 이 작업을 해야 한다고 생각해 보세요. 아직 context, loaders, search param validation 및 라우터의 다른 기능은 다루지도 않았다는 점을 기억해야 합니다 🤕.
그렇다면 파일 기반 라우팅을 기본 방식으로 사용하는 이유는 무엇인가요?
TanStack Router의 파일 기반 라우팅은 이러한 모든 문제를 해결하도록 설계되었습니다. 예측 가능하고 관리 및 유지 관리하기 쉬운 방식으로 라우트를 정의할 수 있으며, 애플리케이션이 성장해도 확장할 수 있습니다.
파일 기반 라우팅 방식은 TanStack Router Bundler Plugin으로 구동됩니다. 코드 기반 라우팅을 사용할 때 라우트 구성의 어려운 지점을 해결하는 다음 3가지 핵심 작업을 수행합니다.
- 라우트 구성 보일러플레이트: 라우트 구성에 필요한 보일러플레이트를 생성합니다.
- 라우트 트리 연결: 라우트 구성을 하나의 일관된 라우트 트리로 연결합니다. 또한 백그라운드에서 라우트 구성을 올바르게 업데이트해
getParentRoute함수를 정의하고 라우트를 부모 라우트와 연결합니다. - 코드 분할: 라우트 콘텐츠 컴포넌트를 자동으로 코드 분할하고 올바른 컴포넌트로 라우트 구성을 업데이트합니다. 또한 런타임에 라우트에 접근할 때 올바른 컴포넌트가 로드되도록 합니다.
앞의 예시에서 라우트 구성이 파일 기반 라우팅으로 어떻게 보이는지 살펴보겠습니다.
// src/routes/posts/index.ts
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/')({
component: () => 'Posts index component goes here!!!',
})
이것으로 끝입니다. getParentRoute 함수를 정의하거나 라우트 트리를 연결하거나 컴포넌트를 코드 분할하는 일을 걱정할 필요가 없습니다. TanStack Router Bundler Plugin이 이 모든 작업을 처리합니다.
TanStack Router Bundler Plugin은 어떤 경우에도 라우트 구성에 대한 제어권을 빼앗지 않습니다. 최대한 유연하도록 설계되어 라우트 구성의 보일러플레이트와 복잡성을 줄이면서 애플리케이션에 맞는 방식으로 라우트를 정의할 수 있습니다.
TanStack Router에서 작동하는 방식을 자세히 알아보려면 파일 기반 라우팅 및 코드 분할 가이드를 참고합니다.