tRPC v11이 Next.js App Router에서 REST를 대체하는 방식 — 코드 생성 없는 end-to-end 타입 안전성과 서버 스트리밍
REST API 엔드포인트를 하나 추가할 때마다 반복하는 작업이 있습니다. 서버에 라우트 파일을 만들고, 요청·응답 타입을 별도로 정의하고, 클라이언트 fetch 래퍼를 작성하고, 두 파일 사이에서 타입이 어긋나지 않았는지 직접 확인하는 과정입니다. 엔드포인트가 늘어날수록 이 관리 비용은 선형이 아니라 지수적으로 커집니다. 응답 타입 변경 하나가 fetch 래퍼, 호출 컴포넌트, 테스트 픽스처 수정으로 연쇄됩니다.
tRPC v11은 이 구조를 근본부터 바꿉니다. TypeScript 추론 엔진을 적극 활용해 서버 함수의 타입이 클라이언트로 그대로 흘러들어오는 구조를 만들고, Next.js App Router의 RSC 패러다임과 맞물려 서버에서 미리 가져온 데이터가 클라이언트 React Query 캐시로 자연스럽게 이어집니다.
v11에서 핵심적으로 달라진 점은 TanStack Query와의 통합 방식입니다. 기존의 tRPC 전용 훅 레이어가 사라지고, TanStack Query 네이티브 queryOptions / mutationOptions API를 직접 소비하는 구조로 전환됐습니다.
이 글에서는 tRPC v11을 Next.js App Router 환경에 세팅하는 방법부터, RSC에서 데이터를 프리패칭해 클라이언트 워터폴을 제거하는 패턴, SSE 기반 실시간 구독, 그리고 실무에서 흔히 빠지는 함정들까지 코드와 함께 짚어봅니다.
핵심 개념
Procedure와 Router — 서버 함수의 단위
tRPC에서 서버 로직의 최소 단위는 Procedure입니다. 세 종류가 있습니다.
| 종류 | 역할 | HTTP 비유 |
|---|---|---|
query |
데이터 읽기 | GET |
mutation |
데이터 쓰기/변경 | POST / PUT / DELETE |
subscription |
서버 → 클라이언트 이벤트 스트림 | SSE |
여러 Procedure를 Router로 묶고, 라우터를 중첩해 도메인별로 구조화할 수 있습니다.
// server/routers/post.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const postRouter = router({
list: publicProcedure
.input(z.object({ limit: z.number().min(1).max(100).default(10) }))
.query(async ({ input, ctx }) => {
return ctx.db.post.findMany({ take: input.limit });
}),
create: publicProcedure
.input(z.object({ title: z.string().min(1), content: z.string() }))
.mutation(async ({ input, ctx }) => {
return ctx.db.post.create({ data: input });
}),
});
// server/routers/index.ts
export const appRouter = router({
post: postRouter,
});
export type AppRouter = typeof appRouter;이 AppRouter 타입 하나만 클라이언트로 임포트하면 모든 Procedure의 입출력 타입이 자동으로 추론됩니다. Codegen 단계가 없습니다.
Context — 요청별 데이터 주입
createTRPCContext는 각 요청마다 실행돼 Procedure에 주입할 객체(DB 연결, 인증 세션 등)를 만듭니다.
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
// App Router 환경에서는 cache()로 감싸 요청당 1회만 생성
export const createTRPCContext = cache(async () => {
const session = await auth();
return { db, session };
});
const t = initTRPC.context<typeof createTRPCContext>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session) throw new TRPCError({ code: 'UNAUTHORIZED' });
// session!으로 TypeScript에 non-null을 전달해 이후 ctx.session 접근의 타입을 확정
return next({ ctx: { ...ctx, session: ctx.session! } });
});
createTRPCContext를 Reactcache()로 감싸지 않으면 동일 요청 내에서 DB 연결이 여러 번 만들어집니다. App Router 환경에서는 필수입니다.
v11의 React Query 통합 방식 변화
v10과 v11의 가장 큰 차이는 React Query와의 통합 방식입니다.
// v10 — tRPC 전용 훅 레이어
const { data } = trpc.post.list.useQuery({ limit: 10 });
// v11 — TanStack Query 네이티브 인터페이스
const trpc = useTRPC();
const { data } = useQuery(trpc.post.list.queryOptions({ limit: 10 }));queryOptions()가 반환하는 객체는 TanStack Query 표준 QueryOptions 타입과 완전히 호환됩니다. 덕분에 캐싱, 무효화, 낙관적 업데이트, prefetchQuery 등 TanStack Query의 모든 기능을 별도 추상화 없이 그대로 쓸 수 있습니다.
패키지명도 변경됐습니다.
# 기존 패키지 제거 후 신규 패키지 설치
npm uninstall @trpc/react-query
npm install @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query zod@trpc/react-query를 제거하지 않은 채 @trpc/tanstack-react-query를 함께 설치하면 두 패키지가 충돌할 수 있습니다. package.json에서 구 패키지를 명시적으로 제거하는 것이 좋습니다.
서버 스트리밍 두 가지 축
tRPC v11은 두 종류의 스트리밍 링크를 제공합니다.
- httpBatchStreamLink: 여러 쿼리를 하나의 배치로 묶되, 각 쿼리가 완료되는 순서대로 클라이언트에 전달합니다. 느린 쿼리가 빠른 응답을 블로킹하지 않습니다.
- httpSubscriptionLink: SSE(Server-Sent Events) 기반 구독 링크입니다. SSE는 HTTP/1.1에서도 동작하며, HTTP/2는 브라우저당 동시 연결 제한(≈6개)을 다중화로 우회하는 부가적 이점을 줄 뿐입니다.
실제 클라이언트 설정에서는 splitLink로 구독과 일반 쿼리를 분기합니다. splitLink의 condition 콜백에서 op.type이 가질 수 있는 값은 'query' | 'mutation' | 'subscription' 세 가지입니다.
// lib/trpc/client.ts
import {
createTRPCClient,
httpBatchStreamLink,
httpSubscriptionLink,
splitLink,
} from '@trpc/client';
import type { AppRouter } from '@/server/routers';
export const trpcClient = createTRPCClient<AppRouter>({
links: [
splitLink({
// op.type은 'query' | 'mutation' | 'subscription'
condition: (op) => op.type === 'subscription',
true: httpSubscriptionLink({ url: '/api/trpc' }),
false: httpBatchStreamLink({ url: '/api/trpc' }),
}),
],
});실전 적용
공유 QueryClient 팩토리 — hydration의 핵심
RSC 프리패칭이 클라이언트 캐시로 이어지려면 서버와 클라이언트가 동일한 팩토리 함수로 생성된 QueryClient를 사용해야 합니다. 이 파일을 먼저 만들고 이후 모든 설정이 이를 참조하게 합니다.
// lib/trpc/query-client.ts
import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query';
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
},
dehydrate: {
// pending 상태의 쿼리도 직렬화해 Suspense 경계와 호환
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
},
},
});
}서버 전용 유틸리티 — HydrateClient와 createTRPCOptionsProxy
createTRPCOptionsProxy는 @trpc/tanstack-react-query에서 가져오는 서버 전용 함수입니다. HTTP를 거치지 않고 appRouter의 Procedure를 직접 호출해 QueryClient 캐시를 채웁니다. HydrateClient는 이 캐시를 직렬화해 클라이언트로 전달하는 서버 컴포넌트입니다.
// lib/trpc/server.ts
import { cache } from 'react';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { makeQueryClient } from './query-client';
import { appRouter } from '@/server/routers';
import { createTRPCContext } from '@/server/trpc';
// 요청당 1회만 생성
export const getQueryClient = cache(makeQueryClient);
export function HydrateClient({ children }: { children: React.ReactNode }) {
return (
<HydrationBoundary state={dehydrate(getQueryClient())}>
{children}
</HydrationBoundary>
);
}
export async function createServerTRPC() {
const ctx = await createTRPCContext();
const queryClient = getQueryClient();
return createTRPCOptionsProxy({ ctx, router: appRouter, queryClient });
}App Router 라우트 핸들러
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers';
import { createTRPCContext } from '@/server/trpc';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
});
export { handler as GET, handler as POST };TanStack Query Provider
클라이언트 컴포넌트 트리의 최상단에서 QueryClientProvider와 TRPCProvider를 구성합니다. useTRPC는 Provider 컨텍스트에 의존하는 클라이언트 전용 훅이므로, 이 파일을 import하는 컴포넌트 역시 클라이언트 컴포넌트여야 합니다.
// components/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createTRPCReactContext } from '@trpc/tanstack-react-query';
import { trpcClient } from '@/lib/trpc/client';
import { makeQueryClient } from '@/lib/trpc/query-client';
import type { AppRouter } from '@/server/routers';
export const { TRPCProvider, useTRPC } = createTRPCReactContext<AppRouter>();
let browserQueryClient: QueryClient | undefined;
function getQueryClient() {
if (typeof window === 'undefined') {
// SSR: 매번 새로 생성
return makeQueryClient();
}
// 브라우저: 싱글톤 유지 (React Suspense 재렌더 시에도 동일 인스턴스 사용)
return (browserQueryClient ??= makeQueryClient());
}
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{children}
</TRPCProvider>
</QueryClientProvider>
);
}RSC 프리패칭 — 클라이언트 워터폴 제거
App Router의 서버 컴포넌트에서 createServerTRPC()로 데이터를 미리 가져오면, HydrateClient가 캐시를 직렬화해 HTML과 함께 전달합니다. 클라이언트 컴포넌트가 마운트됐을 때 동일한 queryOptions로 조회하면 캐시 히트가 발생해 네트워크 요청이 발생하지 않습니다.
// app/posts/page.tsx — Server Component
import { createServerTRPC, HydrateClient } from '@/lib/trpc/server';
import PostList from './post-list';
export default async function PostsPage() {
const trpc = await createServerTRPC();
await trpc.post.list.prefetchQuery({ limit: 20 });
return (
<HydrateClient>
<PostList />
</HydrateClient>
);
}// app/posts/post-list.tsx — Client Component
// useTRPC는 Provider 컨텍스트에 의존하므로 반드시 클라이언트 컴포넌트에서 사용
'use client';
import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@/components/providers';
export default function PostList() {
const trpc = useTRPC();
const { data, isLoading } = useQuery(
trpc.post.list.queryOptions({ limit: 20 })
);
if (isLoading) return <div>로딩 중...</div>;
return (
<ul>
{data?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Mutation — 입력 유효성 검사와 캐시 무효화
// app/posts/create-post-form.tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/components/providers';
export default function CreatePostForm() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const createPost = useMutation(
trpc.post.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: trpc.post.list.queryKey(),
});
},
})
);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createPost.mutate({
title: formData.get('title') as string,
content: formData.get('content') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="제목" required />
<textarea name="content" placeholder="내용" />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? '저장 중...' : '게시'}
</button>
{createPost.isError && (
{/* 프로덕션에서는 error.message를 그대로 노출하지 않고 사용자 친화적 메시지로 변환 권장 */}
<p>오류가 발생했습니다. 잠시 후 다시 시도해 주세요.</p>
)}
</form>
);
}Zod 스키마에서 벗어난 값이 전달되면 서버가 BAD_REQUEST로 응답하고, 클라이언트의 createPost.error에 타입 안전하게 담깁니다. error.message는 서버 내부 오류 메시지를 그대로 담고 있을 수 있으므로, 사용자에게 보여줄 때는 별도의 메시지로 변환하는 것이 좋습니다.
SSE 구독 — 실시간 이벤트 피드
서버 쪽 subscription Procedure는 async function* 제너레이터로 이벤트를 yield합니다. while(true) + setTimeout 폴링 패턴은 SSE가 아닙니다. 실제 이벤트 기반 구독은 EventEmitter(또는 Redis pub/sub, DB change stream)에서 yield하고, 클라이언트 연결 해제 시 signal로 루프를 종료해야 합니다.
// server/routers/notification.ts
import { on, EventEmitter } from 'events';
import { router, protectedProcedure } from '../trpc';
// 실제 환경에서는 Redis pub/sub 또는 DB change stream 사용 권장
export const notificationEmitter = new EventEmitter();
export const notificationRouter = router({
feed: protectedProcedure.subscription(async function* ({ ctx, signal }) {
const userId = ctx.session.user.id;
// 연결 시 미읽은 알림 즉시 전달
const pending = await ctx.db.notification.findMany({
where: { userId, read: false },
orderBy: { createdAt: 'desc' },
take: 5,
});
if (pending.length > 0) yield pending;
// signal이 abort될 때(클라이언트 연결 해제)까지 이벤트 대기
for await (const [notification] of on(
notificationEmitter,
`notification:${userId}`,
{ signal }
)) {
yield [notification];
}
}),
});// components/notification-bell.tsx
'use client';
import { useSubscription } from '@trpc/tanstack-react-query';
import { useTRPC } from '@/components/providers';
export default function NotificationBell() {
const trpc = useTRPC();
const { data: notifications } = useSubscription(
trpc.notification.feed.subscriptionOptions(undefined, {
onData: (data) => console.log('새 알림:', data),
})
);
return <span>알림 {notifications?.length ?? 0}개</span>;
}SSE는 HTTP/1.1에서도 동작합니다. HTTP/2는 브라우저당 동시 연결 제한(≈6개)을 다중화로 우회하는 부가적 이점을 줄 뿐, SSE의 전제 조건이 아닙니다. 클라이언트 → 서버 방향 메시지가 필요 없는 알림, 실시간 대시보드, 주문 상태 업데이트 시나리오에서 WebSocket보다 설정이 단순해 실용적인 선택입니다.
단, Vercel 등 서버리스/엣지 함수 환경은 실행 시간 상한이 있어 장시간 SSE 연결을 유지할 수 없습니다. 자체 호스팅 서버 또는 Vercel의 Fluid compute 환경에서 사용하거나, 강한 실시간 요구사항이 있다면 별도 WebSocket 서버를 함께 고려해야 합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 코드 생성 불필요 | GraphQL codegen 없이 TypeScript 추론만으로 end-to-end 타입 안전성 확보 |
| React Query 네이티브 통합 | queryOptions() 반환값을 useQuery에 직접 전달, TanStack Query 기능 전체 활용 가능 |
| 보일러플레이트 감소 | API 라우트 파일, 타입 정의, fetch 래퍼를 별도로 작성할 필요 없음 |
| RSC → 클라이언트 캐시 연속성 | HydrateClient를 통해 서버 프리패치 데이터가 클라이언트 캐시에 그대로 전달 |
| 스트리밍 우선 아키텍처 | httpBatchStreamLink로 느린 쿼리가 빠른 응답을 블로킹하지 않음 |
| 미들웨어 기반 인증/권한 | protectedProcedure 패턴으로 인증 로직을 절차 단위로 재사용 |
단점 및 제약
| 항목 | 내용 |
|---|---|
| TypeScript 전용 | 클라이언트·서버 모두 TypeScript 필수. Python, Go, Swift 등 다른 언어 클라이언트 불가 |
| 모노레포 선호 구조 | AppRouter 타입 임포트를 위해 클라이언트·서버가 같은 저장소에 있어야 이점이 극대화됨 |
| 공개 API에 부적합 | 외부 서드파티가 소비하는 공개 API는 REST / OpenAPI가 적합 |
| v10 → v11 마이그레이션 비용 | 패키지명 변경, 훅 사용 방식 변경, App Router 설정 구조 변경으로 수정 범위가 넓음 |
| 깊은 중첩 라우터의 TS 추론 지연 | 라우터·미들웨어가 깊어질수록 TypeScript LSP 응답이 느려질 수 있음 |
| 서버리스 환경의 SSE 제약 | 실행 시간 상한이 있는 서버리스 환경에서는 장시간 구독 유지 불가 |
실무에서 흔한 실수
1. Context 생성 시 cache() 누락
// 잘못된 예 — 요청마다 Context가 중복 생성될 수 있음
export const createTRPCContext = async () => { /* ... */ };
// 올바른 예 — 요청당 1회 보장
export const createTRPCContext = cache(async () => { /* ... */ });2. 서버와 클라이언트 QueryClient 팩토리 불일치
providers.tsx에서 new QueryClient()를 직접 호출하면 서버 프리패치 데이터가 클라이언트 캐시로 전달되지 않습니다. 공유 makeQueryClient() 팩토리를 만들고 양쪽에서 참조해야 합니다.
3. v10 스타일 훅을 v11에서 그대로 사용
// v11에서 동작하지 않는 v10 패턴
const { data } = trpc.post.list.useQuery({ limit: 10 }); // ❌
// v11 올바른 패턴
const trpc = useTRPC();
const { data } = useQuery(trpc.post.list.queryOptions({ limit: 10 })); // ✅4. HydrateClient 없이 prefetchQuery 호출
RSC에서 prefetchQuery를 호출했더라도 HydrateClient로 감싸지 않으면 캐시가 클라이언트로 전달되지 않아 동일 요청이 재발생합니다.
5. 구독 프로시저에서 signal 처리 누락
클라이언트가 연결을 끊었을 때 서버 루프가 종료되지 않으면 리소스 누수가 발생합니다. async function* ({ ctx, signal })에서 signal을 받아 for await ... of on(emitter, event, { signal }) 패턴으로 루프를 종료해야 합니다.
언제 tRPC 대신 다른 선택지를 고려할지
| 상황 | 권장 대안 |
|---|---|
| 외부 서드파티에 API를 노출해야 할 때 | REST + OpenAPI, oRPC |
| 기존 Express/Fastify 코드베이스를 유지하면서 타입 안전성만 더하고 싶을 때 | ts-rest |
| Cloudflare Workers 같은 엣지 런타임 특화 경량 솔루션 | Hono RPC |
| 클라이언트가 TypeScript가 아닐 때 | REST / GraphQL |
마치며
tRPC v11이 이전 버전과 가장 크게 달라진 점은 TanStack Query와의 관계 재정립입니다. tRPC가 React Query를 추상화하는 게 아니라, tRPC가 TanStack Query의 표준 인터페이스인 queryOptions / mutationOptions를 생성해주는 역할로 물러납니다. TanStack Query의 모든 기능을 그대로 활용하면서, 네트워크 레이어의 타입 안전성은 tRPC가 담당하는 명확한 역할 분리입니다.
App Router 환경에서의 핵심 흐름은 세 단계입니다. createServerTRPC()로 서버에서 데이터를 프리패칭하고, HydrateClient로 캐시를 직렬화해 전달하고, 클라이언트 컴포넌트에서 useTRPC() + useQuery()로 캐시를 소비합니다. 이 흐름이 자리잡으면 REST API 라우트 파일, 타입 정의 파일, fetch 래퍼를 따로 관리할 필요가 없어집니다.
실제 프로젝트 구조를 빠르게 확인하고 싶다면 create-t3-app 스타터와 tRPC 공식 예제 레포가 좋은 참고점입니다. 공식 예제는 App Router + tRPC v11 + Prisma 조합의 실제 파일 구조를 그대로 확인할 수 있습니다.
참고 자료
- Announcing tRPC v11 — tRPC 공식 블로그
- Set up with Next.js App Router — tRPC 공식 문서
- Set up with React Server Components — tRPC 공식 문서
- Introducing the new TanStack React Query integration — tRPC 블로그
- HTTP Batch Stream Link — tRPC 공식 문서
- Subscriptions (SSE) — tRPC 공식 문서
- Migrate from v10 to v11 — tRPC 공식 문서
- tRPC 11 Setup for Next.js App Router 2025 — DEV Community
- Mastering tRPC with React Server Components: The Definitive 2026 Guide — DEV Community
- tRPC v11: What's New and Should You Upgrade? — PkgPulse Blog
- tRPC v11 vs oRPC vs ts-rest: Type-Safe RPC for SaaS Boilerplates 2026 — StarterPick
- When to Choose REST Over tRPC — Wisp CMS
- Building Production-Ready tRPC APIs — InfoQ
- tRPC GitHub Releases