17.5 tRPC — 타입 안전한 풀스택 API
tRPC란?
tRPC는 서버와 클라이언트 사이에 API 스키마나 코드 생성 없이 End-to-End 타입 안전성을 제공합니다. TypeScript의 타입 추론만으로 클라이언트가 서버 타입을 자동으로 인식합니다.
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
서버 설정
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { z } from 'zod'
// Context 타입 정의
export interface Context {
user: { id: string; role: string } | null
db: DatabaseService
}
// tRPC 인스턴스 생성
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.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({ ctx: { ...ctx, user: ctx.user } }) // user가 non-null임을 보장
})
// 관리자 전용 프로시저
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN' })
}
return next({ ctx })
})
// server/routers/user.router.ts
import { z } from 'zod'
import { router, publicProcedure, protectedProcedure } from '../trpc'
const CreateUserInput = z.object({
email: z.string().email(),
name: z.string().min(2).max(50),
password: z.string().min(8),
})
const UpdateUserInput = z.object({
name: z.string().min(2).max(50).optional(),
})
export const userRouter = router({
// 공개 조회
getAll: publicProcedure
.input(z.object({ page: z.number().default(1), limit: z.number().default(10) }))
.query(async ({ ctx, input }) => {
return ctx.db.user.findMany({
skip: (input.page - 1) * input.limit,
take: input.limit,
})
}),
getById: publicProcedure
.input(z.string().uuid())
.query(async ({ ctx, input }) => {
const user = await ctx.db.user.findUnique({ where: { id: input } })
if (!user) throw new TRPCError({ code: 'NOT_FOUND' })
return user
}),
// 인증 필요
updateMe: protectedProcedure
.input(UpdateUserInput)
.mutation(async ({ ctx, input }) => {
return ctx.db.user.update({
where: { id: ctx.user.id },
data: input,
})
}),
deleteMe: protectedProcedure
.mutation(async ({ ctx }) => {
await ctx.db.user.delete({ where: { id: ctx.user.id } })
return { success: true }
}),
})
// server/routers/_app.ts
import { router } from '../trpc'
import { userRouter } from './user.router'
import { postRouter } from './post.router'
import { authRouter } from './auth.router'
export const appRouter = router({
user: userRouter,
post: postRouter,
auth: authRouter,
})
// 클라이언트에서 사용할 타입 내보내기
export type AppRouter = typeof appRouter
클라이언트 설정
// client/api.ts
import { createTRPCReact } from '@trpc/react-query'
import { httpBatchLink } from '@trpc/client'
import type { AppRouter } from '../server/routers/_app'
export const trpc = createTRPCReact<AppRouter>()
// trpc 클라이언트 — TanStack Query 연동
export const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
headers: () => {
const token = localStorage.getItem('token')
return token ? { authorization: `Bearer ${token}` } : {}
},
}),
],
})
React 컴포넌트에서 사용
// components/UserList.tsx
import { trpc } from '../client/api'
export function UserList() {
// 타입 완전 자동 추론!
const { data, isLoading, error } = trpc.user.getAll.useQuery({
page: 1,
limit: 10,
})
if (isLoading) return <div>로딩 중...</div>
if (error) return <div>에러: {error.message}</div>
return (
<ul>
{data?.map(user => (
<li key={user.id}>{user.name} — {user.email}</li>
))}
</ul>
)
}
// components/UpdateProfile.tsx
export function UpdateProfile() {
const utils = trpc.useUtils()
const updateMe = trpc.user.updateMe.useMutation({
onSuccess: () => {
// 캐시 무효화
utils.user.getAll.invalidate()
},
})
return (
<button
onClick={() => updateMe.mutate({ name: 'New Name' })}
disabled={updateMe.isPending}
>
이름 변경
</button>
)
}
Next.js App Router 통합
// 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',
req,
router: appRouter,
createContext: () => createContext(req),
})
export { handler as GET, handler as POST }
고수 팁
tRPC 에러 처리
import { TRPCClientError } from '@trpc/client'
import type { AppRouter } from '../server/routers/_app'
function handleTRPCError(error: unknown) {
if (error instanceof TRPCClientError<AppRouter>) {
// 타입 안전한 에러 코드 접근
switch (error.data?.code) {
case 'UNAUTHORIZED':
router.push('/login')
break
case 'NOT_FOUND':
router.push('/404')
break
case 'TOO_MANY_REQUESTS':
showRateLimitNotification()
break
default:
showErrorToast(error.message)
}
}
}