tRPC: 번거로움 없는 엔드투엔드 타입 안전성
tRPC가 API 계약 문제를 어떻게 해결하는지, Next.js App Router와의 연동, 인증 미들웨어, 파일 업로드, 서브스크립션 처리. 처음부터 구축하는 실전 tRPC 설정.
이런 상황 아시죠. API 응답에서 필드 이름을 변경합니다. 백엔드 타입을 업데이트합니다. 배포합니다. 그런데 Dashboard.tsx의 247번째 줄에서 fetch 호출을 업데이트하는 것을 누군가 잊었기 때문에 프론트엔드가 프로덕션에서 깨집니다. 필드는 이제 undefined이고, 컴포넌트는 빈 화면을 렌더링하며, 새벽 2시에 에러 트래킹이 불이 켜집니다.
이것이 API 계약 문제입니다. 기술 문제가 아닙니다. 협업 문제입니다. 그리고 아무리 많은 Swagger 문서나 GraphQL 스키마도 프론트엔드와 백엔드 타입이 조용히 어긋날 수 있다는 사실을 해결하지 못합니다.
tRPC는 어긋나는 것을 거부함으로써 이를 해결합니다. 스키마 파일이 없습니다. 코드 생성 단계가 없습니다. 유지해야 할 별도의 계약이 없습니다. 서버에서 TypeScript 함수를 작성하면 클라이언트가 컴파일 타임에 정확한 입력과 출력 타입을 알게 됩니다. 필드 이름을 바꾸면 프론트엔드는 수정할 때까지 컴파일되지 않습니다.
이것이 약속입니다. 실제로 어떻게 작동하는지, 어디서 빛나는지, 그리고 절대 사용해서는 안 되는 곳은 어디인지 보여드리겠습니다.
더 정확한 문제 정의#
오늘날 대부분의 팀이 API를 구축하는 방식을 살펴보겠습니다.
REST + OpenAPI: 엔드포인트를 작성합니다. Swagger 어노테이션을 추가할 수도 있습니다. OpenAPI 스펙에서 클라이언트 SDK를 생성할 수도 있습니다. 하지만 스펙은 별도의 산출물입니다. 오래될 수 있습니다. 생성 단계는 CI 파이프라인에서 깨지거나 잊힐 수 있는 또 다른 것입니다. 그리고 생성된 타입은 종종 보기 흉합니다 — 깊게 중첩된 paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"] 괴물들이죠.
GraphQL: 스키마를 정의합니다. 리졸버를 작성합니다. codegen을 실행하여 TypeScript 타입을 생성합니다. 스키마가 진실의 원천이므로 REST보다 나은 이야기입니다. 하지만 여전히 코드 생성이 있고, 여전히 스키마 언어(SDL)가 있으며, 리졸버 타입이 스키마와 동기화되지 않으면 여전히 런타임 놀라움이 있을 수 있습니다. 그리고 모든 프로젝트에 대한 의식이 있습니다: 스키마, 리졸버, codegen, 생성된 후크, 캐시 정규화.
tRPC: 서버에서 TypeScript 함수를 정의합니다. 완료입니다. 클라이언트가 함수를 import하고 함수를 호출합니다. 타입이 추론됩니다. 스키마도 없고, codegen도 없고, 드리프트도 없습니다.
트레이드오프는 미리 알아두는 것이 좋습니다: tRPC는 클라이언트와 서버 모두 TypeScript인 경우에만 작동합니다. 그것이 주요 제약입니다. 클라이언트가 Swift, Kotlin 또는 TypeScript가 아닌 다른 언어라면 tRPC는 아무것도 제공하지 않습니다. 하지만 클라이언트와 서버 모두 TypeScript를 사용하는 풀스택 TypeScript 앱을 구축하고 있다면(Next.js가 대표적), 대안보다 마찰이 극적으로 적습니다.
처음부터 tRPC 설정하기#
Next.js App Router로 실제 설정을 진행하겠습니다. 장난감 예제가 아닙니다 — 인증, 미들웨어, 에러 처리를 포함한 프로덕션 패턴입니다.
1단계: 패키지 설치#
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query superjson zod왜 이렇게 많은 패키지인가:
@trpc/server— 서버 측 라우터와 프로시저 정의@trpc/client— 클라이언트 핵심 (바닐라, 프레임워크 무관)@trpc/react-query— React hooks (TanStack Query 위에 구축)@trpc/next— Next.js 특화 어댑터@tanstack/react-query— 클라이언트 캐시와 상태 관리 (tRPC가 래핑)superjson— Date, Map, Set 등을 직렬화 (JSON이 처리하지 못하는 것들)zod— 런타임 스키마 검증 (tRPC의 입력 검증 메커니즘)
2단계: 서버 측 초기화#
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { ZodError } from "zod";
import superjson from "superjson";
// 컨텍스트 — 모든 프로시저가 접근하는 것
export async function createTRPCContext(opts: {
req: Request;
resHeaders: Headers;
}) {
// 세션/인증 정보를 여기서 가져옴
const session = await getSession(opts.req);
return {
user: session?.user ?? null,
session,
db: prisma, // 데이터베이스 클라이언트
headers: Object.fromEntries(opts.req.headers),
};
}
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
// 라우터와 프로시저 헬퍼 내보내기
export const router = t.router;
export const publicProcedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;여기서 핵심적인 것은:
- 컨텍스트가 모든 프로시저에서 사용 가능한 것을 정의합니다. 데이터베이스 클라이언트, 현재 사용자, 헤더 등 무엇이든 프로시저에서 접근해야 하는 것은 컨텍스트에 넣으세요.
- **
superjson**이 직렬화를 처리합니다. 이것이 없으면 Date 객체는 문자열이 되고, Map과 Set은 아예 사라집니다. - 에러 포매터가 Zod 검증 에러를 체계적인 응답으로 바꿉니다. 이것은 클라이언트에서 필드별 에러 표시에 중요합니다.
3단계: 인증 미들웨어#
// src/server/trpc.ts (계속)
// 인증된 사용자가 필요한 프로시저
export const protectedProcedure = t.procedure.use(
t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to perform this action",
});
}
return next({
ctx: {
// 여기서 ctx.user가 null이 아님을 보장
user: ctx.user,
session: ctx.session!,
},
});
})
);이것이 tRPC의 미들웨어 패턴입니다. .use()로 미들웨어를 연결합니다. 미들웨어는 컨텍스트를 변환할 수 있습니다 — 이 경우 ctx.user가 null | User에서 User(확정적으로 null이 아닌)로 좁혀집니다. 이 미들웨어 이후의 모든 프로시저는 ctx.user가 존재한다는 것을 알고 TypeScript가 이를 강제합니다.
이것이 타입 안전 미들웨어입니다 — 런타임 가드이자 타입 좁히기를 동시에 수행합니다.
4단계: 라우터 정의#
// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
export const userRouter = router({
// 공개 — 인증 불필요
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id },
select: {
id: true,
name: true,
image: true,
createdAt: true,
},
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
// 보호됨 — ctx.user가 보장됨
me: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.user.findUnique({
where: { id: ctx.user.id },
});
}),
// 보호됨 뮤테이션
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional(),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.db.user.update({
where: { id: ctx.user.id },
data: input,
});
}),
});// src/server/routers/_app.ts
import { router } from "../trpc";
import { userRouter } from "./user";
import { postRouter } from "./post";
export const appRouter = router({
user: userRouter,
post: postRouter,
});
// 이 타입을 클라이언트에서 import
export type AppRouter = typeof appRouter;AppRouter 타입은 마법이 일어나는 곳입니다. 이 단일 타입 export가 클라이언트와 서버를 연결합니다. 런타임 코드가 아닙니다 — 빌드 시 순수하게 타입 레벨이며 번들에 바이트를 추가하지 않습니다.
5단계: Next.js API 라우트 핸들러#
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createTRPCContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: ({ req, resHeaders }) =>
createTRPCContext({ req, resHeaders }),
});
export { handler as GET, handler as POST };이것이 전체 API 라우트입니다. 모든 tRPC 프로시저가 이 단일 라우트를 통해 처리됩니다 — 엔드포인트별 라우트 파일이 필요 없습니다.
6단계: 클라이언트 설정#
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();// src/app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
import superjson from "superjson";
function getBaseUrl() {
if (typeof window !== "undefined") return "";
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return "http://localhost:3000";
}
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
headers() {
return {
// 필요하면 커스텀 헤더
};
},
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}// src/app/layout.tsx
import { TRPCProvider } from "./providers";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}이 설정은 한 번이면 됩니다. 설정이 완료되면 새 API 엔드포인트를 추가하는 것은 라우터에 함수를 추가하는 것만큼 간단합니다. 라우트 파일이 필요 없고, fetch 설정이 필요 없고, 타입 정의가 필요 없습니다.
클라이언트에서 사용하기#
여기서 타입 안전성이 가시화됩니다.
"use client";
import { trpc } from "@/lib/trpc";
export function UserProfile({ userId }: { userId: string }) {
// 이 쿼리는 완전히 타입이 지정됨:
// data는 { id: string; name: string; image: string | null; createdAt: Date }
const { data: user, isLoading, error } = trpc.user.byId.useQuery({
id: userId,
});
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error.message}</div>;
if (!user) return <div>사용자를 찾을 수 없습니다</div>;
return (
<div>
<h1>{user.name}</h1>
{/* user.createdAt는 Date입니다 — string이 아닙니다! superjson 덕분에 */}
<p>가입일: {user.createdAt.toLocaleDateString()}</p>
{user.image && <img src={user.image} alt={user.name} />}
</div>
);
}user.createdAt.toLocaleDateString()에 주목하세요. 일반 fetch를 사용했다면 createdAt은 문자열일 것입니다(JSON은 Date 타입이 없기 때문). new Date(user.createdAt)으로 파싱해야 하고, 해당 파싱이 성공하기를 바라야 합니다. superjson을 사용하면 Date는 Date로 유지됩니다. Map은 Map으로, Set은 Set으로 유지됩니다. 직렬화가 투명해집니다.
타입 추론의 실제#
다음은 제가 가장 좋아하는 데모입니다. 서버에서 필드 이름을 바꿔보세요:
// 서버에서 — "name"을 "displayName"으로 변경
me: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: ctx.user.id },
select: {
id: true,
displayName: true, // 이전에는 "name"이었음
email: true,
},
});
return user;
}),순간적으로, trpc.user.me.useQuery()를 사용하는 모든 클라이언트 컴포넌트가 user.name에 접근하면 빨간 밑줄이 나타납니다. TypeScript가 Property 'name' does not exist on type '{ id: string; displayName: string; email: string; }'라고 말합니다. 찾기-바꾸기하면 끝입니다. 이 문제가 프로덕션에 도달할 방법이 없습니다.
이것은 GraphQL codegen이나 OpenAPI 클라이언트 생성과 다릅니다. 실행할 생성 단계가 없습니다. 타입이 즉시 업데이트됩니다. 왜냐하면 TypeScript의 추론 시스템을 통한 직접적인 import이기 때문입니다.
미들웨어 심층 분석#
인증을 넘어서, 미들웨어가 빛나는 곳을 보겠습니다.
로깅 미들웨어#
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
if (result.ok) {
console.log(`[tRPC] ${type} ${path} - OK (${duration}ms)`);
} else {
console.error(`[tRPC] ${type} ${path} - ERROR (${duration}ms)`);
}
return result;
});역할 기반 접근 제어#
function requireRole(role: "ADMIN" | "MODERATOR" | "USER") {
return t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const roleHierarchy = { USER: 0, MODERATOR: 1, ADMIN: 2 };
if (roleHierarchy[ctx.user.role] < roleHierarchy[role]) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Requires ${role} role`,
});
}
return next({ ctx: { user: ctx.user } });
});
}
// 사용
export const adminProcedure = protectedProcedure.use(requireRole("ADMIN"));
export const moderatorProcedure = protectedProcedure.use(requireRole("MODERATOR"));레이트 리미팅 미들웨어#
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10s"),
});
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
const ip = ctx.headers["x-forwarded-for"] ?? "unknown";
const { success, remaining } = await ratelimit.limit(ip);
if (!success) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Rate limit exceeded. Try again later.`,
});
}
return next();
});Zod를 사용한 입력 검증#
tRPC는 입력 검증에 Zod를 사용합니다. 이것은 선택적 장식이 아닙니다 — 클라이언트와 서버 모두에서 입력이 안전한지 보장하는 메커니즘입니다.
기본 검증#
const postRouter = router({
create: protectedProcedure
.input(
z.object({
title: z.string().min(1, "제목은 필수입니다").max(200, "제목이 너무 깁니다"),
content: z.string().min(10, "내용은 최소 10자 이상이어야 합니다"),
categoryId: z.string().uuid("유효하지 않은 카테고리 ID"),
tags: z.array(z.string()).max(5, "최대 5개 태그").default([]),
published: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
// input은 완전히 타입이 지정됨:
// {
// title: string;
// content: string;
// categoryId: string;
// tags: string[];
// published: boolean;
// }
return ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
});이중 검증 트릭#
여기 미묘한 점이 있습니다: Zod 검증은 양쪽 모두에서 실행됩니다. 클라이언트에서 tRPC는 요청을 보내기 전에 입력을 검증합니다. 입력이 유효하지 않으면 요청이 브라우저를 떠나지 않습니다. 서버에서는 보안 조치로 같은 스키마가 다시 검증합니다.
이것은 무료로 즉각적인 클라이언트 측 검증을 얻는다는 의미입니다:
"use client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
export function CreatePostForm() {
const [title, setTitle] = useState("");
const utils = trpc.useUtils();
const createPost = trpc.post.create.useMutation({
onSuccess: () => {
utils.post.list.invalidate();
},
onError: (error) => {
// Zod 에러가 구조화되어 도착
if (error.data?.zodError) {
const fieldErrors = error.data.zodError.fieldErrors;
// fieldErrors.title?: string[]
// fieldErrors.content?: string[]
console.log(fieldErrors);
}
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
createPost.mutate({
title,
content: "...",
categoryId: "...",
});
}}
>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
{createPost.error?.data?.zodError?.fieldErrors.title && (
<span className="text-red-500">
{createPost.error.data.zodError.fieldErrors.title[0]}
</span>
)}
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? "생성 중..." : "게시글 작성"}
</button>
</form>
);
}복잡한 입력 패턴#
// 구별된 유니온
const searchInput = z.discriminatedUnion("type", [
z.object({
type: z.literal("user"),
query: z.string(),
includeInactive: z.boolean().default(false),
}),
z.object({
type: z.literal("post"),
query: z.string(),
category: z.string().optional(),
}),
]);
// 프로시저 간 재사용되는 페이지네이션 입력
const paginationInput = z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).max(100).default(20),
});
const postRouter = router({
infiniteList: publicProcedure
.input(
z.object({
...paginationInput.shape,
category: z.string().optional(),
sortBy: z.enum(["newest", "popular", "trending"]).default("newest"),
})
)
.query(async ({ ctx, input }) => {
const { cursor, limit, category, sortBy } = input;
const posts = await ctx.db.post.findMany({
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
where: category ? { categoryId: category } : undefined,
orderBy:
sortBy === "newest"
? { createdAt: "desc" }
: sortBy === "popular"
? { likes: "desc" }
: { score: "desc" },
});
let nextCursor: string | undefined;
if (posts.length > limit) {
const nextItem = posts.pop();
nextCursor = nextItem?.id;
}
return {
posts,
nextCursor,
};
}),
});선택적 입력#
모든 프로시저가 입력을 필요로 하는 것은 아닙니다. 쿼리는 종종 필요하지 않습니다:
const statsRouter = router({
// 입력 불필요
overview: publicProcedure.query(async ({ ctx }) => {
const [userCount, postCount, commentCount] = await Promise.all([
ctx.db.user.count(),
ctx.db.post.count(),
ctx.db.comment.count(),
]);
return { userCount, postCount, commentCount };
}),
// 선택적 필터
detailed: publicProcedure
.input(
z
.object({
from: z.date().optional(),
to: z.date().optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const where = {
...(input?.from && { createdAt: { gte: input.from } }),
...(input?.to && { createdAt: { lte: input.to } }),
};
return ctx.db.post.groupBy({
by: ["categoryId"],
where,
_count: true,
});
}),
});에러 처리#
tRPC의 에러 처리는 구조화되어 있고, 타입 안전하며, HTTP 시맨틱과 클라이언트 측 UI 모두와 깔끔하게 통합됩니다.
서버에서 에러 던지기#
import { TRPCError } from "@trpc/server";
const postRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const post = await ctx.db.post.findUnique({
where: { id: input.id },
});
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "게시글을 찾을 수 없습니다",
});
}
if (post.authorId !== ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "자신의 게시글만 삭제할 수 있습니다",
});
}
await ctx.db.post.delete({ where: { id: input.id } });
return { success: true };
}),
});tRPC 에러 코드는 HTTP 상태 코드에 매핑됩니다:
| tRPC 코드 | HTTP 상태 | 사용 시기 |
|---|---|---|
BAD_REQUEST | 400 | Zod 검증을 넘어선 잘못된 입력 |
UNAUTHORIZED | 401 | 로그인하지 않음 |
FORBIDDEN | 403 | 로그인했지만 권한 부족 |
NOT_FOUND | 404 | 리소스가 존재하지 않음 |
CONFLICT | 409 | 중복 리소스 |
TOO_MANY_REQUESTS | 429 | 레이트 리밋 초과 |
INTERNAL_SERVER_ERROR | 500 | 예상치 못한 서버 에러 |
커스텀 에러 포맷팅#
설정에서의 에러 포매터가 실제로 어떻게 작동하는지 보겠습니다:
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
// 커스텀 필드 추가
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
},
};
},
});클라이언트 측 에러 처리#
"use client";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
export function DeletePostButton({ postId }: { postId: string }) {
const utils = trpc.useUtils();
const deletePost = trpc.post.delete.useMutation({
onSuccess: () => {
toast.success("게시글이 삭제되었습니다");
utils.post.list.invalidate();
},
onError: (error) => {
switch (error.data?.code) {
case "FORBIDDEN":
toast.error("이 게시글을 삭제할 권한이 없습니다");
break;
case "NOT_FOUND":
toast.error("이 게시글은 더 이상 존재하지 않습니다");
utils.post.list.invalidate();
break;
default:
toast.error("문제가 발생했습니다. 다시 시도해 주세요.");
}
},
});
return (
<button
onClick={() => deletePost.mutate({ id: postId })}
disabled={deletePost.isPending}
>
{deletePost.isPending ? "삭제 중..." : "삭제"}
</button>
);
}글로벌 에러 처리#
모든 처리되지 않은 tRPC 에러를 잡는 글로벌 에러 핸들러를 설정할 수 있습니다:
// TRPCProvider에서
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// 처리되지 않은 뮤테이션 에러의 글로벌 폴백
if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") {
// 로그인 페이지로 리디렉션
window.location.href = "/login";
return;
}
toast.error(error.message);
}
},
},
},
})
);뮤테이션과 낙관적 업데이트#
뮤테이션은 tRPC가 TanStack Query와 정말 잘 통합되는 부분입니다. 실전 패턴을 살펴보겠습니다: 낙관적 업데이트가 있는 좋아요 버튼.
기본 뮤테이션#
// 서버
const postRouter = router({
toggleLike: protectedProcedure
.input(z.object({ postId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.like.findUnique({
where: {
userId_postId: {
userId: ctx.user.id,
postId: input.postId,
},
},
});
if (existing) {
await ctx.db.like.delete({
where: { id: existing.id },
});
return { liked: false };
}
await ctx.db.like.create({
data: {
userId: ctx.user.id,
postId: input.postId,
},
});
return { liked: true };
}),
});낙관적 업데이트#
사용자가 "좋아요"를 클릭합니다. 서버 응답을 200ms 기다려서 UI를 업데이트하고 싶지 않습니다. 낙관적 업데이트가 이를 해결합니다: 즉시 UI를 업데이트한 후 서버가 거부하면 롤백합니다.
"use client";
import { trpc } from "@/lib/trpc";
export function LikeButton({ postId, initialLiked, initialCount }: {
postId: string;
initialLiked: boolean;
initialCount: number;
}) {
const utils = trpc.useUtils();
const toggleLike = trpc.post.toggleLike.useMutation({
onMutate: async ({ postId }) => {
// 진행 중인 refetch를 취소하여 낙관적 업데이트를 덮어쓰지 않도록
await utils.post.byId.cancel({ id: postId });
// 이전 값을 스냅샷
const previousPost = utils.post.byId.getData({ id: postId });
// 캐시를 낙관적으로 업데이트
utils.post.byId.setData({ id: postId }, (old) => {
if (!old) return old;
return {
...old,
liked: !old.liked,
likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
};
});
// 롤백을 위해 스냅샷 반환
return { previousPost };
},
onError: (_error, { postId }, context) => {
// 에러 시 이전 값으로 롤백
if (context?.previousPost) {
utils.post.byId.setData({ id: postId }, context.previousPost);
}
},
onSettled: (_data, _error, { postId }) => {
// 에러 또는 성공 후 항상 refetch하여 서버 상태 보장
utils.post.byId.invalidate({ id: postId });
},
});
return (
<button
onClick={() => toggleLike.mutate({ postId })}
className={initialLiked ? "text-red-500" : "text-gray-400"}
>
♥ {initialCount}
</button>
);
}패턴은 항상 같습니다:
onMutate: 쿼리 취소, 현재 데이터 스냅샷, 낙관적 업데이트 적용, 스냅샷 반환.onError: 스냅샷을 사용하여 롤백.onSettled: 성공이든 에러든 상관없이 쿼리를 무효화하여 서버에서 다시 fetch.
이 세 단계 춤은 UI가 항상 반응적이고 결국 서버와 일관되도록 보장합니다.
관련 쿼리 무효화#
뮤테이션 후 관련 데이터를 새로고침해야 할 때가 많습니다. tRPC의 useUtils()가 이를 인체공학적으로 만듭니다:
const createComment = trpc.comment.create.useMutation({
onSuccess: (_data, variables) => {
// 게시글의 댓글 목록 무효화
utils.comment.listByPost.invalidate({ postId: variables.postId });
// 게시글 자체 무효화 (댓글 수가 변경됨)
utils.post.byId.invalidate({ id: variables.postId });
// 모든 게시글 목록 무효화 (목록 뷰의 댓글 수)
utils.post.list.invalidate();
},
});배칭과 서브스크립션#
HTTP 배칭#
기본적으로 httpBatchLink을 사용하는 tRPC는 여러 동시 요청을 단일 HTTP 호출로 결합합니다. 컴포넌트가 렌더링되면서 세 개의 쿼리를 실행하면:
function Dashboard() {
const user = trpc.user.me.useQuery();
const posts = trpc.post.list.useQuery({ limit: 10 });
const stats = trpc.stats.overview.useQuery();
// ...
}이 세 쿼리는 자동으로 단일 HTTP 요청으로 배칭됩니다: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...
서버가 세 개를 모두 처리하고, 단일 응답으로 세 결과를 반환하며, TanStack Query가 각 훅에 결과를 배분합니다. 설정이 필요 없습니다.
특정 호출에 대해 배칭을 비활성화할 수 있습니다:
// 프로바이더에서, splitLink로 특정 프로시저를 다르게 라우팅
import { splitLink, httpBatchLink, httpLink } from "@trpc/client";
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
splitLink({
condition: (op) => op.path === "post.infiniteList",
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
maxURLLength: 2048,
}),
}),
],
})
);WebSocket 서브스크립션#
실시간 기능을 위해 tRPC는 WebSocket을 통한 서브스크립션을 지원합니다. 이를 위해서는 별도의 WebSocket 서버가 필요합니다(Next.js는 Route Handler에서 WebSocket을 네이티브로 지원하지 않습니다).
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
// 인메모리 이벤트 에미터 (프로덕션에서는 Redis pub/sub 사용)
import { EventEmitter } from "events";
const eventEmitter = new EventEmitter();
export const notificationRouter = router({
onNew: protectedProcedure.subscription(({ ctx }) => {
return observable<Notification>((emit) => {
const handler = (notification: Notification) => {
if (notification.userId === ctx.user.id) {
emit.next(notification);
}
};
eventEmitter.on("notification", handler);
return () => {
eventEmitter.off("notification", handler);
};
});
}),
// 알림을 트리거하는 뮤테이션
markAsRead: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const notification = await ctx.db.notification.update({
where: { id: input.id, userId: ctx.user.id },
data: { readAt: new Date() },
});
return notification;
}),
});클라이언트에서:
"use client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]);
trpc.notification.onNew.useSubscription(undefined, {
onData: (notification) => {
setNotifications((prev) => [notification, ...prev]);
toast.info(notification.message);
},
onError: (error) => {
console.error("서브스크립션 에러:", error);
},
});
return (
<div>
<span className="badge">{notifications.length}</span>
{/* 알림 목록 UI */}
</div>
);
}WebSocket 전송을 위해서는 별도의 서버 프로세스가 필요합니다. ws 라이브러리를 사용한 최소 설정입니다:
// ws-server.ts (별도 프로세스)
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { appRouter } from "./server/routers/_app";
import { createTRPCContext } from "./server/trpc";
const wss = new WebSocketServer({ port: 3001 });
const handler = applyWSSHandler({
wss,
router: appRouter,
createContext: createTRPCContext,
});
wss.on("connection", (ws) => {
console.log(`연결 열림 (총 ${wss.clients.size}개)`);
ws.once("close", () => {
console.log(`연결 종료 (총 ${wss.clients.size}개)`);
});
});
process.on("SIGTERM", () => {
handler.broadcastReconnectNotification();
wss.close();
});
console.log("WebSocket 서버가 ws://localhost:3001에서 수신 대기 중");그리고 클라이언트는 서브스크립션을 위한 wsLink가 필요합니다:
import { wsLink, createWSClient } from "@trpc/client";
const wsClient = createWSClient({
url: "ws://localhost:3001",
});
// splitLink를 사용하여 서브스크립션을 WebSocket으로 라우팅
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
splitLink({
condition: (op) => op.type === "subscription",
true: wsLink({ client: wsClient, transformer: superjson }),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
}),
],
})
);타입 안전한 파일 업로드#
tRPC는 파일 업로드를 네이티브로 처리하지 않습니다. JSON-RPC 프로토콜이라 바이너리 데이터는 전문 분야가 아닙니다. 하지만 tRPC와 사전 서명된 URL을 결합하여 타입 안전한 업로드 플로우를 구축할 수 있습니다.
패턴:
- 클라이언트가 tRPC에 사전 서명된 업로드 URL을 요청합니다.
- tRPC가 요청을 검증하고, 권한을 확인하고, URL을 생성합니다.
- 클라이언트가 사전 서명된 URL을 사용하여 S3에 직접 업로드합니다.
- 클라이언트가 tRPC에 업로드 완료를 알립니다.
// src/server/routers/upload.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export const uploadRouter = router({
getPresignedUrl: protectedProcedure
.input(
z.object({
filename: z.string().min(1).max(255),
contentType: z.string().regex(/^(image|application)\//),
size: z.number().max(10 * 1024 * 1024, "파일은 10MB 미만이어야 합니다"),
})
)
.mutation(async ({ ctx, input }) => {
const key = `uploads/${ctx.user.id}/${crypto.randomUUID()}-${input.filename}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: input.contentType,
ContentLength: input.size,
});
const presignedUrl = await getSignedUrl(s3, command, {
expiresIn: 300, // 5분
});
// 대기 중인 업로드를 데이터베이스에 저장
const upload = await ctx.db.upload.create({
data: {
key,
userId: ctx.user.id,
filename: input.filename,
contentType: input.contentType,
size: input.size,
status: "PENDING",
},
});
return {
uploadId: upload.id,
presignedUrl,
key,
};
}),
confirmUpload: protectedProcedure
.input(z.object({ uploadId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const upload = await ctx.db.upload.findUnique({
where: { id: input.uploadId, userId: ctx.user.id },
});
if (!upload) {
throw new TRPCError({ code: "NOT_FOUND" });
}
// S3에 파일이 실제로 존재하는지 확인
// (선택사항이지만 권장)
const confirmed = await ctx.db.upload.update({
where: { id: upload.id },
data: { status: "CONFIRMED" },
});
return {
url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${confirmed.key}`,
};
}),
});클라이언트에서:
"use client";
import { trpc } from "@/lib/trpc";
import { useState, useCallback } from "react";
export function FileUpload() {
const [uploading, setUploading] = useState(false);
const getPresignedUrl = trpc.upload.getPresignedUrl.useMutation();
const confirmUpload = trpc.upload.confirmUpload.useMutation();
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
// 1단계: tRPC에서 사전 서명된 URL 가져오기
const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type,
size: file.size,
});
// 2단계: S3에 직접 업로드
const uploadResponse = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("업로드 실패");
}
// 3단계: tRPC를 통해 업로드 확인
const { url } = await confirmUpload.mutateAsync({ uploadId });
toast.success(`파일 업로드됨: ${url}`);
} catch (error) {
toast.error("업로드에 실패했습니다. 다시 시도해 주세요.");
} finally {
setUploading(false);
}
},
[getPresignedUrl, confirmUpload]
);
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
accept="image/*"
/>
{uploading && <p>업로드 중...</p>}
</div>
);
}전체 플로우가 타입 안전합니다. 사전 서명된 URL 응답 타입, 업로드 ID 타입, 확인 응답 — 모두 서버 정의에서 추론됩니다. 사전 서명된 URL 응답에 새 필드를 추가하면 클라이언트가 즉시 알게 됩니다.
서버 측 호출과 React 서버 컴포넌트#
Next.js App Router에서는 종종 서버 컴포넌트에서 데이터를 가져오고 싶습니다. tRPC는 서버 측 호출자를 통해 이를 지원합니다:
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
// 서버 컴포넌트에서의 사용
// src/app/posts/[id]/page.tsx
import { createTRPCContext } from "@/server/trpc";
import { createCaller } from "@/server/trpc";
export default async function PostPage({
params,
}: {
params: { id: string };
}) {
const ctx = await createTRPCContext({
req: new Request("http://localhost"),
resHeaders: new Headers(),
});
const caller = createCaller(ctx);
const post = await caller.post.byId({ id: params.id });
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
{/* 인터랙티브 부분을 위한 클라이언트 컴포넌트 */}
<LikeButton postId={post.id} initialLiked={post.liked} />
<CommentSection postId={post.id} />
</article>
);
}이것은 양쪽의 장점을 모두 제공합니다: 완전한 타입 안전성을 갖춘 서버 렌더링 초기 데이터와, 뮤테이션 및 실시간 기능을 위한 클라이언트 측 인터랙티비티.
tRPC 프로시저 테스트#
프로시저가 단순한 함수이기 때문에 테스트가 간단합니다. HTTP 서버를 띄울 필요가 없습니다.
// src/server/routers/user.test.ts
import { describe, it, expect, vi } from "vitest";
import { appRouter } from "./routers/_app";
import { createCaller } from "./trpc";
describe("user router", () => {
it("현재 사용자 프로필을 반환한다", async () => {
const caller = createCaller({
user: { id: "user-1", email: "test@example.com", role: "USER" },
db: prismaMock,
session: mockSession,
headers: {},
});
prismaMock.user.findUnique.mockResolvedValue({
id: "user-1",
name: "테스트 사용자",
email: "test@example.com",
image: null,
createdAt: new Date("2026-01-01"),
});
const result = await caller.user.me();
expect(result).toEqual({
id: "user-1",
name: "테스트 사용자",
email: "test@example.com",
image: null,
createdAt: new Date("2026-01-01"),
});
});
it("인증되지 않은 요청에 UNAUTHORIZED를 던진다", async () => {
const caller = createCaller({
user: null,
db: prismaMock,
session: null,
headers: {},
});
await expect(caller.user.me()).rejects.toThrow("UNAUTHORIZED");
});
it("Zod로 입력을 검증한다", async () => {
const caller = createCaller({
user: { id: "user-1", email: "test@example.com", role: "USER" },
db: prismaMock,
session: mockSession,
headers: {},
});
await expect(
caller.user.updateProfile({
name: "", // 최소 길이 1
})
).rejects.toThrow();
});
});HTTP 레이어 모킹도 없고, supertest도 없고, 라우트 매칭도 없습니다. 함수를 호출하고 결과를 단언합니다. 이것이 tRPC의 과소평가된 장점 중 하나입니다: 전송 레이어가 구현 세부사항이므로 테스트가 사소할 정도로 간단합니다.
tRPC를 사용하지 말아야 할 때#
tRPC는 범용 솔루션이 아닙니다. 실패하는 경우가 있습니다:
공개 API#
외부 개발자가 소비할 API를 구축하고 있다면 tRPC는 잘못된 선택입니다. 외부 소비자는 여러분의 TypeScript 타입에 접근할 수 없습니다. 문서화된 안정적인 계약이 필요합니다 — REST의 경우 OpenAPI/Swagger, 또는 GraphQL 스키마. tRPC의 타입 안전성은 클라이언트와 서버가 같은 TypeScript 코드베이스를 공유할 때만 작동합니다.
모바일 앱 (TypeScript를 사용하지 않는 경우)#
모바일 앱이 Swift, Kotlin 또는 Dart로 작성되었다면 tRPC는 아무것도 제공하지 않습니다. 타입이 언어 경계를 넘지 않습니다. 이론적으로 trpc-openapi를 사용하여 tRPC 라우트에서 OpenAPI 스펙을 생성할 수 있지만, 그 시점에서 다시 번거로움을 추가하는 것입니다. 처음부터 REST를 사용하세요.
마이크로서비스#
tRPC는 단일 TypeScript 코드베이스를 가정합니다. 백엔드가 다른 언어의 여러 서비스로 분할되어 있다면 tRPC는 서비스 간 통신에 도움이 되지 않습니다. 이를 위해 gRPC, REST 또는 메시지 큐를 사용하세요.
프론트엔드/백엔드가 별도 저장소인 대규모 팀#
프론트엔드와 백엔드가 별도의 저장소와 별도의 배포 파이프라인에 있다면 tRPC의 핵심 장점을 잃게 됩니다. 타입 공유에는 모노레포 또는 공유 패키지가 필요합니다. AppRouter 타입을 npm 패키지로 게시할 수는 있지만, 이제 REST + OpenAPI가 더 자연스럽게 처리하는 버전 관리 문제가 생깁니다.
REST 시맨틱이 필요할 때#
HTTP 캐싱 헤더, 콘텐츠 협상, ETag 또는 기타 REST 특화 기능이 필요하다면 tRPC의 HTTP에 대한 추상화가 여러분과 싸울 것입니다. tRPC는 HTTP를 기능이 아닌 전송 세부사항으로 취급합니다.
결정 프레임워크#
제가 결정하는 방법입니다:
| 시나리오 | 권장 |
|---|---|
| 같은 저장소 풀스택 TypeScript 앱 | tRPC — 최대 이점, 최소 오버헤드 |
| 내부 도구 / 관리자 대시보드 | tRPC — 개발 속도가 우선 |
| 서드파티 개발자를 위한 공개 API | REST + OpenAPI — 소비자에게 문서가 필요, 타입이 아닌 |
| 모바일 + 웹 클라이언트 (비-TS 모바일) | REST 또는 GraphQL — 언어에 구애받지 않는 계약 필요 |
| 실시간 중심 (채팅, 게임) | 복잡도에 따라 tRPC 서브스크립션 또는 순수 WebSocket |
| 별도의 프론트엔드/백엔드 팀 | GraphQL — 스키마가 팀 간의 계약 |
프로덕션에서 배운 실전 팁#
문서에 없는, tRPC를 프로덕션에서 운영하면서 배운 몇 가지:
라우터를 작게 유지하세요. 단일 라우터 파일이 200줄을 넘지 않아야 합니다. 도메인별로 분할하세요: userRouter, postRouter, billingRouter. 각각 자체 파일에.
서버 측 호출에 createCallerFactory를 사용하세요. 서버 컴포넌트에서 자체 API를 호출할 때 fetch에 손대지 마세요. 호출자 팩토리가 HTTP 오버헤드 없이 같은 타입 안전성을 제공합니다.
배칭을 과도하게 최적화하지 마세요. 기본 httpBatchLink이 거의 항상 충분합니다. 한계적인 이득을 위해 splitLink 설정에 며칠을 보내는 팀을 봤습니다. 먼저 프로파일링하세요.
QueryClient에서 staleTime을 설정하세요. 기본 staleTime이 0이면 모든 포커스 이벤트가 refetch를 트리거합니다. 데이터 신선도 요구사항에 따라 합리적인 값(30초~5분)으로 설정하세요.
처음부터 superjson을 사용하세요. 나중에 추가하면 모든 클라이언트와 서버를 동시에 마이그레이션해야 합니다. Date 직렬화 버그에서 구해주는 한 줄 설정입니다.
에러 바운더리는 여러분의 친구입니다. tRPC 사용이 많은 페이지 섹션을 React 에러 바운더리로 감싸세요. 하나의 실패한 쿼리가 전체 페이지를 다운시켜서는 안 됩니다.
"use client";
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error, resetErrorBoundary }: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div role="alert" className="p-4 bg-red-50 rounded-lg">
<p className="font-medium text-red-800">문제가 발생했습니다</p>
<pre className="text-sm text-red-600 mt-2">{error.message}</pre>
<button
onClick={resetErrorBoundary}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded"
>
다시 시도
</button>
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>대시보드</h1>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserStats />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<RecentPosts />
</ErrorBoundary>
</div>
);
}마무리#
tRPC는 REST나 GraphQL의 대체가 아닙니다. 특정 상황을 위한 다른 도구입니다: 클라이언트와 서버를 모두 제어하고, 둘 다 TypeScript이며, "백엔드를 변경했다"에서 "프론트엔드가 그것을 안다"까지 가장 짧은 경로를 원할 때.
그 상황에서는 다른 어떤 것도 가까이 오지 못합니다. 코드 생성도 없고, 스키마 파일도 없고, 드리프트도 없습니다. TypeScript가 가장 잘하는 것을 하는 것뿐입니다: 실수가 프로덕션에 도달하기 전에 잡는 것.
트레이드오프는 명확합니다: 프로토콜 수준의 상호 운용성(비-TypeScript 클라이언트 없음)을 포기하고 다른 방법으로는 달성하기 어려운 개발 속도와 컴파일 타임 안전성을 얻습니다.
대부분의 풀스택 TypeScript 애플리케이션에서 이것은 할 만한 가치가 있는 트레이드오프입니다.