tRPC 완벽 가이드 — End-to-end 타입 안정성으로 API 설계하기
이 글의 핵심
tRPC로 서버·클라이언트 타입을 하나로 묶는 방법을 정리했습니다. Router·Procedure, Context·미들웨어, React Query, Next.js App Router, WebSocket, Zod 검증·에러 처리까지 실무 관점으로 설명합니다.
먼저 질투(?) 나는 이야기 하나
옛날엔 백엔드가 DTO 쓰고, 프론트가 interface 복붙하고, OpenAPI는 «대충» 맞춰 두고, 배포는 금요일에 하고… 런타임에만 터지는 그림을 너무 많이 봤어요. 저 포함해서요. 한쪽에서 필드 하나 바꾸면 나머지 팀은 문서 뒤져 보고, 아니면 프로덕션에서 처음 압니다. 타입이 «있는 것 같은데» 실제론 끊겨 있던 거죠.
tRPC에 옮긴 뒤로는 이야기가 달라져요. 서버에 procedure를 박는 순간, 그 시그니처가 클라이언트 trpc.* 호출 전체로 흘러갑니다. 와이어를 넘는 건 JSON이 맞는데, 그 전에 TypeScript가 «여기에 이 필드 있어?»를 같이 봐 주는 느낌이에요. 제 기준에선 풀스택이 전부 TypeScript면(모노레포든, 한 리포에 프론트·API 같이 둔 형태든) 공개 API가 아닌 이상 tRPC 쪽이 기본 옵션이에요. REST·GraphQL이 나쁜 게 아니라, 내부 제품에선 굳이 스펙 싱크 비용을 두 번 내기 싫다는 쪽이죠.
이 글에선 Router·Procedure, Context·미들웨어, React Query 붙이기, App Route Handler, WebSocket·subscription, Zod·TRPCError까지, 예전에 제가 막히던 지점 기준으로 훑어볼게요.
1. tRPC가 해결하는 문제 (제 경험 버전)
1.1 API 계층에서 타입이 헛도는 느낌
REST는 익숙하고 좋죠. 다만 타입을 «공유»한다고 말하려면, 실제로는 생성 코드나 수동 DTO, 혹은 팀 룰에 의존해요. 한 번이라도 «백엔드는 바꿨는데 프론트 타입만 옛날」 걸리면, 그때부터는 문서보다 믿을 건 tsc라는 쪽으로 마음이 기울어요.
tRPC는 서버 라우터의 타입이 그대로 클라이언트 메서드 체인으로 붙어요. 경로, 인자, 반환 — 한 그래프요. 제가 좋아하는 지점은 코드젠을 또 돌리는 단계가 심리적으로 가벼워진다는 거예요(원하면 여전히 엄격하게 갈 수 있고요).
1.2 프로시저 트리
URL 패턴만 늘어나는 것보다 router → procedure로 묶이면, trpc.user.list.useQuery() 같은 호출이 이름·인자·반환까지 IDE가 다 잡아줘요. 팀에 신입이 와도 «어느 엔드포인트 썼더라?» 탐색 시간이 확 줄어요. 팀마다 이건 체감이 크답니다.
1.3 Zod는 같이 끼는 게 이상하리만큼 잘 맞음
타입은 컴파일만 해 주고, 와이어는 런타임이니까, 경계엔 Zod를 얹는 패턴이 많아요. 같은 스키마로 파싱 + 추론 — 이거 하다 보면 «API 검증이 왜 tRPC+Zod 조합이 자주 찍히는지» 이해가 돼요.
2. initTRPC, Router, Procedure
2.1 initTRPC로 공장 하나 두기
서버 쪽에선 initTRPC로 인스턴스 만들고, 그 위에 router·procedure·미들웨어를 쌓아요. Context 타입을 한 번 꽂아 두면 이후 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;
Context를 안 쓰면 ctx가 비어서 아쉬워요. 세션, DB, 요청 ID — 한 군데에서만 잡아 두는 습관이 나중에 보안·로그 다 편해요. 저는 이거 게을리했다가 protected랑 public 섞을 때 괴롭힌 적 있어요.
2.2 Router: 쪼개서 합치기
user/post 라우터 나누고, 루트에서 합친 뒤 AppRouter 타입만 export — 클라이언트는 이거 하나 import하면 끝이에요. «경로 오타»가 빌드에서 걸리는 건 익숙한 REST 느낌이랑은 좀 달라요(편하다는 쪽).
// 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;
2.3 query / mutation / subscription
- query: 읽기, 캐시 단위로 잘 쪼개지죠.
- mutation: 끝나고
invalidate·낙관적 업데이트. - subscription: 실시간 쓰려면 WS까지 같이 봐야 해서, 운영 비용 먼저 짚고 가는 편이에요(아래 6절).
.input 없으면 인자는 거의 void 느낌, 반환은 handler 추론이에요. 저는 입력 Zod는 지르고, 출력까지 전부 z로 묶는 팀은 상황 봐서라고 봐요(비용 대비).
3. Context랑 미들웨어
3.1 Context
요청마다 한 번씩. 쿠키에서 세션, DB 커넥션 — 어댑터마다 req/headers 모양이 달라서, 한 프로젝트 안에선 패턴을 통일하는 게 제일 중요해요(여기 흔들리면 createContext만 디버깅하다 하루 갑니다).
// 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 미들웨어로 인증 빼기
인증이 없으면 TRPCError로 끊고, 있으면 next로 user를 ctx에 좁혀 넘기는 패턴이 제일 깔끔해요.
// 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);
protected 이후 procedure에서는 user가 optional이 아니게 쓰기 좋죠.
3.3 체인 순서
.use(a).use(b) 쌓을 때 누가 먼저 도는지 헷갈리면, 로그 찍다가 인증이 두 번… 같은 일이 나요. 저는 레이트 리밋·감사 로그는 바깥, 인증·권한은 비즈 직전에 두는 쪽이 디버깅이 편했어요.
4. TanStack React Query랑
tRPC React 쪽은 내부적으로 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/CSR 전략에 맞게 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>
);
}
Date·Map 쓰면 superjson 양쪽 맞추는 걸 잊지 마요. 한쪽만 켜 두면 «왜 undefined지?» 반복.
4.3 무효화
const utils = trpc.useUtils();
const create = trpc.post.create.useMutation({
onSuccess: async () => {
await utils.post.list.invalidate();
},
});
useUtils가 AppRouter를 알아서, invalidate 경로 꼬이는 실수가 줄어요. 저는 이거 없을 때 팀마다 queryKey 문자열 흩어지는 것만 봐도 tRPC 쓰는 이유 하나는 충분하다고 봐요.
5. Next.js App Router
createNextApiHandler 말고, 요즘은 fetch 어댑터로 GET/POST 한 route에서 받는 쪽이 일반적이에요.
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 RSC vs 클라이언트
RSC에선 훅이 없으니까, 서버에선 createCaller나 서비스 레이어로 직접, 클라이언트 위젯에만 useQuery — 이 역할 나눔은 팀마다 갈리는데, 한 API를 RSC·클라이언트가 둘 다 쓰면 캐시 일관성은 미리 합의하는 게 좋아요(여기서 싸움 나요. 경험담).
6. WebSocket / subscription
채팅, 진행률 스트림 같이 진짜 실시간이면 subscription + WS, 나머지는 HTTP. 클라이언트에선 splitLink로 갈라 쓰는 그림 흔해요. 다만 tRPC 이전에 WS 인프라부터 생각하세요(스티키, 토큰, 타임아웃 — tRPC보다 이쪽이 치는 경우 많음).
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);
});
}),
});
return으로 구독 해제 — 이거 안 하면 리스너 누수로 기억 먹습니다.
6.2 클라이언트: WS 링크
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
UNAUTHORIZED, NOT_FOUND 같은 코드로 클라이언트가 error 객체에서 UI 분기하기 좋죠.
import { TRPCError } from '@trpc/server';
throw new TRPCError({
code: 'NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
});
7.2 .input에 Zod
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 issue를 그대로 내보낼지, TRPCError cause에 요약할지 팀 규약만 맞으면 돼요. 규약 없이 각자 onError에서 파싱하면 나중에 후회해요(저도 한 번.
8. 운영에서 내가 자주 체크하는 것
- tRPC/클라이언트 버전은 가능하면 같이 올리기(미묘한 불일치 싫어지면 늦어요)
- httpBatchLink: RTT는 줄는데, 디버깅할 땐 요청이 뭉쳐서 헷갈릴 수 있어서 dev 설정 분리
- 미들웨어에 request id / user id — 나중에 트레이스 엮기 좋아요
- procedure마다 권한 —
public/protected로 타입까지 습관화
정리 (의견 포함)
tRPC는 TypeScript 풀스택이면 API 계층을 «한 갈래 타입」으로 잡는 도구예요. 제 경험으론 내부 제품이고, 프론트·백이 같은 언어·같은 레포에 가깝다면, REST 스펙 맞추느라 쓰는 에너지를 도메인 로직에 쓰는 쪽이 이득인 경우가 많아요. 다시 말해 풀스택 TS면 tRPC를 먼저 염두에 두고, 정말 공개·다국어·모바일 네이티브가 섞이면 그때 REST/GraphQL 게이트를 논하자 — 쪽이 제 스탠스예요. Zod로 경계, TRPCError로 의미, React Query로 캐시, 필요하면 WS로 확장. 이 조합이면 «조용한 불일치»는 확실히 줄어요.
자주 묻는 질문 (FAQ)
Q. REST랑 tRPC를 한 프로젝트에 같이?
A. 점진 도입 많이 하죠. 대신 캐시·인증·에러 스타일이 이원화되니까, 경계 모듈(apiRest / trpc 같은) 나눠 두는 게 좋아요.
Q. 앱, 다른 언어 클라이언트도 붙이려면?
A. tRPC는 TypeScript 쪽이 본家예요. 공개 API·비 TS 클라이언트면 언어 중립 레이어(REST/GraphQL)를 따로 두는 설계가 흔해요. 여긴 tRPC fanboy라서 찍어 말할게요.
Q. RSC만 쓰면 tRPC 없어도 되지 않나?
A. RSC는 서버 페칭에 강하고, 낙관적 업데이트·클라이언트 캐시·무효화 쪽은 React Query(= tRPC 훅)가 편한 경우가 많아요. 같이 쓰는 팀 꽤 봤어요.
Q. subscription은?
A. 실시간이 비즈에 필수이고, WS·LB·스티키까지 감당할 수 있을 때. 아니면 폴링·SSE·큐 쪽을 먼저 보세요.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- tRPC 가이드 — End-to-end 타입 안전 API, REST·GraphQL 없이 TypeScript만
- Next.js App Router 완벽 가이드 | Server Components·Streaming
- Zod 완벽 가이드 — TypeScript schema validation, Yup·Joi 대체
- Prisma 완벽 가이드 | ORM·Schema·Migration·Query·타입 안전성·실전 활용
이 글에서 다루는 키워드
tRPC, TypeScript, API, Type Safety, React Query, End-to-end 타입, Next.js App Router, Zod, WebSocket, Subscription
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「tRPC 완벽 가이드 — End-to-end 타입 안정성으로 API 설계하기」)를 구현·런타임·운영 쪽에서 다시 압축한 거예요. 도메인마다 다르지만, 입력 검증 → 핵심 연산 → 부작용 → 관측 순으로 장애를 쪼개면 원인이 빨리 잡혀요.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건: 버퍼, 프로토콜 상태, 격리 수준, FD 상한 — 단계마다 문장으로 적어 두면 디버깅이 수월해요.
- 결정성: 순수 층이랑 시간·I/O 층을 나누면 테스트·장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, syscall, 락, 캐시 미스 — 의심 리스트에 넣기.
- 백프레셔: 생산이 소비보다 빠를 때 어디서 속도 줄일지.
프로덕션에서 스스로에게 묻는 것 (표 말고 글로)
- 관측성: 요청마다 상관 ID, p95/p99, 타임아웃·재시도가 대시보드에 보이나? 안 보이면 «운영」이 아니라 감이에요.
- 안전성: 경로마다 입력 검증·권한·감사 로그가 같은 규칙으로 가나? 한 procedure만 뚫리면 끝이니까.
- 신뢰성: 재시도는 멱등한 쪽에만. 서킷, 백오프, DLQ — 없으면 악순환.
- 성능: N+1, 풀 크기, 인덱스, 배치, 백프레셔. 데이터 늘면 처음부터 달라져요.
- 배포: 롤백, 카나리, 마이그레이션·피처 플래그가 문서에 있나(머릿속만 있으면 밤에 깨어 있게 됩니다).
- 용량: 피크, 디스크, FD, 풀 상한 — 가끔만 보지 말고 주기적으로.
스테이징은 데이터 양·RTT·동시성을 프로덕에 가깝게 맞출수록 «재현 안 됨»이 줄어요.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「tRPC 완벽 가이드 — End-to-end 타입 안정성으로 API 설계하기」)를 배포·운영 흐름에 옮긴 체크리스트예요. 이름만 바꿔 쓰면 됩니다.
- 입력 계약: 스키마, 타임아웃, 최대 페이로드, 에러 코드.
- 핵심 경로 계측: 요청 ID, 단계 지연, 외부 코드 — 로그·메트릭·트레이스 한 줄기.
- 실패 주입: 스테이징에서 타임아웃·5xx·부분 데이터 재현.
- 롤백 루트: 설정·클라 버전·마이그레이션 되돌릴 수 있는지.
- 부하 후 점검: p99, 에러율, 알림.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting) — 표 대신 케이스
- 간헐적 실패 — 원인 흔한 순: 레이스, 타임아웃, 외부, DNS. 조치: 최소 재현, 트레이스·로그 상관, 재시도·서킷 설정.
- 성능 저하 — N+1, 동기 I/O, 락, 직렬화 과다, 캐시 미스. 조치: 프로파일/APM에서 핫스팟 하나씩.
- 메모리 늘어남 — 캐시 무한, 구독 누수, 큰 버퍼, 커넥션 미반납. 조치: 상한·TTL·힙/FD 스냅샷.
- 빌드/배포만 깨짐 — env, 권한, 플랫폼, lockfile. 조치: CI와 로컬 diff, 런타임/이미지 핀.
- 설정 불일치 — 프로필, 시크릿, 기본값, 리전. 조치: 스키마 검증된 단일 소스, 배포 매트릭스.
- 데이터 엇갈림 — 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락. 조치: 멱등 키, 아웃박스, 트랜잭션 경계.
권장 순서: (1) 최소 재현 (2) 최근 변경 축소 (3) 환경 차이 (4) 관측으로 가설 (5) 수정 후 부하/회귀.
배포 전에는 git add → git commit → git push 하고, 그다음 npm run deploy — 이 순서로 가요.