Next.js 말고 다른 선택지 — TanStack Start가 타입 안전성에 집착하는 이유

TanStack Start

React로 풀스택 앱을 만들 때 대부분 Next.js를 먼저 떠올려요. 사실상 표준이니까요. 근데 쓰다 보면 불편한 지점이 있거든요. 타입 안전성이 중간에 끊긴다거나, 캐싱이 예상과 다르게 돌아간다거나, 배포할 때 특정 플랫폼에 묶이는 느낌이 든다거나.

TanStack Start는 그 불편한 지점들을 정면으로 파고든 풀스택 React 프레임워크예요. TanStack Query, TanStack Table, TanStack Router로 유명한 TanStack 생태계에서 나왔고, Vite 기반이고, 현재 RC(Release Candidate) 단계예요.

"엔드투엔드 타입 안전성"을 내세우는데, 이게 정확히 뭘 의미하는지 코드로 봐야 이해가 돼요.

서버 함수 — 함수 호출처럼 보이는 HTTP 요청

TanStack Start에서 가장 눈에 띄는 건 서버 함수예요. 서버에서만 실행되는 함수를 정의하고, 클라이언트에서 마치 일반 함수처럼 호출할 수 있거든요.

```typescript import { createServerFn } from "@tanstack/react-start";

const getServerTime = createServerFn().handler(async () => { return new Date().toISOString(); });

const time = await getServerTime(); ```

겉보기엔 그냥 함수 호출이에요. 근데 빌드할 때 서버 코드가 클라이언트 번들에서 빠지고 그 자리에 HTTP 요청 코드가 들어가요. 데이터베이스 접근, 환경 변수 읽기, 파일 시스템 접근 같은 서버 전용 작업을 안전하게 쓸 수 있는 거죠.

진짜 빛나는 건 라우트의 `loader`와 같이 쓸 때예요.

```typescript const getCount = createServerFn({ method: "GET" }).handler(async () => { return count; });

export const Route = createFileRoute("/")({ component: Counter, loader: async () => await getCount(), });

function Counter() { const count = Route.useLoaderData(); // count는 number로 자동 추론 } ```

`Route.useLoaderData()`가 핵심이에요. loader의 반환 타입이 여기까지 자동으로 흘러와서 별도 타입 선언 없이도 `count`가 `number`로 추론돼요. 이게 그냥 되는 게 아니라, TanStack Router와 Start가 한 몸처럼 붙어 있어서 가능한 거예요.

입력 검증도 `createServerFn`에 붙일 수 있어요. Zod 스키마를 넘기면 런타임 유효성 검사와 TypeScript 타입 추론을 한 번에 해결하죠.

```typescript import { z } from "zod";

const createUser = createServerFn({ method: "POST" }) .inputValidator(z.object({ name: z.string().min(1), age: z.number().min(0), })) .handler(async ({ data }) => { // data는 { name: string; age: number }로 추론 return `${data.name}님(${data.age}세)을 등록했습니다.`; }); ```

에러 처리도 깔끔해요. 인증이 필요하면 `throw redirect({ to: "/login" })`, 데이터가 없으면 `throw notFound()`. redirect와 notFound 모두 TanStack Router에서 가져오는 건데 서버 함수 안에서도 똑같이 동작하거든요. 라우터와 프레임워크가 완전히 통합돼 있다는 증거예요.

미들웨어 체이닝 — 타입이 끝까지 흐른다

인증, 로깅, 권한 확인 같은 공통 로직을 매번 반복하기 싫으면 미들웨어를 쓰면 돼요.

```typescript const authMiddleware = createMiddleware().server(async ({ next, request }) => { const session = await getSession(request.headers); if (!session) throw new Error("인증이 필요합니다"); return next({ context: { session } }); });

const adminMiddleware = createMiddleware() .middleware([authMiddleware]) .server(async ({ next, context }) => { if (context.session.role !== "admin") { throw new Error("관리자 권한이 필요합니다"); } return next(); }); ```

