tRPC 완전 가이드 | End-to-End 타입 안전한 API 만들기
이 글의 핵심
REST API나 GraphQL 없이 TypeScript로 완벽한 타입 안전성을 제공하는 tRPC. 프론트엔드에서 백엔드 함수를 직접 호출하듯 사용하며, 타입은 자동으로 동기화됩니다. React Query와 완벽히 통합됩니다.
이 글의 핵심
tRPC는 REST API나 GraphQL 없이 TypeScript로 완벽한 타입 안전성을 제공하는 라이브러리입니다. 프론트엔드에서 백엔드 함수를 직접 호출하듯 사용하며, 타입은 자동으로 동기화되고, React Query와 완벽히 통합됩니다.
목차
tRPC란?
tRPC (TypeScript Remote Procedure Call)는 타입 안전한 API를 만드는 라이브러리입니다.
🚀 핵심 특징
1. End-to-End 타입 안전성
// 백엔드
const userRouter = t.router({
getById: t.procedure
.input(z.object({ id: z.number() }))
.query(({ input }) => {
return db.users.findById(input.id);
}),
});
// 프론트엔드 (자동 타입 추론)
const user = await trpc.user.getById.query({ id: 1 });
// user의 타입이 자동으로 추론됨!
2. No Code Generation
GraphQL:
- Schema 정의 → Code 생성 → 사용
tRPC:
- TypeScript 함수 작성 → 바로 사용
3. React Query 통합
// useQuery와 완벽히 통합
const { data, isLoading } = trpc.user.getById.useQuery({ id: 1 });
4. 빠른 개발 속도
- API 문서 불필요
- Postman 테스트 불필요
- 타입 동기화 자동
핵심 개념: Procedure와 End-to-End 타입 안정성
tRPC에서 서버에 정의한 모든 호출 경로는 procedure로 표현됩니다. initTRPC로 만든 t에 대해 t.procedure는 공통 미들웨어를 끼우고, 그 위에 input·output(선택)과 query / mutation / subscription을 조합해 하나의 원자적인 작업을 만듭니다.
타입 안정성은 “클라이언트가 추측”하는 수준이 아니라, AppRouter 타입이 소스이 되어 createTRPCReact<AppRouter>()(또는 createTRPCClient)가 입력·출력·에러의 형태를 그대로 전달한다는 뜻입니다. 즉, 서버의 zod 스키마나 resolve의 반환 타입이 바뀌면 빌드가 깨지기 때문에 런타임에만 터지는 404·필드 누락 문제를 컴파일 단계로 끌어올릴 수 있습니다.
Query, Mutation, Subscription의 역할
- query: 읽기 전용(멱등에 가깝게 설계). GET에 대응하는 사용 패턴이 자연스럽고, React Query와 결합할 때
useQuery의 캐싱·리페치 이점이 큽니다. - mutation: 생성·수정·삭제 같이 부수 효과가 있다고 가정. 성공 시
invalidate로 관련query를 다시 읽게 하는 패턴이 일반적입니다. - subscription: 서버 푸시(주로 WebSocket) 기반. HTTP만 쓰는 환경에서는 별도
splitLink로 구독 전용 transport를 나누는 경우가 많습니다(아래 배치·성능 절 참고).
Procedure는 이 세 가지 중 하나에 결합되기 직전까지 동일한 체인(input → use 미들웨어)을 공유한다는 점이 중요합니다. 그래서 “인증은 공통, 세부 권한은 라우트별” 같은 횡단 관심사를 middleware로 모으기가 좋습니다.
Router·Context: 실전에서 자주 쓰는 구조
Router는 router({ ... })로 네임스페이스를 만듭니다. appRouter 아래에 user, post처럼 도메인 단위로 나누면, 클라이언트에서도 trpc.user.xxx처럼 API 경로가 코드 구조를 반영해 탐색이 쉬워집니다.
Context는 createContext에서 한 번 만들어 createNextApiHandler / fastify 어댑터 등에 요청마다 주입됩니다. 세션, DB 풀, requestId, feature flag, 로그용 메타데이터를 여기 둡니다. initTRPC.context<Context>()로 제네릭을 박아 두면 procedure의 ctx가 끝까지 타입이 유지됩니다.
// server/trpc.ts — 공통: router/procedure/미들웨어 조립
import { initTRPC } from '@trpc/server';
import type { Context } from './context';
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
// protectedProcedure, middleware는 아래 "Middleware" 절에서 확장
경험에 비추어 볼 때, Context에 “무엇이든” 넣기 쉬운데, 권한·감사 로그에 필요한 최소한의 객체만 노출하도록 next({ ctx: { ... } })로 좁히는(narrowing) 습관을 두면, 나중에 팀이 커져도 ctx가 비대해지는 속도를 늦출 수 있습니다.
Middleware: 인증, 로깅, 에러 처리
procedure.use()로 한 절차에만 붙이거나, t.middleware + publicProcedure를 확장해 재사용할 수 있습니다. 아래는 요청 id, 지연 시간(ms), 에러를 공통 포맷으로 감싸는 식의 패턴을 한 번에 담은 예시입니다(실서비스에서는 pino·OpenTelemetry·감사 로그 테이블로 확장).
// server/middlewares.ts
import { TRPCError, initTRPC } from '@trpc/server';
import type { Context } from './context';
const t = initTRPC.context<Context>().create();
const logMiddleware = t.middleware(async ({ next, path, type, ctx, getRawInput }) => {
const start = performance.now();
const reqId = ctx.reqId ?? 'na';
try {
const result = await next();
const ms = Math.round(performance.now() - start);
// 프로덕션: 구조적 로그(JSON)로 전송
console.info({ reqId, path, type, ms, level: 'info' });
return result;
} catch (err) {
const ms = Math.round(performance.now() - start);
console.error({ reqId, path, type, ms, err, level: 'error' });
if (err instanceof TRPCError) throw err;
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred',
cause: err,
});
}
});
export const publicProcedure = t.procedure.use(logMiddleware);
// 인증: 세션이 있을 때만 user를 ctx에 좁힘
export const authedProcedure = publicProcedure.use(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
인증은 UNAUTHORIZED, 리소스 소유권이 없으면 FORBIDDEN, 입력은 이미 zod로 걸러졌는데 비즈니스 규칙이 깨지면 BAD_REQUEST 또는 CONFLICT처럼 의미 있는 TRPCError 코드를 쓰는 편이, 클라이언트의 onError·토스트·재시도 정책을 통일하는 데 유리합니다.
Zod와의 통합: Input 검증을 넘어 Output까지
input은 zod 스키마를 넣는 것이 사실상 표준입니다. z.infer<typeof schema>로 서버 resolve의 input 타입이 고정되고, 클라이언트는 동일한 제약을 컴파일 타임에 체감합니다.
import { z } from 'zod';
const createTagSchema = z
.object({
name: z.string().min(1).max(32),
slug: z
.string()
.regex(/^[a-z0-9-]+$/)
.optional(),
})
.transform((v) => ({
...v,
slug: v.slug ?? v.name.toLowerCase().replace(/\s+/g, '-'),
}));
// .transform 이후의 타입이 input 추론에 반영됨
// protectedProcedure.input(createTagSchema).mutation(...)
서버 쪽에서 공개하려는 응답만 강제하고 싶다면, procedure에 .output(zodSchema)를 붙이는 것도 유효합니다(내부 모델에 비밀 필드가 있을 때, 응답 DTO로 좁힐 수 있음).
superRefine이나 refine으로 “DB에 이미 있나?” 같은 비동기 검증을 붙일 수는 있으나, 느리면 뮤테이션 전체가 지연되므로, 가능하면 유니크 제약 위반을 DB에서 잡고 TRPCError로 매핑하는 쪽이 운영에 안전한 경우가 많습니다.
React Query 통합: useQuery, invalidate, 그 너머
@trpc/react-query는 이미 TanStack Query 위에 얇은 래퍼를 씌운 형태라, staleTime, refetchOnWindowFocus, keepPreviousData 등 Query 옵션을 그대로 넘깁니다. 다만 키 구조가 tRPC에 의해 일관되게 관리되므로, utils.post.list.invalidate()만으로 도메인 단위 무효화가 단순해집니다.
// 일괄 선조회(대시보드) — useQueries 느낄 때는 배열 + 병렬 쿼리
const a = trpc.user.me.useQuery();
const b = trpc.post.recent.useQuery({ limit: 5 });
// 서버에서 미리 쿼리해 하이드레이션 (Next.js app router 등)
// await ssrHelper.prefetch(trpc.user.me.query());
뮤테이션 성공 후 invalidate만 쓰지 않고, setData로 낙관적 UI를 넣다가 실패 시 롤백하는 식이 가능합니다. 팀이 커질수록 “캐시는 단일 원천”이 되기 때문에, 어떤 뮤테이션이 어떤 쿼리 키를 더럽히는지 PR 리뷰에서 눈에 띄는 편이 장점입니다.
HTTP 배치와 성능: 한 번의 RTT에 묶기
httpBatchLink는 짧은 시간에 발생한 여러 procedure 호출을 하나의 HTTP 요청으로 묶습니다(기본 GET + 쿼리스트링, 혹은 설정에 따라 POST 배치). 덕분에 첫 화면에서 쿼리를 여럿 날릴 때 RTT(왕복 지연) 비용이 줄어듭니다.
다만 URL 길이 제한(프록시/게이트웨이)에 걸릴 수 있으므로, maxURLLength를 조정하거나 splitLink로 배치는 POST, 나머지는 GET처럼 나누는 구성이 나옵니다. 또한 쿼리는 멱등이라 배치해도 이해가 쉬운데, 뮤테이션은 순서 보장을 어떻게 기대하느냐에 따라 배치에 넣을지 팀 합의가 필요합니다(동시에 보내면 서버가 순차 적용을 보장하지 않는 경우가 있습니다).
import { createTRPCProxyClient, httpBatchLink, splitLink, httpLink } from '@trpc/client';
import type { AppRouter } from '../server/routers/_app';
// 클라이언트(예: 스크립트/노드) 쪽 조합 예시
const client = createTRPCProxyClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: httpLink({ url: 'https://api.example.com/trpc' }), // 실제는 wsLink
false: httpBatchLink({ url: 'https://api.example.com/trpc' }),
}),
],
});
프로덕션에서는 응답 압축(gzip/brotli), 엣지 캐싱(대부분 tRPC엔 제한적), DB N+1이 체감 지연의 대부분이므로, tRPC는 “불필요한 OpenAPI/클라 생성 비용”을 깎는 동시에, 데이터베이스 쿼리 최적화는 별도로 해야 합니다.
WebSocket과 Subscription: 언제 쓰는가
채팅·알림·대시보드 실시간 갱신이 필요하면, HTTP 폴링 대신 subscription이 자연깁니다. tRPC v10 이후 생태는 프레임워크별 어댑터·@trpc/client link 조합이 달라서, 배포 환경(서버리스, 롱런 WebSocket, 멀티 인스턴스)을 먼저 정하는 것이 중요합니다. 멀티 인스턴스에서는 단일 서버 EventEmitter로는 크로스 노드 이벤트가 안 터지므로, Redis pub/sub, NATS, Upstash 같은 버스를 붙이는 식이 일반적입니다(아래 서버 예시는 단일 프로세스용으로만 유효).
tRPC vs REST(그리고 GraphQL) — 표 너머의 이야기
| 기능 | tRPC | REST API | GraphQL |
|---|---|---|---|
| 타입 안전성 | ✅ End-to-End | ❌ 수동 | ⚠️ Code Gen |
| 오버페칭 | ❌ 없음 | ⚠️ 있음 | ❌ 없음 |
| 학습 곡선 | 🟢 쉬움 | 🟢 쉬움 | 🔴 어려움 |
| 공개 API | ❌ 부적합 | ✅ 적합 | ✅ 적합 |
| 풀스택 TS | ✅ 최고 | ❌ 수동 | ⚠️ 복잡 |
| 캐싱 | ✅ React Query | ⚠️ 수동 | ✅ Apollo |
REST는 캐시(HTTP) 친화적·모바일/서드파티·버전닝·게이트웨이·모니터링 도구가 이미 쌓인 강점이 있습니다. GraphQL은 클라이언트 주도 스키마가 강력하지만, 운영·N+1·스키마 거버넌스 비용이 있습니다. tRPC는 “같은 저장소·같은 팀” 전제의 TypeScript monorepo / 풀스택에서, “함수가 곧 API”라는 점이 가장 큽니다. 반대로 대외 API·멀티 랭귀지·iOS/안드로이드 네이티브가 장기적 중심이면, REST(gRPC·OpenAPI)를 엣지에 두는 판단이 흔합니다. 저는 “내부 BFF = tRPC, 외부 = REST” 이중 버스를 한 코드베이스에서 겪었는데, 경계(도메인 모듈)만 단단하면 꽤 수월합니다.
실제로 운영에서 체감한 것(개인적 견해)
한 제품 팀에서 Next.js + tRPC로 1년가량 구축·운영하면서, 가장 큰 이득은 “필드 추가 시 빌드가 먼저 터진다”는 점이었습니다. API 스펙을 문서로 밀지 않아도, 프론트 호출부가 곧 사실상의 계약이 됩니다. 대신 권한 모델이 복잡해질수록, TRPCError의 코드·메시지·클라이언트 토스트 정책을 팀 합의로 박아 두지 않으면, UX가 케이스마다 갈리기 쉽습니다.
또 서버리스(짧은 실행) + WebSocket은 궁합이 맞지 않는 경우가 많아, 실시간은 별도 서비스로 빼는 결정을 자주 봤습니다. 배치 httpBatchLink는 초기 화면 성능에 도움을 주었지만, 뮤테이션 순서는 스트레스 테스트에서 사용자 흐름과 엇갈릴 수 있어, 중요한 연산은 mutation을 단일 요청으로 강제하는 룰을 둔 적도 있습니다.
결론적으로, tRPC는 “타입”만이 아니라 배포·권한·캐시·실시간까지 아키텍처 결정과 함께 봐야 한다는 점이 실무 체감이었습니다.
tRPC 시작하기
1️⃣ 설치
# Next.js 프로젝트
npx create-next-app@latest my-trpc-app --typescript
cd my-trpc-app
# tRPC 패키지
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next
npm install @tanstack/react-query zod
2️⃣ 백엔드 설정 (Next.js API Routes)
// server/trpc.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/_app.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const appRouter = router({
hello: publicProcedure
.input(z.object({ name: z.string() }))
.query(({ input }) => {
return { greeting: `Hello ${input.name}!` };
}),
getUsers: publicProcedure.query(() => {
return [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
}),
createUser: publicProcedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ input }) => {
// DB에 저장
const user = { id: Date.now(), ...input };
return user;
}),
});
export type AppRouter = typeof appRouter;
// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';
export default createNextApiHandler({
router: appRouter,
createContext: () => ({}),
});
3️⃣ 프론트엔드 설정
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
// pages/_app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from '../utils/trpc';
import type { AppProps } from 'next/app';
export default function App({ Component, pageProps }: AppProps) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</trpc.Provider>
);
}
기본 사용법
Query (데이터 조회)
// pages/index.tsx
import { trpc } from '../utils/trpc';
export default function HomePage() {
// useQuery와 동일
const { data, isLoading, error } = trpc.getUsers.useQuery();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Mutation (데이터 변경)
// components/CreateUser.tsx
import { trpc } from '../utils/trpc';
import { useState } from 'react';
export function CreateUser() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const utils = trpc.useContext();
const createUser = trpc.createUser.useMutation({
onSuccess: () => {
// 사용자 목록 다시 가져오기
utils.getUsers.invalidate();
},
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await createUser.mutateAsync({ name, email });
setName('');
setEmail('');
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
type="email"
/>
<button type="submit" disabled={createUser.isLoading}>
{createUser.isLoading ? 'Creating...' : 'Create User'}
</button>
</form>
);
}
Context와 인증
Context 정의
// server/context.ts
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { getSession } from 'next-auth/react';
export async function createContext({ req, res }: CreateNextContextOptions) {
const session = await getSession({ req });
return {
session,
req,
res,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import type { Context } from './context';
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
// 인증된 사용자만 접근 가능
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
session: ctx.session,
},
});
});
인증된 라우터
// server/routers/user.ts
import { router, protectedProcedure } from '../trpc';
import { z } from 'zod';
export const userRouter = router({
getProfile: protectedProcedure.query(({ ctx }) => {
// ctx.session은 자동으로 타입 추론됨
return {
id: ctx.session.user.id,
name: ctx.session.user.name,
email: ctx.session.user.email,
};
}),
updateProfile: protectedProcedure
.input(z.object({ name: z.string() }))
.mutation(async ({ ctx, input }) => {
// DB 업데이트
await db.users.update({
where: { id: ctx.session.user.id },
data: { name: input.name },
});
return { success: true };
}),
});
입력 검증 (Zod)
복잡한 스키마
import { z } from 'zod';
const postRouter = router({
create: protectedProcedure
.input(
z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
tags: z.array(z.string()).max(5).optional(),
published: z.boolean().default(false),
metadata: z.object({
readTime: z.number().positive(),
category: z.enum(['tech', 'design', 'business']),
}).optional(),
})
)
.mutation(async ({ ctx, input }) => {
// input은 자동으로 검증되고 타입이 추론됨
const post = await db.posts.create({
data: {
...input,
authorId: ctx.session.user.id,
},
});
return post;
}),
getById: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const post = await db.posts.findUnique({
where: { id: input.id },
});
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post not found',
});
}
return post;
}),
});
라우터 병합
// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
import { commentRouter } from './comment';
export const appRouter = router({
user: userRouter,
post: postRouter,
comment: commentRouter,
});
export type AppRouter = typeof appRouter;
// 프론트엔드에서 사용
const user = await trpc.user.getProfile.query();
const posts = await trpc.post.getAll.query();
const comments = await trpc.comment.getByPost.query({ postId: 1 });
Subscriptions (실시간)
이전에 정리한 대로, HTTP 배치와 WebSocket(구독)은 용도가 다릅니다. 아래는 로컬 개발·단일 인스턴스를 가정한 최소 예이며, 멀티 인스턴스에서는 메시지 버스를 앞에 두는 식으로 확장하는 것이 일반적입니다.
WebSocket 설정
// server/wsServer.ts
import { applyWSSHandler } from '@trpc/server/adapters/ws';
import ws from 'ws';
import { appRouter } from './routers/_app';
import { createContext } from './context';
const wss = new ws.Server({ port: 3001 });
applyWSSHandler({
wss,
router: appRouter,
createContext,
});
Subscription 라우터
// server/routers/message.ts
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';
const ee = new EventEmitter();
export const messageRouter = router({
onNewMessage: publicProcedure.subscription(() => {
return observable<{ id: number; text: string }>((emit) => {
const onMessage = (data: any) => {
emit.next(data);
};
ee.on('newMessage', onMessage);
return () => {
ee.off('newMessage', onMessage);
};
});
}),
sendMessage: publicProcedure
.input(z.object({ text: z.string() }))
.mutation(({ input }) => {
const message = { id: Date.now(), text: input.text };
ee.emit('newMessage', message);
return message;
}),
});
프론트엔드에서 구독
// components/Chat.tsx
import { trpc } from '../utils/trpc';
import { useEffect, useState } from 'react';
export function Chat() {
const [messages, setMessages] = useState<any[]>([]);
trpc.message.onNewMessage.useSubscription(undefined, {
onData(message) {
setMessages((prev) => [...prev, message]);
},
});
const sendMessage = trpc.message.sendMessage.useMutation();
return (
<div>
<ul>
{messages.map((msg) => (
<li key={msg.id}>{msg.text}</li>
))}
</ul>
<button onClick={() => sendMessage.mutate({ text: 'Hello!' })}>
Send
</button>
</div>
);
}
에러 처리
// 서버
import { TRPCError } from '@trpc/server';
export const postRouter = router({
getById: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const post = await db.posts.findUnique({
where: { id: input.id },
});
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Post with id ${input.id} not found`,
});
}
return post;
}),
});
// 클라이언트
const { data, error } = trpc.post.getById.useQuery({ id: 999 });
if (error) {
console.log(error.data?.code); // 'NOT_FOUND'
console.log(error.message); // 'Post with id 999 not found'
}
tRPC vs REST vs GraphQL
| 기능 | tRPC | REST API | GraphQL |
|---|---|---|---|
| 타입 안전성 | ✅ End-to-End | ❌ 수동 | ⚠️ Code Gen |
| 오버페칭 | ❌ 없음 | ⚠️ 있음 | ❌ 없음 |
| 학습 곡선 | 🟢 쉬움 | 🟢 쉬움 | 🔴 어려움 |
| 공개 API | ❌ 부적합 | ✅ 적합 | ✅ 적합 |
| 풀스택 TS | ✅ 최고 | ❌ 수동 | ⚠️ 복잡 |
| 캐싱 | ✅ React Query | ⚠️ 수동 | ✅ Apollo |
실전 프로젝트: Todo 앱
백엔드 라우터
// server/routers/todo.ts
import { router, protectedProcedure } from '../trpc';
import { z } from 'zod';
export const todoRouter = router({
getAll: protectedProcedure.query(async ({ ctx }) => {
return await db.todos.findMany({
where: { userId: ctx.session.user.id },
orderBy: { createdAt: 'desc' },
});
}),
create: protectedProcedure
.input(z.object({ title: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
return await db.todos.create({
data: {
title: input.title,
userId: ctx.session.user.id,
completed: false,
},
});
}),
toggle: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const todo = await db.todos.findFirst({
where: { id: input.id, userId: ctx.session.user.id },
});
if (!todo) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return await db.todos.update({
where: { id: input.id },
data: { completed: !todo.completed },
});
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
await db.todos.delete({
where: {
id: input.id,
userId: ctx.session.user.id,
},
});
return { success: true };
}),
});
프론트엔드 컴포넌트
// components/TodoList.tsx
import { trpc } from '../utils/trpc';
import { useState } from 'react';
export function TodoList() {
const [title, setTitle] = useState('');
const utils = trpc.useContext();
const { data: todos, isLoading } = trpc.todo.getAll.useQuery();
const createTodo = trpc.todo.create.useMutation({
onSuccess: () => {
utils.todo.getAll.invalidate();
setTitle('');
},
});
const toggleTodo = trpc.todo.toggle.useMutation({
onSuccess: () => {
utils.todo.getAll.invalidate();
},
});
const deleteTodo = trpc.todo.delete.useMutation({
onSuccess: () => {
utils.todo.getAll.invalidate();
},
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
createTodo.mutate({ title });
}}
>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="New todo..."
/>
<button type="submit">Add</button>
</form>
<ul>
{todos?.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo.mutate({ id: todo.id })}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.title}
</span>
<button onClick={() => deleteTodo.mutate({ id: todo.id })}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
핵심 정리
✅ tRPC의 장점
- End-to-End 타입 안전성: 컴파일 타임에 에러 발견
- 빠른 개발 속도: API 문서·테스트 불필요
- React Query 통합: 캐싱·리페칭 자동
- 간단한 설정: Code Generation 불필요
- TypeScript 네이티브: 자연스러운 DX
🚀 다음 단계
- tRPC 공식 문서에서 심화 학습
- create-t3-app으로 풀스택 템플릿 시작
- tRPC Discord에서 커뮤니티 참여
시작하기:
npx create-t3-app@latest로 5분 만에 tRPC 풀스택 프로젝트를 시작하고, 타입 안전한 API 개발을 경험하세요! 🚀