본문으로 건너뛰기
Advertisement

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)
}
}
}
Advertisement