tRPC 완벽 가이드 — End-to-end 타입 안정성으로 API 설계하기
이 글의 핵심
이 글은 tRPC를 사용해 엔드투엔드(End-to-end) 타입 안정성을 갖춘 API를 설계·구현하는 흐름을 한 번에 정리한 가이드입니다. REST나 OpenAPI로 흔히 겪는 «스펙 문서와 실제 타입 불일치», «런타임에야 드러나는 필드 누락» 같은 문제를, 단일 TypeScript 코드베이스 안에서 어떻게 줄이는지에 초점을 맞춥니다.
다루는 내용: tRPC의 핵심 개념과 장점, Router와 Procedure 정의, Context와 미들웨어, TanStack React Query와의 통합, Next.js App Router에서의 권장 설정, WebSocket 기반 구독(Subscriptions), Zod를 이용한 입력 검증과 에러 처리입니다.
1. tRPC가 해결하는 문제
1.1 API 계층에서의 타입 불일치
전통적인 REST API는 서버가 JSON을 내보내고, 클라이언트는 별도의 타입 정의(*.d.ts, 생성된 클라이언트, 수동 인터페이스)에 의존합니다. 한쪽만 수정하면 다른 쪽은 컴파일러가 잡아주지 못하고, 배포 후 런타임 오류로 이어지기 쉽습니다.
tRPC는 서버에 정의한 라우터 타입을 그대로 클라이언트의 메서드 체인 타입으로 노출합니다. 즉, «호출 가능한 경로», «입력 형태», «반환 형태」가 한 타입 그래프로 연결됩니다. 별도의 코드 생성 단계 없이도(Zero-codegen 스타일) 편집기 자동 완성과 타입 검사가 클라이언트까지 이어집니다.
1.2 프로시저 기반 모델
tRPC는 HTTP 메서드와 URL 패턴을 직접 나열하기보다, router → procedure 트리로 API를 표현합니다. 각 procedure는 query(읽기), mutation(변경), subscription(스트림) 중 하나이며, 이름 공간은 중첩 라우터로 모듈화할 수 있습니다. 이 구조 덕분에 프론트에서는 trpc.user.list.useQuery()처럼 안전하게 호출할 수 있습니다.
1.3 런타임 검증과의 결합
타입만으로는 네트워크 경계의 모든 입력을 보장할 수 없습니다. tRPC는 Zod 등 스키마와 잘 맞물리도록 설계되어, 같은 스키마로 입력 파싱 + TypeScript 타입 추론을 동시에 가져가기 쉽습니다. 이는 «컴파일 타임 안전성»과 «런타임 거부」를 함께 갖추는 실무 패턴입니다.
2. 핵심 빌딩 블록: initTRPC, Router, Procedure
2.1 initTRPC로 공장 만들기
서버 측에서는 initTRPC로 인스턴스를 만들고, 그 위에 router, procedure, 미들웨어를 붙입니다. Context 타입을 넣으면 이후 모든 procedure에서 ctx의 타입이 추론됩니다.
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import type { Context } from './context';
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const mergeRouters = t.mergeRouters;
export const middleware = t.middleware;
export const publicProcedure = t.procedure;
initTRPC.context<Context>()를 쓰지 않으면 ctx는 기본적으로 빈 객체에 가깝게 다뤄집니다. 인증 DB 세션 등을 쓰려면 한 번만 Context 타입을 정의해 두는 것이 좋습니다.
2.2 Router: API 트리의 루트와 병합
작은 단위의 router({ ... })를 여러 파일로 나눈 뒤, mergeRouters 또는 중첩 필드로 합치는 방식이 일반적입니다. 루트 라우터의 typeof를 export하면 클라이언트가 그 타입만 import해 엔드투엔드 연결이 완성됩니다.
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
export const userRouter = router({
list: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.user.findMany();
}),
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
return await ctx.db.user.findUnique({ where: { id: input.id } });
}),
});
// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
export const appRouter = router({
user: userRouter,
post: postRouter,
});
export type AppRouter = typeof appRouter;
AppRouter 타입 하나가 클라이언트 제네릭에 들어가며, 경로 오타나 잘못된 인자는 빌드 단계에서 차단됩니다.
2.3 Procedure: query / mutation / subscription
- query: idempotent한 읽기에 적합합니다. React Query와 매핑될 때 캐시 키·리페치의 기본 단위가 됩니다.
- mutation: 생성·수정·삭제. 성공 시 관련 query를
invalidate하거나setData로 동기화하는 패턴이 흔합니다. - subscription: WebSocket 등 스트림 전송 계층과 함께 쓰일 때 실시간 이벤트를 밀어낼 수 있습니다. 인프라 구성이 필요하므로 뒤 절에서 따로 정리합니다.
.input(schema)를 생략하면 입력은 void에 가깝게 다루어지고, 반환 타입은 함수의 반환값으로부터 추론됩니다. 복잡한 출력까지 강하게 묶고 싶다면 z로 출력도 검증하는 팀도 있으나, 비용 대비로는 입력 쪽 Zod가 우선인 경우가 많습니다.
3. Context와 미들웨어
3.1 Context 생성
Context는 요청마다 한 번 만들어지며, DB 커넥션, 로그인 사용자, 요청 ID 등을 담습니다. Next.js 어댑터에 따라 req/res 또는 headers에 접근하는 형태가 달라지므로, 프로젝트에서 한 가지 패턴으로 통일하는 것이 중요합니다.
// server/context.ts
import type { inferAsyncReturnType } from '@trpc/server';
export async function createContext(opts: { headers: Headers }) {
const session = await getSessionFromCookie(opts.headers);
return {
session,
db,
};
}
export type Context = inferAsyncReturnType<typeof createContext>;
3.2 미들웨어로 횡단 관심사 분리
t.middleware는 procedure 앞뒤로 공통 로직을 끼워 넣습니다. 인증은 «ctx에 사용자가 없으면 TRPCError», 로깅은 «경로·소요 시간 기록»처럼 선언적으로 쌓을 수 있습니다.
// server/trpc.ts (일부)
import { TRPCError, initTRPC } from '@trpc/server';
import type { Context } from './context';
const t = initTRPC.context<Context>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
session: ctx.session,
user: ctx.session.user,
},
});
});
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
next({ ctx: { ... } })로 좁혀진 ctx를 다음 단계에 넘기면, 이후 procedure에서는 인증된 사용자 타입만 다루게 만들 수 있습니다. 이는 타입 좁히기(narrowing) 측면에서도 유리합니다.
3.3 미들웨어 체인 순서
여러 미들웨어를 .use(a).use(b)로 연결할 때, 실행 순서와 ctx 확장이 설계 의도와 맞는지 확인해야 합니다. 로깅·레이트 리밋·감사 로그 등은 가능한 한 가장 바깥에 두고, 인증·권한은 비즈니스 procedure 직전에 두는 편이 디버깅에 유리합니다.
4. TanStack React Query와 통합
tRPC의 React 바인딩은 내부적으로 TanStack Query를 사용합니다. 캐시, 백그라운드 리페치, 낙관적 업데이트 등을 타입이 보존된 채 활용할 수 있습니다.
4.1 클라이언트 생성 (createTRPCReact)
// lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
4.2 Provider 구성
앱 루트에서 QueryClient와 tRPC의 client를 함께 제공합니다. SSR/SSG 전략에 따라 defaultOptions의 staleTime 등을 조정합니다.
// providers/trpc-provider.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from '@/lib/trpc';
import superjson from 'superjson';
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson,
}),
],
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
superjson은 Date, Map 등 JSON만으로는 손실되는 값을 다룰 때 유용합니다. 서버·클라이언트 양쪽에 동일한 transformer 설정이 있어야 합니다.
4.3 훅 사용과 캐시 무효화
const utils = trpc.useUtils();
const create = trpc.post.create.useMutation({
onSuccess: async () => {
await utils.post.list.invalidate();
},
});
useUtils()로 얻는 헬퍼는 AppRouter 타입을 따르기 때문에 잘못된 경로로 invalidate하는 실수를 줄여 줍니다.
5. Next.js App Router 통합
Pages Router 시절의 createNextApiHandler와 달리, App Router에서는 Fetch API 어댑터로 Route Handler를 구성하는 방식이 일반적입니다. 한 Route에서 GET/POST를 함께 처리해 tRPC의 HTTP 프로토콜을 만족시킵니다.
5.1 Route Handler
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/context';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
router: appRouter,
req,
createContext: () => createContext({ headers: req.headers }),
});
export { handler as GET, handler as POST };
5.2 레이아웃에 Provider 장착
// app/layout.tsx
import { TRPCProvider } from '@/providers/trpc-provider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}
5.3 서버 컴포넌트와의 역할 분담
React Server Component에서는 브라우저 훅을 쓸 수 없으므로, 데이터 페칭은 서버에서 직접 호출 가능한 헬퍼(예: appRouter.createCaller)나 일반 서비스 레이어로 처리하고, 클라이언트 위젯에서만 trpc.*.useQuery를 쓰는 식으로 경계를 나누는 경우가 많습니다. 한 API를 RSC와 클라이언트가 동시에 쓸 때는 캐시 일관성을 React Query와 맞추기 위해 설계가 필요합니다.
6. WebSocket과 Subscriptions
실시간 알림, 채팅, 진행률 스트림 등은 단발 HTTP보다 이벤트 스트림이 적합할 때가 있습니다. tRPC의 subscription은 전송 계층으로 WebSocket을 자주 사용하며, 클라이언트에서는 splitLink로 «subscription은 WS, 나머지는 HTTP」로 나누는 구성이 흔합니다.
6.1 서버: observable
import { observable } from '@trpc/server/observable';
import { z } from 'zod';
import { publicProcedure, router } from '../trpc';
export const realtimeRouter = router({
onMessage: publicProcedure
.input(z.object({ channelId: z.string() }))
.subscription(({ input }) => {
return observable<{ text: string }>((emit) => {
const handler = (payload: { text: string }) => emit.next(payload);
messageBus.subscribe(input.channelId, handler);
return () => messageBus.unsubscribe(input.channelId, handler);
});
}),
});
구독 해제 함수에서 리스너를 반드시 정리해야 메모리 누수를 막을 수 있습니다.
6.2 클라이언트: WebSocket 링크
운영 환경에서는 인증 토큰을 쿼리 또는 첫 메시지로 보내는 방식, 스티키 세션, 프록시 타임아웃 등을 함께 설계해야 합니다. tRPC 자체보다 인프라·보안 이슈 비중이 큰 영역입니다.
import { createWSClient, wsLink } from '@trpc/client';
import { splitLink, httpBatchLink } from '@trpc/client';
const wsClient = createWSClient({ url: 'wss://api.example.com/trpc' });
const link = splitLink({
condition: (op) => op.type === 'subscription',
true: wsLink({ client: wsClient }),
false: httpBatchLink({ url: '/api/trpc' }),
});
개발 단계에서는 HTTP 폴링이나 SSE로 대체하는 팀도 있으며, 실시간 요구사항과 운영 비용을 함께 보는 것이 좋습니다.
7. 에러 처리와 Zod 검증
7.1 TRPCError 코드
tRPC는 UNAUTHORIZED, FORBIDDEN, NOT_FOUND, BAD_REQUEST, INTERNAL_SERVER_ERROR 등 기계가 읽기 쉬운 코드로 오류를 분류합니다. 클라이언트는 React Query의 error 객체에서 data?.code 등을 읽어 UI를 분기할 수 있습니다.
import { TRPCError } from '@trpc/server';
throw new TRPCError({
code: 'NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
});
7.2 Zod와 입력 검증
.input(z.object({ ... }))를 사용하면 잘못된 입력은 procedure 본문에 도달하기 전에 거절됩니다. 메시지를 한글화하거나 필드별 오류를 쓰려면 Zod의 refine, superRefine, 커스텀 errorMap을 활용합니다.
const createPostInput = z.object({
title: z.string().min(1, '제목은 필수입니다.'),
body: z.string().max(50_000),
});
export const postRouter = router({
create: publicProcedure.input(createPostInput).mutation(async ({ ctx, input }) => {
return await ctx.db.post.create({ data: input });
}),
});
7.3 Zod 오류를 API 응답 형식에 맞게 매핑
프론트에서 폼 필드와 서버 오류를 매핑하려면, onError에서 Zod 이슈 배열을 파싱하거나, 서버에서 TRPCError의 cause에 요약 정보를 넣는 방식을 택할 수 있습니다. 팀 내에서 오류 페이로드 규약을 문서화해 두면 클라이언트 분기 비용이 줄어듭니다.
8. 운영 관점 체크리스트
- 버전 정책: 서버와 클라이언트 패키지 버전을 가능한 한 동기화합니다.
- 배치 링크:
httpBatchLink는 RTT를 줄이지만, 디버깅 시 요청이 묶여 혼동될 수 있어 개발용 설정을 분리합니다. - 관찰 가능성: 미들웨어에서 요청 ID·사용자 ID를 로깅하고, OpenTelemetry 등과 연동합니다.
- 보안: procedure마다 권한 검사를 빼먹지 않도록
protectedProcedure같은 타입 수준의 구분을 습관화합니다.
정리
tRPC는 TypeScript 풀스택에서 API 계층의 타입을 한 줄기로 이어, 개발 속도와 안전성을 동시에 끌어올리는 도구입니다. Router/Procedure로 도메인을 나누고, Context와 미들웨어로 인증·로깅을 걸며, React Query로 캐시 전략을 세우고, 필요 시 WebSocket으로 실시간 계층을 확장할 수 있습니다. 마지막으로 Zod와 TRPCError로 경계에서의 입력·오류를 명확히 하면, 프로덕션에서도 재현하기 어려운 «조용한 불일치」를 크게 줄일 수 있습니다.
자주 묻는 질문 (FAQ)
Q. 기존 REST 클라이언트와 tRPC를 한 프로젝트에서 같이 쓸 수 있나요?
A. 가능합니다. 다만 캐시·인증·에러 규약이 이원화되므로, 점진적 도입 시 경계 모듈을 명확히 나누는 것이 좋습니다.
Q. 모바일 앱이나 타 언어 클라이언트도 같은 tRPC 서버를 쓸 수 있나요?
A. tRPC는 TypeScript 생태계에 최적화되어 있습니다. 네이티브 앱 등은 REST/GraphQL처럼 언어 중립 API가 필요할 수 있어, 공개 API는 별도 게이트웨이를 두는 설계가 흔합니다.
Q. App Router에서 RSC만으로 데이터를 가져오면 tRPC가 불필요한가요?
A. 반드시 그렇지는 않습니다. RSC는 서버 페칭에 강하고, 클라이언트 상호작용·캐시 무효화·낙관적 UI에는 React Query가 유리한 경우가 많습니다. 역할을 나눠 쓰는 경우가 많습니다.
Q. Subscription은 언제 쓰는 것이 합리적인가요?
A. 실시간성이 비즈니스에 필수이고, WebSocket 운영(인증, 스케일아웃, 로드밸런서) 비용을 감당할 수 있을 때입니다. 그렇지 않다면 폴링·SSE·메시지 큐 기반 알림 등을 검토합니다.
이 글에서 다루는 키워드
tRPC, TypeScript, API, Type Safety, React Query, End-to-end 타입, Next.js App Router, Zod, WebSocket, Subscription