이 글은 누구를 위한 것인가
- Next.js로 풀스택 앱을 만들면서 API 타입 관리가 번거로운 개발자
- 런타임 타입 오류로 고생한 경험이 있는 TypeScript 개발자
- REST API 작성 없이 백엔드 함수를 프론트엔드에서 바로 호출하고 싶은 분
들어가며
TypeScript를 쓰는 팀이라면 한 번쯤 이런 경험을 했을 것이다. 백엔드에서 API 응답 타입을 바꿨는데 프론트엔드는 여전히 이전 타입으로 데이터를 처리하고 있다. 빌드 단계에서 잡히지 않고 런타임에 조용히 오류가 발생한다.
"API 클라이언트 자동 생성하면 되지 않나?"라고 생각할 수 있다. OpenAPI 스펙을 정의하고, codegen을 돌리고, 생성된 클라이언트를 업데이트하는 과정이 있다. 작동하지만 마찰이 있다.
tRPC는 이 문제를 다르게 접근한다. API를 따로 정의하지 않는다. 백엔드 함수 자체가 타입을 가지고, 그 타입이 프론트엔드로 직접 전달된다. API 정의서도, 코드 생성도 필요 없다. TypeScript의 타입 추론이 전부를 처리한다.
1. tRPC가 무엇인지, 어떻게 동작하는지
핵심 아이디어
tRPC는 RPC(Remote Procedure Call) 스타일로 백엔드 함수를 호출하는 라이브러리다. REST의 URL 기반 설계 대신, 함수 호출 방식으로 통신한다.
// REST 방식
// POST /api/users/create
// GET /api/users/123
// DELETE /api/users/123
// tRPC 방식
const user = await trpc.users.create.mutate({ name: "김철수", email: "..." });
const user = await trpc.users.getById.query({ id: "123" });
await trpc.users.delete.mutate({ id: "123" });
겉으로 보면 단순히 명명 방식의 차이처럼 보이지만, 핵심은 타입이 자동으로 공유된다는 것이다.
// 서버: 라우터 정의
const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
return user; // User | null 타입이 자동으로 추론됨
}),
});
// 클라이언트: 타입 자동 추론
const { data } = trpc.user.getById.useQuery({ id: "123" });
// data의 타입이 User | null | undefined 로 자동 추론됨
// 백엔드 반환 타입이 바뀌면 여기서 바로 타입 에러 발생
Zod의 역할
Zod는 런타임 타입 검증 라이브러리다. TypeScript 타입은 컴파일 타임에만 존재하고 런타임에는 사라진다. Zod는 런타임에서도 실제 데이터를 검증한다.
import { z } from 'zod';
const UserCreateSchema = z.object({
name: z.string().min(1, "이름은 필수입니다").max(50),
email: z.string().email("올바른 이메일을 입력하세요"),
age: z.number().min(0).max(150).optional(),
});
// TypeScript 타입 자동 추출
type UserCreateInput = z.infer<typeof UserCreateSchema>;
// { name: string; email: string; age?: number | undefined }
tRPC와 Zod를 함께 쓰면 API 입력값의 타입 안전성(Zod)과 출력값의 타입 안전성(TypeScript 추론)이 모두 보장된다.
2. Next.js 프로젝트에 tRPC 설정하기
설치
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next
npm install @tanstack/react-query zod
서버 설정
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
// tRPC 인스턴스 생성
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// 인증이 필요한 프로시저
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, user: ctx.session.user } });
});
// server/routers/user.ts
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { z } from 'zod';
import { db } from '../db';
export const userRouter = router({
// 단일 사용자 조회
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.user.findUnique({
where: { id: input.id },
select: { id: true, name: true, email: true, createdAt: true },
});
}),
// 사용자 생성
create: publicProcedure
.input(z.object({
name: z.string().min(1).max(50),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return db.user.create({ data: input });
}),
// 내 정보 수정 (인증 필요)
updateProfile: protectedProcedure
.input(z.object({
name: z.string().min(1).max(50).optional(),
bio: z.string().max(200).optional(),
}))
.mutation(async ({ input, ctx }) => {
return db.user.update({
where: { id: ctx.user.id },
data: input,
});
}),
});
// 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 (클라이언트에서 사용)
export type AppRouter = typeof appRouter;
Next.js API 라우트 연결
// app/api/trpc/[trpc]/route.ts (Next.js App Router)
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { createTRPCContext } from '@/server/context';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
});
export { handler as GET, handler as POST };
클라이언트 설정
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';
// AppRouter 타입을 사용하여 완전한 타입 추론
export const trpc = createTRPCReact<AppRouter>();
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/utils/trpc';
import { useState } from 'react';
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({ url: '/api/trpc' }),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
3. 실전 패턴: 자주 쓰는 케이스
목록 조회와 페이지네이션
// 서버
const postRouter = router({
list: publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(), // 커서 기반 페이지네이션
category: z.string().optional(),
}))
.query(async ({ input }) => {
const posts = await db.post.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
where: input.category ? { category: input.category } : undefined,
orderBy: { createdAt: 'desc' },
});
const hasMore = posts.length > input.limit;
const items = hasMore ? posts.slice(0, -1) : posts;
const nextCursor = hasMore ? items[items.length - 1].id : undefined;
return { items, nextCursor };
}),
});
// 클라이언트 - Infinite Query
function PostList() {
const { data, fetchNextPage, hasNextPage } = trpc.post.list.useInfiniteQuery(
{ limit: 20 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const posts = data?.pages.flatMap(page => page.items) ?? [];
return (
<>
{posts.map(post => <PostCard key={post.id} post={post} />)}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>더 보기</button>
)}
</>
);
}
낙관적 업데이트 (Optimistic Update)
function LikeButton({ postId }: { postId: string }) {
const utils = trpc.useUtils();
const likeMutation = trpc.post.like.useMutation({
// 서버 응답 전에 UI 미리 업데이트
onMutate: async ({ postId }) => {
await utils.post.getById.cancel({ id: postId });
const previous = utils.post.getById.getData({ id: postId });
utils.post.getById.setData({ id: postId }, (old) => {
if (!old) return old;
return { ...old, likeCount: old.likeCount + 1, isLiked: true };
});
return { previous }; // 롤백용 이전 상태 보존
},
// 에러 시 롤백
onError: (err, { postId }, context) => {
if (context?.previous) {
utils.post.getById.setData({ id: postId }, context.previous);
}
},
// 성공/실패 관계없이 서버 상태로 동기화
onSettled: (_, __, { postId }) => {
utils.post.getById.invalidate({ id: postId });
},
});
return (
<button onClick={() => likeMutation.mutate({ postId })}>
좋아요
</button>
);
}
에러 처리
// 서버에서 의미 있는 에러 코드 반환
import { TRPCError } from '@trpc/server';
const postRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const post = await 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: '삭제 권한이 없습니다',
});
}
return db.post.delete({ where: { id: input.id } });
}),
});
// 클라이언트에서 에러 처리
function DeleteButton({ postId }: { postId: string }) {
const deleteMutation = trpc.post.delete.useMutation({
onError: (error) => {
if (error.data?.code === 'FORBIDDEN') {
toast.error('삭제 권한이 없습니다');
} else if (error.data?.code === 'NOT_FOUND') {
toast.error('이미 삭제된 포스트입니다');
} else {
toast.error('삭제 중 오류가 발생했습니다');
}
},
});
return (
<button
onClick={() => deleteMutation.mutate({ id: postId })}
disabled={deleteMutation.isPending}
>
삭제
</button>
);
}
4. Zod 스키마를 폼 유효성 검사와 공유하기
Zod 스키마는 API 검증뿐 아니라 폼 유효성 검사에도 재사용할 수 있다.
// schemas/user.ts - 서버와 클라이언트가 공유
import { z } from 'zod';
export const UserCreateSchema = z.object({
name: z.string()
.min(1, "이름을 입력하세요")
.max(50, "이름은 50자 이내로 입력하세요"),
email: z.string()
.email("올바른 이메일 형식을 입력하세요"),
password: z.string()
.min(8, "비밀번호는 8자 이상이어야 합니다")
.regex(/[A-Z]/, "대문자를 포함해야 합니다")
.regex(/[0-9]/, "숫자를 포함해야 합니다"),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{ message: "비밀번호가 일치하지 않습니다", path: ["confirmPassword"] }
);
export type UserCreateInput = z.infer<typeof UserCreateSchema>;
// 클라이언트 폼에서 동일한 스키마 사용 (react-hook-form + zod)
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { UserCreateSchema, type UserCreateInput } from '@/schemas/user';
function SignUpForm() {
const { register, handleSubmit, formState: { errors } } = useForm<UserCreateInput>({
resolver: zodResolver(UserCreateSchema),
});
const createUser = trpc.user.create.useMutation();
const onSubmit = (data: UserCreateInput) => {
createUser.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} placeholder="이름" />
{errors.name && <span>{errors.name.message}</span>}
<input {...register("email")} type="email" placeholder="이메일" />
{errors.email && <span>{errors.email.message}</span>}
<button type="submit" disabled={createUser.isPending}>
가입하기
</button>
</form>
);
}
같은 Zod 스키마가 폼 유효성 검사(클라이언트), tRPC 입력 검증(서버)에서 동시에 사용된다. 유효성 규칙을 한 곳에서 관리하면서 프론트엔드와 백엔드가 항상 동일한 기준으로 검증한다.
5. tRPC를 쓰면 좋은 케이스와 나쁜 케이스
tRPC가 적합한 경우
- TypeScript 풀스택 프로젝트 (Next.js, Remix 등)
- 프론트엔드와 백엔드를 같은 팀이 개발
- 빠른 개발 속도가 중요한 스타트업, 사이드 프로젝트
tRPC가 적합하지 않은 경우
- 외부 클라이언트(모바일 앱, 서드파티)가 API를 소비하는 경우 → REST/GraphQL이 낫다
- 여러 언어로 구성된 마이크로서비스 → tRPC는 TypeScript 전용
- 공개 API를 제공해야 하는 경우 → OpenAPI 스펙이 있는 REST
맺으며
tRPC + Zod 조합은 TypeScript 풀스택 팀에게 "REST API를 별도로 정의하고 관리하는 비용"을 없애준다. 백엔드 함수를 수정하면 컴파일 타임에 프론트엔드 오류가 즉시 잡힌다. Zod 스키마 하나가 폼 검증과 API 검증을 동시에 처리한다.
새 프로젝트를 시작한다면 tRPC로 시작하는 것이 기본 선택지가 될 만큼 개발 경험이 좋아졌다. 기존 REST API를 모두 tRPC로 바꿀 필요는 없다. 신규 기능부터 tRPC로 구현해보고, 타입 오류를 빌드 단계에서 잡는 경험이 얼마나 편한지 직접 느껴보자.