REST API를 쓰면 서버에서 응답 필드 이름을 하나 바꿔도 프런트엔드 코드는 아무 경고 없이 빌드가 됩니다. 실제로 그 엔드포인트를 호출하는 화면을 열어봐야 undefined가 찍히는 걸 보고서야 알아챕니다. GraphQL은 이 문제를 스키마로 풀지만, 스키마 파일을 따로 관리하고 코드젠을 돌려서 타입을 생성하는 빌드 단계가 하나 더 생깁니다. tRPC는 서버 라우터 코드의 타입을 그대로 클라이언트가 import해서 쓰는 방식으로, 스키마 파일과 코드 생성 단계 둘 다 없이 같은 효과를 냅니다.
핵심 개념
- procedure가 API의 최소 단위: 조회는
query, 쓰기는mutation으로 구분해서 정의합니다. REST의 엔드포인트, GraphQL의 필드와 비슷한 역할입니다. - router로 procedure를 묶음: 여러 procedure를 하나의
router로 묶고, router끼리도 중첩할 수 있습니다. 중첩된 구조가 곧 클라이언트에서 호출할 때의 경로(trpc.post.create)가 됩니다. - 입력 검증이 곧 타입:
input()에 Zod 스키마를 넘기면 그 스키마가 런타임 검증과 TypeScript 타입 둘 다를 담당합니다. 서버 코드에서 입력 타입을 따로 선언할 필요가 없습니다. - 클라이언트는 서버 타입을 "값"이 아니라 "타입"으로만 import:
import type { AppRouter } from "../server"처럼 타입만 가져오기 때문에, 서버 구현 코드가 클라이언트 번들에 섞여 들어가지 않습니다. 실제로 실행되는 건 HTTP 요청이고, 타입 추론만 컴파일 시점에 서버 코드를 참조합니다. - 네트워크 계층은
links로 조립: 기본httpBatchLink는 같은 틱에서 발생한 여러 호출을 하나의 HTTP 요청으로 묶어 보냅니다. 로깅, 재시도, 캐싱 같은 동작을 링크를 체이닝해서 추가합니다.
실전 예시
서버 쪽은 세 파일로 나눕니다. 먼저 tRPC 인스턴스를 초기화합니다.
// server/trpc.ts
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
procedure를 정의합니다. input()에 넘긴 Zod 스키마가 검증과 타입을 동시에 담당합니다.
// server/routers/post.ts
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
import { db } from "../db";
export const postRouter = router({
list: publicProcedure.query(() => {
return db.post.findMany();
}),
create: publicProcedure
.input(z.object({ title: z.string().min(1).max(200) }))
.mutation(({ input }) => {
return db.post.create({ data: { title: input.title } });
}),
});
router를 모으고, 전체 타입을 export합니다. 이 AppRouter 타입 하나가 클라이언트가 참조할 전부입니다.
// server/routers/_app.ts
import { router } from "../trpc";
import { postRouter } from "./post";
export const appRouter = router({
post: postRouter,
});
export type AppRouter = typeof appRouter;
Next.js App Router에서는 fetch 어댑터로 라우트 핸들러 하나에 전체 라우터를 연결합니다.
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
});
export { handler as GET, handler as POST };
클라이언트는 AppRouter 타입만 가져와서 씁니다.
// lib/trpc-client.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: "/api/trpc" })],
});
const posts = await trpc.post.list.query();
// posts의 타입이 서버의 db.post.findMany() 반환형까지 그대로 추론됨
await trpc.post.create.mutate({ title: "새 글" });
create.mutate({})처럼 title을 빼먹으면 이 줄에서 바로 TypeScript 에러가 뜹니다. REST였다면 빌드는 통과하고 요청을 보낸 뒤에야 서버가 400을 돌려주는 상황인데, tRPC는 그 체크를 호출하는 자리에서 끝냅니다. 서버 쪽 input 스키마를 고치면 — 예를 들어 title에 .max(200)을 추가하면 — 별도 빌드나 코드 생성 명령을 돌리지 않아도 클라이언트 호출부가 다음 타입체크에서 바로 그 제약을 반영합니다.
부딪힌 부분
처음 붙였을 때는 입력 검증 실패 메시지가 기대와 달리 나왔습니다. title: z.string().min(1)인데 빈 문자열을 보냈더니 클라이언트에 이런 에러가 찍혔습니다.
TRPCClientError: [
{
"code": "too_small",
"minimum": 1,
"type": "string",
"message": "Too small: expected string to have >=1 characters"
}
]
Zod의 ZodError를 tRPC가 TRPCError로 감싸서 올려주는데, 기본적으로는 Zod 이슈 배열이 그대로 message에 JSON 문자열로 박혀 있어서 사용자에게 보여줄 문구로 쓰기엔 지저분했습니다. errorFormatter를 initTRPC.create()에 추가해서 필요한 필드만 뽑아 내는 식으로 정리했습니다.
반대로 좋았던 지점은, 라우터 하나를 여러 파일로 쪼개도 타입 추론이 끊기지 않는다는 점이었습니다. post.ts, user.ts처럼 도메인별로 나눠도 _app.ts에서 합치는 순간 전체가 하나의 타입으로 묶여서, 클라이언트는 어느 파일에 뭐가 있는지 몰라도 trpc.user.get, trpc.post.list를 자동완성으로 바로 찾을 수 있었습니다.
다만 이 구조가 성립하려면 클라이언트와 서버가 같은 TypeScript 코드베이스(주로 모노레포)에 있어야 합니다. 서버 타입을 "import"해서 쓰는 방식이라, 안드로이드·iOS 네이티브 클라이언트처럼 아예 다른 언어로 동작하는 쪽에는 애초에 적용할 수 없습니다. 그런 클라이언트까지 지원해야 한다면 tRPC 레이어 앞단에 REST나 GraphQL처럼 언어 중립적인 스키마를 하나 더 두거나(trpc-openapi 같은 플러그인으로 OpenAPI 스펙을 뽑아내는 방법도 있습니다), 애초에 다른 선택을 하는 게 맞습니다.