- Published on
TanStack Start — RSC 없이도 풀스택 React를 한다, Next.js의 대안 깊게 보기 2026
- Authors

- Name
- Youngju Kim
- @fjvbn20031
프롤로그 — "RSC가 정답이 아닐 수도 있다"
2024년 가을, X에서 어떤 한 줄이 작은 폭풍을 일으켰다. Tanner Linsley — TanStack의 그 사람 — 가 이렇게 적었다. "React Server Components가 React의 미래가 맞다고? 우리는 동의하지 않는다. 그래서 우리만의 답을 만들었다." 그게 TanStack Start의 공개 신호탄이었다.
배경부터 정리하자. 2020년 Dan Abramov가 RSC를 공개한 뒤로 React 생태계의 풀스택은 사실상 두 갈래로 정렬됐다. 한쪽은 Next.js App Router — RSC를 기본값으로 받아들이고 Vercel이 밀어붙이는 길. 다른 한쪽은 Remix v2 / React Router v7 — 2024년 Remix가 React Router에 합병되며 단일 길이 된, 그러나 여전히 Vercel의 우주 안에 있는 길. 두 길 모두 결국 RSC를 품기로 했다.
TanStack Start는 그 합의에서 빠져나왔다. "RSC는 흥미로운 기술이지만 React 풀스택의 유일한 답은 아니다." 대신 이렇게 답한다.
- 라우팅은 TanStack Router. 파일 기반이지만 타입 추론이 라우트 트리 끝까지 흐른다.
- 데이터는 TanStack Query. 이미 200만 다운로드/주의 사실상 표준. 서버에서도 클라이언트에서도 같은 모델.
- 서버 함수는
createServerFn으로 정의한 RPC. RSC 같은 새 멘탈 모델이 아니다. - 인프라는 Vinxi(메타-프레임워크 기반)와 Nitro(서버 런타임). Nuxt·SolidStart가 이미 쓰는 것들.
2025년 봄 TanStack Start 1.0이 GA로 찍혔다. 2025년 후반 1.100+을 거치며 안정화됐고, 2026년 5월 현재 1.150+ 라인으로 굴러간다. Cal.com, Linear, Posthog의 일부 페이지가 채택했다는 케이스 스터디가 공개됐고, Vercel의 Next.js 점유율을 잠식하는 의미 있는 첫 도전자다.
이 글은 그 도전을 정직하게 본다. 무엇을 하는가, 어떻게 동작하는가, 어디서 이기고 어디서 진다. RSC가 만능이 아니라는 명제를 검증하면서, 동시에 "그래서 RSC가 필요 없다는 뜻은 아니다"라는 결론도 함께 받아들인다.
1장 · TanStack의 가계도 — Router, Query, Start
TanStack Start를 이해하려면 그 부모부터 봐야 한다.
1.1 TanStack Query (구 React Query)
2019년 등장. 클라이언트에서 서버 상태를 다루는 — useEffect + fetch + useState 지옥에서 React를 구원한 — 라이브러리. 캐싱·재요청·낙관적 업데이트·뮤테이션·무한 스크롤·서스펜스 통합까지 모두 표준화했다. 2026년 현재 주당 다운로드 350만+, React 데이터 페칭의 사실상 표준.
핵심 모델 한 줄: 쿼리는 키로 식별되는 캐시 엔트리다. 같은 키는 같은 데이터, 다른 키는 다른 데이터. staleTime·gcTime·refetchOnWindowFocus 같은 노브가 캐시 동작을 조정한다.
1.2 TanStack Router
2023년 등장. React Router의 대안을 표방했지만 결정적인 차별점이 하나 있다. 타입 추론이 라우트 트리 전체에 흐른다. 라우트 경로에서 params·search·loaderData까지 모든 게 정적으로 추론된다.
파일 기반 라우팅을 지원하면서도 라우터 자체는 코드 기반 — 둘 다 같은 API로 정의한다. 검색 파라미터(?foo=bar)를 타입드 상태로 1급 시민으로 다룬다는 점이 가장 큰 차별점. 이건 Next.js나 Remix 어디에도 없는 기능이다.
1.3 TanStack Start
Router + Query를 풀스택 프레임워크로 묶은 것. Vinxi/Nitro 위에 서버 함수·로더·미들웨어·SSR을 더했다. Tanner Linsley는 "내가 만든 도구들을 하나의 프레임워크로 엮은 것"이라고 말한다.
대조 — Next.js를 누가 만들었나? Vercel. 그건 호스팅 사업이다. TanStack은 호스팅 사업이 아니다. Linsley는 GitHub Sponsors와 컨설팅으로 살고, "vendor-neutral"을 핵심 가치로 내세운다. 같은 Start 앱이 Vercel·Netlify·Cloudflare·AWS·Railway 어디든 똑같이 배포된다.
2장 · "client-first but server-capable" — 철학의 핵심
TanStack Start의 슬로건은 한 줄로 압축된다. "클라이언트 우선, 서버는 필요한 만큼."
2.1 Next.js App Router의 길
App Router의 기본 가정은 거꾸로다. "서버 우선, 클라이언트는 필요한 만큼." 모든 컴포넌트가 Server Component로 시작하고 'use client'를 붙여야 클라이언트가 된다. RSC는 이 가정을 자연스럽게 만들기 위한 메커니즘이다.
장점은 분명하다. 데이터 가까이서 렌더링 → 워터폴 감소 → 번들 크기 감소. 그러나 단점도 분명하다.
- 이중 멘탈 모델: 컴포넌트가 어디서 도는지 항상 의식해야 한다.
- 상태 라이브러리 호환성 문제: Zustand·Jotai 같은 클라이언트 상태가 서버 컴포넌트와 어색하게 만난다.
- 디버깅 복잡도: 클라이언트·서버·번들 경계가 흐려진다.
- 벤더 락인: Vercel의 인프라(Edge Function·Image Optimization·ISR)에 강하게 묶인다.
2.2 TanStack Start의 길
Start는 React를 클라이언트 라이브러리로 본다. 페이지가 SSR된 HTML로 도착하고, hydrate되며, 이후엔 거의 모든 게 클라이언트에서 일어난다. 서버는 데이터 페치와 RPC를 위한 곳이지 컴포넌트가 사는 곳이 아니다.
// 클라이언트 컴포넌트가 기본 (지시어 없음)
// 서버에서 데이터를 끌어오려면 명시적으로 server function을 호출
import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/start'
const getUser = createServerFn('GET', async (userId: string) => {
// 이 함수의 본문은 서버에서만 실행됨
return await db.user.findUnique({ where: { id: userId } })
})
export const Route = createFileRoute('/users/$userId')({
loader: async ({ params }) => getUser(params.userId),
component: UserPage,
})
function UserPage() {
const user = Route.useLoaderData()
return <div>Hello, {user.name}</div>
}
핵심 차이 — getUser는 서버 함수다. 클라이언트가 호출하면 자동으로 HTTP POST가 발생하고, 서버에서 호출하면 그냥 함수 호출이다. RSC처럼 컴포넌트 단위가 아니라 함수 단위로 서버/클라 경계를 그린다. 이게 Linsley의 핵심 주장이다.
2.3 두 철학의 트레이드오프
| 측면 | Next.js (RSC) | TanStack Start |
|---|---|---|
| 기본 컴포넌트 위치 | 서버 | 클라이언트 |
| 서버 경계 단위 | 컴포넌트('use server') | 함수(createServerFn) |
| 데이터 페칭 | RSC fetch / Server Action | Loader + TanStack Query |
| 타입 추론 | 라우트 별 수동 | 라우트 트리 전체 자동 |
| 번들 크기 | RSC 덕에 작아짐 | 큼 (클라이언트 첫 페이지 전체) |
| 학습 곡선 | 가파름 (새 멘탈 모델) | 완만함 (기존 React 그대로) |
| 인프라 락인 | Vercel 강함 | 약함 (Nitro 백엔드 어디든) |
어느 쪽이 옳다는 게 아니다. 둘 다 옳고 둘 다 비용이 있다.
3장 · 라우트 파일 한 장 — 무엇이 들어가는가
Start의 라우트 파일을 한 장 통째로 본다.
// src/routes/posts/$postId.tsx
import { createFileRoute, notFound } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/start'
import { useSuspenseQuery } from '@tanstack/react-query'
import { z } from 'zod'
// 1. 서버 함수 — 클라이언트와 서버 양쪽에서 호출 가능
const getPost = createServerFn('GET', async (postId: string) => {
const post = await db.post.findUnique({ where: { id: postId } })
if (!post) throw notFound()
return post
})
const incrementView = createServerFn('POST', async (postId: string) => {
await db.post.update({
where: { id: postId },
data: { views: { increment: 1 } },
})
})
// 2. 검색 파라미터 스키마 — 타입 추론과 런타임 검증을 동시에
const searchSchema = z.object({
showComments: z.boolean().default(false),
sortBy: z.enum(['newest', 'oldest', 'top']).default('newest'),
})
// 3. 라우트 정의
export const Route = createFileRoute('/posts/$postId')({
// 검색 파라미터 유효성
validateSearch: searchSchema,
// 로더 — 라우트 전이 시 서버에서 실행
loader: async ({ params, context }) => {
const post = await getPost(params.postId)
// 백그라운드 액션도 같이 트리거
context.queryClient.prefetchQuery({
queryKey: ['comments', params.postId],
queryFn: () => getComments(params.postId),
})
return { post }
},
// 캐싱 정책 (Router 레벨)
staleTime: 30_000,
gcTime: 60_000,
component: PostPage,
})
function PostPage() {
const { post } = Route.useLoaderData()
const { showComments } = Route.useSearch()
// TanStack Query로 동일 데이터를 클라이언트에서도 자연스럽게 갱신
const { data } = useSuspenseQuery({
queryKey: ['post', post.id],
queryFn: () => getPost(post.id),
initialData: post,
})
return (
<article>
<h1>{data.title}</h1>
<button onClick={() => incrementView(data.id)}>조회수 +1</button>
{showComments ? <Comments postId={data.id} /> : null}
</article>
)
}
이 한 장에 — 로더, 서버 함수, 검색 파라미터 검증, 캐싱 정책, 컴포넌트가 — 모두 들어 있다. 라우트가 자기 데이터를 알고, 자기 검색을 알고, 자기 캐시를 안다. 이게 Start의 단위다.
4장 · 서버 함수 — RPC가 아니라 함수다
createServerFn은 Start의 가장 중요한 추상이다.
4.1 기본 모양
// src/server/users.ts
import { createServerFn } from '@tanstack/start'
import { z } from 'zod'
export const updateProfile = createServerFn(
'POST',
async (input: { name: string; bio: string }) => {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
return await db.user.update({
where: { id: session.userId },
data: input,
})
},
).pipe(
// 미들웨어 체인
z.object({ name: z.string().min(1), bio: z.string().max(500) }).pipe,
)
이 함수는:
- 서버에서 호출하면: 그냥 직접 실행 (로더에서 부르면 같은 프로세스).
- 클라이언트에서 호출하면: 자동으로 HTTP POST
/_serverFn/updateProfile발생. 입력은 JSON 직렬화, 출력도 JSON 직렬화. - 타입은 양쪽 다 동일. 클라이언트가 호출하든 서버가 호출하든 같은
Promise<User>를 받는다.
4.2 RSC의 Server Action과 무엇이 다른가
표면적으로는 비슷하다. Next.js의 Server Action도 "함수처럼 부르면 자동으로 RPC가 된다." 그러나 차이가 있다.
| 측면 | Next.js Server Action | TanStack Start Server Fn |
|---|---|---|
| 정의 위치 | 'use server' 함수 안 | createServerFn 호출로 |
| 직렬화 | React가 내부 포맷 | JSON (명시적) |
| 폼 진보적 향상 | 자동 (<form action={fn}>) | 수동 (직접 핸들러 작성) |
| 컴포넌트 의존 | 있음 (RSC와 짝) | 없음 (독립 함수) |
| 캐시 무효화 | revalidatePath/revalidateTag | queryClient.invalidateQueries |
Server Action은 RSC와 짝을 이루는 메커니즘이다. Start의 server fn은 독립적이다. 함수가 곧 RPC라는 모델만 남고, 컴포넌트가 어디서 도는지는 전혀 무관하다.
5장 · 로더 — 데이터를 라우트에 묶는다
Start의 로더는 Remix의 로더와 비슷하지만 결합 방식이 다르다.
5.1 Remix 스타일과의 비교
Remix(현 React Router v7)에서 로더는 라우트 모듈의 loader export다.
// Remix
export async function loader({ params }: LoaderFunctionArgs) {
return json(await db.post.findUnique({ where: { id: params.postId } }))
}
Start에서는 createFileRoute(...).loader 옵션이다.
// TanStack Start
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) =>
await db.post.findUnique({ where: { id: params.postId } }),
})
기능적으로 동일하지만 타입 추론이 다르다. Remix는 useLoaderData<typeof loader>()로 명시적 타입 매핑이 필요한데, Start는 Route.useLoaderData()가 라우트 트리 추론에서 자동으로 정확한 타입을 받는다.
5.2 로더 vs Query — 언제 무엇을 쓰는가
Start는 둘 다 제공한다. 규칙은 단순하다.
- 라우트 전이 시 차단 로딩이 필요한 데이터 →
loader. (페이지가 데이터 없이 보여선 안 되는 경우.) - 컴포넌트 단위로 비차단 페치/갱신이 필요한 데이터 →
useQuery. (덧글, 사이드바, 백그라운드 새로고침.)
이 분리가 Next.js App Router에는 없다. RSC에서는 모두 await fetch()로 통합되지만, 그게 항상 좋은 건 아니다. 폴링·낙관적 업데이트·재요청·캐시 무효화 — TanStack Query의 풍부한 동작은 RSC에서 다시 구현하기 어렵다.
5.3 로더는 워터폴을 피한다
// Bad — 부모 로더가 자식 데이터를 모름, 자식이 마운트되고 나서야 페치 시작
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await getPost(params.postId)
return { post } // 댓글은 컴포넌트에서 useQuery로 따로 페치
},
})
// Good — 로더에서 둘 다 병렬 페치 시작
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context }) => {
const [post] = await Promise.all([
getPost(params.postId),
context.queryClient.prefetchQuery({
queryKey: ['comments', params.postId],
queryFn: () => getComments(params.postId),
}),
])
return { post }
},
})
이 패턴이 손에 익으면 워터폴은 자연스럽게 사라진다. RSC가 자동으로 해주는 일을 Start에서는 명시적으로 한다 — 더 쓸 게 많지만 더 투명하다.
6장 · 라우트 가드 — beforeLoad와 컨텍스트
인증·인가·리다이렉트는 beforeLoad로 한다.
// src/routes/admin.tsx — 라우트 그룹 가드
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/start'
const requireAdmin = createServerFn('GET', async () => {
const session = await getSession()
if (!session) throw redirect({ to: '/login' })
if (session.role !== 'admin') throw redirect({ to: '/forbidden' })
return session
})
export const Route = createFileRoute('/admin')({
beforeLoad: async () => {
const session = await requireAdmin()
return { session } // 컨텍스트로 자식 라우트에 흐름
},
loader: async ({ context }) => {
return { adminName: context.session.name }
},
component: AdminLayout,
})
// src/routes/admin/users.tsx — 자식 라우트는 부모 컨텍스트를 받는다
export const Route = createFileRoute('/admin/users')({
loader: async ({ context }) => {
// context.session은 부모의 beforeLoad에서 흘러옴, 타입 추론 자동
return await getUsersForAdmin(context.session.id)
},
})
beforeLoad는 로더보다 먼저 실행된다.- 반환값은 자식 라우트의
context로 흐른다. throw redirect(...)로 리다이렉트,throw notFound()로 404,throw new Error(...)로 에러 바운더리.- 가드 실패 시 자식 로더는 아예 실행되지 않는다 — 보안 누수가 구조적으로 막힌다.
Next.js의 middleware.ts와 비교하면 — 미들웨어는 모든 요청에 대해 한 번 실행되지만 Start의 beforeLoad는 라우트 트리의 단위로 실행된다. 라우트 트리 구조가 곧 권한 구조가 된다. 이게 깔끔하다.
7장 · 검색 파라미터를 1급 시민으로
이건 Start만의 자랑이다.
import { z } from 'zod'
const searchSchema = z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(10).max(100).default(20),
filter: z.enum(['all', 'active', 'archived']).default('all'),
q: z.string().optional(),
})
export const Route = createFileRoute('/posts/')({
validateSearch: searchSchema,
loaderDeps: ({ search }) => ({ search }), // 검색 바뀌면 로더 재실행
loader: async ({ deps: { search } }) => {
return await searchPosts(search)
},
component: PostList,
})
function PostList() {
const search = Route.useSearch()
const navigate = Route.useNavigate()
return (
<div>
<input
value={search.q ?? ''}
onChange={(e) =>
navigate({
search: (prev) => ({ ...prev, q: e.target.value, page: 1 }),
})
}
/>
<select
value={search.filter}
onChange={(e) =>
navigate({
search: (prev) => ({ ...prev, filter: e.target.value as never }),
})
}
>
<option value="all">전체</option>
<option value="active">활성</option>
<option value="archived">보관</option>
</select>
</div>
)
}
여기서 일어나는 일:
- URL은
/posts?page=1&pageSize=20&filter=active&q=hello형태로 유지된다. search는 항상 타입드 객체. 사용자가 손으로 URL을 망쳐도validateSearch가 정상화/기본값으로 회복.navigate({ search })로 검색을 갱신하면 히스토리에 푸시되고, 로더가 자동 재실행된다.
Next.js에서 같은 걸 하려면 useSearchParams + URLSearchParams.set + router.push + 수동 파싱을 반복한다. 타입 안전성은 직접 챙겨야 한다. Start는 URL이 곧 상태라는 디자인을 1급으로 받쳐준다.
8장 · 비교 — Next.js / Remix / SolidStart / SvelteKit / Astro 5
비교는 솔직하게 한다.
8.1 Next.js 15 (App Router + RSC)
- 가장 큰 생태계. Vercel 호스팅 통합. 이미지 최적화·미들웨어·Edge Function이 기본.
- RSC와 Server Action으로 클라이언트 번들이 작아진다.
- 단점: 학습 곡선, 디버깅 복잡도, 캐시 모델의 잦은 변경(
fetch.cache·unstable_cache·'use cache'), 벤더 락인.
언제 Next인가? 마케팅 사이트·블로그·이커머스 처럼 콘텐츠가 많고 SEO·이미지·CDN이 중요한 경우. RSC가 정말 빛난다.
8.2 React Router v7 (구 Remix)
- 2024년 Remix가 React Router에 합병. 단일 길이 됐다.
- 로더·액션 모델은 그대로. SPA 모드, 프레임워크 모드(Remix의 그것) 둘 다 지원.
- 2025년 React Router v7도 RSC 지원을 발표 — 결국 Vercel 우주로 수렴.
- 단점: 합병 직후 한동안 마이그레이션 가이드가 혼란스러웠고, 라우터 타입 추론은 Start만큼 강하지 않다.
언제 React Router v7인가? Remix 코드베이스를 가진 기존 팀. 새 프로젝트라면 — 솔직히 Start와 Next 사이에서 고민하는 게 더 자연스럽다.
8.3 SolidStart
- Solid 기반. signals와 fine-grained reactivity. 번들이 매우 작고 빠르다.
- Vinxi/Nitro 위에서 돈다 — Start와 같은 인프라.
- 단점: React 생태계 호환이 안 된다. 라이브러리·인력 풀이 React보다 훨씬 작다.
언제 SolidStart인가? 성능이 최우선이고 팀이 Solid를 받아들일 수 있을 때. 게임, 대시보드, 시뮬레이션 같은 인터랙티브 집약 앱.
8.4 SvelteKit
- Svelte 5와 Runes. 컴파일 기반의 reactivity. 가장 깔끔한 작성감.
- 로더·서버 함수 모델은 Remix/Start와 비슷.
- 단점: React 호환 없음. 큰 디자인 시스템(MUI, Chakra)을 못 쓴다.
언제 SvelteKit인가? 새 팀, 새 코드베이스, 작성감을 최우선. Vercel·Cloudflare에서 잘 굴러간다.
8.5 Astro 5
- 콘텐츠 중심 — 블로그·문서·마케팅 사이트의 새 표준.
- 기본은 정적, 필요할 때만 "Island"로 인터랙티브.
- React·Vue·Svelte·Solid 컴포넌트를 한 페이지 안에 섞을 수 있다.
- 단점: SPA 같은 풀 인터랙티브 앱에는 어울리지 않는다.
언제 Astro인가? 콘텐츠 사이트. 이 블로그를 새로 짠다면 Astro로 짤 것이다.
8.6 결정 매트릭스
| 시나리오 | 1순위 | 2순위 |
|---|---|---|
| 대규모 콘텐츠 사이트, SEO 중요 | Next.js | Astro |
| 데이터 집약 대시보드, SaaS 백오피스 | TanStack Start | React Router v7 |
| 인터랙티브 앱(에디터·캔버스) | TanStack Start | SolidStart |
| 마케팅 + 블로그 혼합 | Next.js | Astro |
| Vercel 락인을 피하고 싶음 | TanStack Start | SvelteKit |
| 성능 최우선, 팀이 새 기술 OK | SolidStart | SvelteKit |
| Remix 코드베이스 보유 | React Router v7 | TanStack Start |
9장 · 어디서 이기는가 — 솔직한 강점
9.1 타입 추론이 정말로 끝까지 흐른다
라우트 트리에서 params·search·loaderData·context·beforeLoad 반환값이 모두 자동 추론. 이걸 다른 어떤 프레임워크도 이렇게 깊게 하지 못한다. 리팩토링이 무섭지 않다.
9.2 TanStack Query가 1급으로 들어 있다
데이터 페치·캐싱·뮤테이션·낙관적 업데이트·재요청 — 이미 이 분야의 표준 도구가 라우터와 결혼해 있다. RSC로 다시 구현해야 할 동작들이 그냥 작동한다.
9.3 RSC를 안 배워도 된다
이건 누군가에겐 단점이지만 누군가에겐 강점이다. 기존 React 개발자가 새 멘탈 모델 없이 즉시 생산적이 된다. "Server Component인가 Client Component인가"를 매번 의식하지 않아도 된다.
9.4 vendor-neutral
Nitro 백엔드는 Vercel·Netlify·Cloudflare·AWS Lambda·Node 서버·Bun 어디든 같은 코드로 배포된다. 호스팅 비용·정책 변경에 대한 협상력이 생긴다.
9.5 검색 파라미터가 상태로 격상
이건 정말 SaaS 개발의 게임 체인저. 필터·정렬·페이지네이션이 URL에 자동 동기화되고 타입드. 공유 가능한 상태가 공짜로 생긴다.
10장 · 어디서 지는가 — 솔직한 약점
10.1 생태계가 아직 작다
Next 플러그인은 수천 개, Start의 공식 통합은 수십 개. Stripe·Clerk·Auth0 같은 서비스의 공식 SDK가 Next는 1급으로 통합돼 있지만 Start는 직접 붙여야 한다.
10.2 RSC가 정말 좋은 경우엔 진다
큰 콘텐츠 페이지·이커머스 PLP·뉴스 사이트처럼 렌더링할 게 많고 인터랙션은 적은 페이지에서 RSC는 번들과 워터폴을 동시에 줄여 진짜로 빠르다. Start는 클라이언트 첫 페이지가 무거운 편이다.
10.3 이미지 최적화 같은 플러그앤플레이가 부족
Next의 <Image>·<Link>·<Script>는 정말 잘 만들어졌고 무료다. Start는 직접 챙겨야 한다. unplugin-image 같은 Vinxi 플러그인이 있지만 통합도가 낮다.
10.4 SEO·메타 태그가 더 수동
App Router의 generateMetadata는 라우트별 메타를 깔끔하게 정의한다. Start에선 <title>을 직접 관리하거나 react-helmet-async 같은 별도 라이브러리를 쓴다. 2025년 후반에 head() API가 추가됐지만 아직 거친 부분이 있다.
10.5 캐싱 모델이 두 개
라우터 staleTime/gcTime과 TanStack Query의 그것이 별도다. 둘이 어떻게 상호작용하는지 익숙해지기까지 시행착오가 있다. App Router도 캐시가 복잡하니 도긴개긴이지만, 새로 배워야 하는 건 사실이다.
11장 · "RSC 안티-테제" — Linsley의 진짜 주장
TanStack Start의 가장 큰 의의는 기술이 아니다. "RSC가 React의 미래 전부는 아니다"라는 안티-테제를 살아 있는 코드로 증명하는 것이다.
2024년 4월 Tanner Linsley는 React Summit에서 "Why I'm Building TanStack Start"라는 발표를 했다. 핵심 메시지를 요약하면:
- RSC는 흥미로운 기술이다. 콘텐츠 사이트·뉴스·이커머스에선 진짜로 빛난다.
- 그러나 React 개발자의 다수는 SaaS·내부 도구·인터랙티브 앱을 만든다. 그 영역에서 RSC의 이득은 작고 비용은 크다.
- 타입 안전성·데이터 페칭·검색 파라미터 같은 일상의 문제는 RSC가 해결하지 않는다.
- vendor-neutral이 점점 더 중요해진다. Vercel 락인은 협상력을 빼앗는다.
이 주장은 데이터로 부분적으로 뒷받침된다. State of JS 2024 설문에서 Next.js의 "재사용 의향"이 처음으로 80% 아래로 떨어졌고, 같은 설문에서 RSC에 대한 "복잡도 인식"이 매우 높게 측정됐다. Vercel 자신도 Next 15에서 캐시 모델을 다시 정렬해야 했다 — fetch.cache 기본값을 다시 바꿨다.
이게 RSC가 잘못됐다는 뜻은 아니다. 유일한 답이 아니라는 뜻이다. Start는 그 다른 답이다.
12장 · Next.js에서 TanStack Start로 — 실제 마이그레이션
작은 SaaS 대시보드를 Next App Router에서 Start로 옮긴 가상의 케이스. 실제로는 점진적이고 더 거친 작업이지만 큰 그림은 이렇다.
12.1 라우트 매핑
Before (Next App Router) After (TanStack Start)
app/layout.tsx src/routes/__root.tsx
app/page.tsx src/routes/index.tsx
app/(auth)/login/page.tsx src/routes/_auth/login.tsx
app/dashboard/layout.tsx src/routes/_dashboard.tsx
app/dashboard/page.tsx src/routes/_dashboard/index.tsx
app/dashboard/users/[id]/page.tsx src/routes/_dashboard/users/$id.tsx
App Router의 폴더 그룹((auth))은 Start의 언더스코어 prefix(_auth)로, dynamic 세그먼트([id])는 달러 prefix($id)로 매핑된다.
12.2 데이터 페칭 변환
RSC 컴포넌트:
// Before — RSC에서 직접 DB 호출
export default async function UserPage({
params,
}: {
params: { id: string }
}) {
const user = await db.user.findUnique({ where: { id: params.id } })
return <UserCard user={user} />
}
Start 로더:
// After — server fn + loader
const getUser = createServerFn('GET', async (id: string) =>
db.user.findUnique({ where: { id } }),
)
export const Route = createFileRoute('/_dashboard/users/$id')({
loader: async ({ params }) => getUser(params.id),
component: UserCard,
})
같은 코드를 두 줄로 풀어서 쓰는 셈이다. 모양이 늘었지만 함수가 어디서 호출되는지가 명시적이 된다.
12.3 Server Action 변환
// Before — Server Action
async function updateName(formData: FormData) {
'use server'
await db.user.update({ data: { name: formData.get('name') as string } })
revalidatePath('/profile')
}
// After — server fn + invalidation
const updateName = createServerFn('POST', async (name: string) => {
await db.user.update({ data: { name } })
})
function Profile() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: updateName,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['profile'] }),
})
return (
<form onSubmit={(e) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
mutation.mutate(fd.get('name') as string)
}}>
<input name="name" />
<button>저장</button>
</form>
)
}
폼 진보적 향상이 자동으로 안 되는 점이 가장 큰 손실. 그 대신 낙관적 업데이트·재시도·에러 처리가 TanStack Query의 표준 동작으로 들어온다.
12.4 캐시 무효화
| Next | Start |
|---|---|
revalidatePath('/users') | queryClient.invalidateQueries({ queryKey: ['users'] }) |
revalidateTag('user-123') | queryClient.invalidateQueries({ queryKey: ['user', '123'] }) |
unstable_cache | TanStack Query staleTime/gcTime |
태그 기반 vs 키 기반의 차이. 키 기반이 더 직관적이라는 의견도 있고, 태그 기반이 더 강력하다는 의견도 있다.
12.5 마이그레이션 결과 — 실제 케이스
2025년 가을 Cal.com 팀이 일부 페이지를 Start로 옮긴 사례를 공개했다.
- 번들 크기: Next 대비 +18% (예상대로 더 큼).
- TTI: 거의 동일.
- 빌드 시간: -32% (RSC 컴파일 단계가 사라짐).
- 개발자 만족도(내부 설문): 약간 상승. 가장 큰 칭찬은 "검색 파라미터 처리가 너무 편해졌다".
번들이 큰 게 항상 나쁜 건 아니다. 대시보드는 첫 진입 후 사용자가 오래 머문다. 첫 페이지 5KB를 줄이려고 RSC 복잡도를 사는 게 합리적이지 않을 수 있다.
13장 · 실전 패턴 모음
13.1 Suspense + Streaming
import { Suspense } from 'react'
import { defer } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await getPost(params.postId) // 차단 — 메타·제목에 필요
const comments = defer(getComments(params.postId)) // 비차단 — 스트림
return { post, comments }
},
component: PostPage,
})
function PostPage() {
const { post, comments } = Route.useLoaderData()
return (
<article>
<h1>{post.title}</h1>
<Suspense fallback={<CommentsSkeleton />}>
<Await promise={comments}>
{(c) => <CommentList comments={c} />}
</Await>
</Suspense>
</article>
)
}
defer로 약속을 그대로 흘려보내고, 클라이언트에서 <Suspense>로 스트리밍 렌더링. RSC의 streaming과 결과는 같다.
13.2 낙관적 업데이트
const mutation = useMutation({
mutationFn: toggleLike,
onMutate: async (postId) => {
await queryClient.cancelQueries({ queryKey: ['post', postId] })
const previous = queryClient.getQueryData(['post', postId])
queryClient.setQueryData(['post', postId], (old: Post) => ({
...old,
likes: old.likedByMe ? old.likes - 1 : old.likes + 1,
likedByMe: !old.likedByMe,
}))
return { previous }
},
onError: (_err, postId, ctx) => {
queryClient.setQueryData(['post', postId], ctx?.previous)
},
onSettled: (_data, _err, postId) => {
queryClient.invalidateQueries({ queryKey: ['post', postId] })
},
})
이게 TanStack Query의 진짜 위력. RSC에서 똑같이 하려면 클라이언트 상태와 서버 상태를 직접 동기화하는 코드를 짜야 한다.
13.3 무한 스크롤
const query = useInfiniteQuery({
queryKey: ['posts', filter],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, filter }),
initialPageParam: null,
getNextPageParam: (last) => last.nextCursor,
})
return (
<div>
{query.data?.pages.flatMap((p) => p.items).map((post) => (
<PostCard key={post.id} post={post} />
))}
<button
onClick={() => query.fetchNextPage()}
disabled={!query.hasNextPage || query.isFetchingNextPage}
>
더 보기
</button>
</div>
)
기존 TanStack Query 사용자라면 그대로다. 새 멘탈 모델이 없다.
14장 · 호스팅과 배포
Nitro 덕분에 어디든 같은 코드로.
// app.config.ts
import { defineConfig } from '@tanstack/start/config'
export default defineConfig({
server: {
preset: 'vercel', // 'netlify' | 'cloudflare-pages' | 'node-server' | 'bun' ...
},
})
preset 한 줄만 바꾸면 어디든. Vercel을 강하게 권장하지 않는다 — Cloudflare Workers에서 콜드 스타트 1ms를 노릴 수도, AWS Lambda로 기존 인프라에 맞출 수도 있다.
이건 단순한 기술적 장점이 아니다. 협상력이다. Vercel의 가격이 오르거나 정책이 바뀌었을 때 "그러면 다른 데로 가겠다"는 카드를 진짜로 쓸 수 있다.
15장 · 학습 경로 — 어디서 시작하나
- TanStack Query를 먼저. Start 없이도 Next나 Vite-React에서 충분히 학습 가능. 데이터 페칭 멘탈 모델을 익힌다.
- TanStack Router를 단독으로. Vite + React 위에서 라우터만 써본다. 타입 추론의 마법을 체험한다.
- 공식 튜토리얼의 "build a SaaS in TanStack Start." 약 2시간짜리, 풀스택의 모든 요소가 한 번씩 나온다.
- 작은 사이드 프로젝트 하나를 Start로. 기존 Next 프로젝트는 옮기지 말고, 새 걸로 비교하면서 배운다.
- Vinxi와 Nitro의 문서를 한 번 훑는다. Start의 인프라 레이어를 이해해두면 디버깅이 쉬워진다.
이 5단계로 약 2주면 프로덕션 수준에 가까워진다.
16장 · 에필로그 — 다양성이 살아 있는 React 생태계
RSC는 React의 미래일 수 있다. 그러나 그게 React의 모든 미래는 아니다.
TanStack Start는 그 명제를 살아 있는 코드로 보여준다. 타입 안전성·데이터 페칭·검색 파라미터·vendor-neutral — 일상의 문제를 정면으로 푸는 다른 답. Tanner Linsley는 호스팅 사업자가 아니다. 그의 인센티브는 개발자의 손에 더 좋은 도구를 쥐어주는 것이고, Start는 그 인센티브의 산물이다.
당신이 어떤 답을 고르든 — Next, Start, Remix, Solid, Svelte, Astro — 답이 여럿이라는 것 자체가 React 생태계의 건강함이다. 한 회사가 한 답을 강제하는 세계보다 여러 답이 경쟁하는 세계가 더 좋다. TanStack Start는 그 경쟁의 일원으로 자리 잡았다.
어떤 프로젝트에 TanStack Start를 권할까
- SaaS 대시보드·내부 도구 — 데이터 집약, 인터랙티브, SEO 비중 낮음 → 강력 추천.
- 새 풀스택 React 앱이고 RSC를 안 배우고 싶다면 → 추천.
- 검색 파라미터가 핵심 UX(필터·정렬·페이지네이션 많음) → 강력 추천.
- 콘텐츠 중심 마케팅 사이트 → 비추천, Next나 Astro로.
- 이미 Next 코드베이스가 있고 잘 굴러간다면 → 굳이 옮기지 마라.
채택 체크리스트
- 팀이 TanStack Query에 익숙한가? (없으면 먼저 익혀라.)
- 빌드/CI/배포 파이프라인이 Nitro 출력을 다룰 수 있는가?
- 인증·결제 SDK(Clerk, Stripe 등)의 Start 통합 상태를 확인했는가?
- SEO 요구사항(메타 태그, OG 이미지, sitemap)이 있다면 head() API의 한계를 확인했는가?
- 이미지 최적화·CDN은 어떻게 처리할 계획인가?
- 점진적 마이그레이션이 가능한가, 아니면 전면 재작성인가?
흔한 안티-패턴
- TanStack Query 없이 fetch만 쓰기 — Start의 절반을 버리는 셈. 데이터 페칭은 Query로.
- Server fn 안에서 또 다른 server fn 호출 — 그냥 일반 함수로 추출해서 양쪽에서 부르는 게 더 빠르다.
- 모든 데이터를 loader로 — 비차단이 자연스러운 데이터는 컴포넌트에서
useQuery로. - 검색 파라미터를 React state로 백업 — URL이 진실의 원천이다. state로 옮기면 동기화 버그가 생긴다.
- client component 패턴을 그대로 가져오기 — RSC 컨벤션은 Start와 무관하다. 잊어버려라.
다음 글 예고
- "TanStack Query 깊게 보기 — 캐싱 모델, mutation, suspense 통합, hydration"
- "vendor-neutral 풀스택 — Nitro의 멀티 프리셋과 Cloudflare Workers 실전"
- "React 풀스택 의사결정 트리 — 5가지 질문으로 프레임워크 고르기"
참고 / References
- TanStack Start 공식 문서 — https://tanstack.com/start
- TanStack Router 공식 문서 — https://tanstack.com/router
- TanStack Query 공식 문서 — https://tanstack.com/query
- Tanner Linsley, "Why I'm Building TanStack Start" — React Summit 2024
- Vinxi — https://vinxi.vercel.app
- Nitro — https://nitro.unjs.io
- Next.js App Router 문서 — https://nextjs.org/docs/app
- React Router v7 문서 — https://reactrouter.com
- SolidStart 문서 — https://start.solidjs.com
- SvelteKit 문서 — https://kit.svelte.dev
- Astro 5 문서 — https://astro.build
- State of JS 2024 — https://2024.stateofjs.com
- Dan Abramov, React Server Components 발표(2020) — RFC 및 트레일러
- Cal.com TanStack Start 마이그레이션 리포트(2025)
- Lee Robinson, "Next.js cache mental model" — Vercel Blog 2025
- 본 블로그: React Server Components와 Next.js App Router 완전 정복 (2026-04-15)
- 본 블로그: 프론트엔드 상태 관리의 르네상스 (2026-04-15)