`authMiddleware`가 먼저 실행되고, 통과하면 `adminMiddleware`가 실행되는 구조예요. 이걸 서버 함수에 적용하면 이렇게 돼요.

```typescript const deleteUser = createServerFn() .middleware([adminMiddleware]) .handler(async ({ context }) => { // context.session이 타입 안전하게 사용 가능 return { success: true }; }); ```

미들웨어에서 `context`에 넣은 `session`의 타입이 handler까지 자동으로 전파돼요. 미들웨어 체인 전체에서 타입이 흐르기 때문에 `context.session`을 쓸 때 자동 완성이 바로 뜨거든요.

이게 TanStack Start가 말하는 "엔드투엔드 타입 안전성"의 실체예요. 라우트 경로, 검색 파라미터, 서버 함수 입출력, 미들웨어 컨텍스트까지 전부 컴파일 타임에 체크돼요. `<Link to="/posst">`처럼 경로를 잘못 치면 에디터에서 바로 빨간 줄이 뜨는 거죠.

Vite 기반이라 배포를 안 가린다

프로젝트 세팅은 Vite 플러그인 형태로 통합돼요.

```typescript import { defineConfig } from "vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import react from "@vitejs/plugin-react";

export default defineConfig({ plugins: [tanstackStart(), react()], }); ```

`tanstackStart()` 플러그인이 파일 기반 라우팅, 서버 함수 변환, SSR 설정을 다 처리해줘요. `src/routes/` 디렉토리에 파일을 추가하면 자동으로 라우트가 등록되고, `routeTree.gen.ts`가 알아서 생성돼요. `about.tsx`를 만들면 `/about`, `posts/$postId.tsx`를 만들면 `/posts/:postId` 동적 라우트가 되는 식이죠.

라우터 설정에서 `declare module` 부분이 포인트인데, 이걸 선언해 두면 앱 어디서든 라우터 타입 정보를 쓸 수 있어요.

서버 함수 안에서 HTTP 요청/응답을 직접 다뤄야 할 때는 `getRequestHeader()`, `setResponseHeaders()`, `setResponseStatus()` 같은 유틸리티 함수를 제공하고요.

Next.js와 뭐가 다른가 — 철학이 다르다

같은 풀스택 React 프레임워크지만 접근이 정반대예요.

Next.js는 서버 컴포넌트가 기본이고 상호작용이 필요한 곳에서만 `"use client"`를 선언해요. TanStack Start는 반대. 모든 컴포넌트가 기본적으로 클라이언트에서 돌아가고, 서버 로직은 서버 함수로 명시적으로 떼어내요.

캐싱 방식도 꽤 달라요. Next.js 캐싱은 요청 메모이제이션, 데이터 캐시, 전체 라우트 캐시, 라우터 캐시 등 여러 계층이 겹쳐서 동작을 예측하기 까다로울 때가 있거든요. (솔직히 Next.js 캐싱 동작을 100% 예측할 수 있다고 말하는 사람은 좀 의심스러워요.) TanStack Start는 TanStack Query의 SWR(stale-while-revalidate) 패턴을 그대로 가져가요. 이미 TanStack Query를 써봤으면 새로 배울 게 없다는 뜻이죠.

배포도 마찬가지. Next.js가 Vercel에 최적화돼 있는 반면, TanStack Start는 Vite 기반이라 Cloudflare든 Netlify든 AWS든 가리지 않아요.

물론 Next.js의 장점은 분명해요. 생태계가 훨씬 크고 튜토리얼이나 커뮤니티 자료도 압도적으로 많죠. RSC도 이미 안정화됐고요. TanStack Start는 아직 RC라 프로덕션 투입은 좀 이를 수 있어요.

사이드 프로젝트에서 먼저 손에 익혀보는 게 현실적인 접근이에요. TanStack Query나 TanStack Router를 이미 쓰고 있으면 적응이 훨씬 수월할 거고요. 타입 안전성에 진심인 팀이라면, Next.js에서 느끼던 불편함이 TanStack Start에서는 상당 부분 해소될 거예요.