Devlery
Blog/TypeScript

tRPC 실전 가이드: TypeScript로 스키마 없는 타입 안전 API 구축하기

REST의 타입 불일치, GraphQL의 스키마 피로감을 해결하는 tRPC의 핵심 개념부터 Next.js App Router 통합, 인증 미들웨어 패턴까지 실전 코드와 함께 알아봅니다.

TypeScript로 풀스택 개발을 하다 보면 한 가지 반복적인 불편함을 마주하게 됩니다. 서버에서 API 응답 타입을 정의하고, 클라이언트에서 또 같은 타입을 정의하는 이중 작업입니다. REST API를 쓰면 OpenAPI 스펙을 작성하고 코드 생성 도구를 돌려야 하고, GraphQL을 쓰면 스키마 정의 후 codegen을 실행해야 합니다. 서버 코드를 바꿀 때마다 이 파이프라인을 다시 돌리는 건 꽤 피곤한 일입니다.

서버에서 함수를 정의하면 클라이언트에서 그 함수의 타입을 자동으로 알 수 있다면 어떨까요? 스키마 파일도, 코드 생성 도구도 없이 말입니다. tRPC는 정확히 이 문제를 해결합니다.

tRPC란 무엇인가

tRPC(TypeScript Remote Procedure Call)는 TypeScript의 타입 추론만으로 서버-클라이언트 간 API 계약을 자동화하는 RPC 프레임워크입니다. "Move Fast and Break Nothing"이라는 슬로건처럼, 빠른 개발 속도와 타입 안전성을 동시에 달성하는 것이 목표입니다.

핵심 아이디어는 단순합니다. 서버에서 정의한 프로시저(함수)의 입출력 타입이 클라이언트에 그대로 전파 됩니다. 별도의 스키마 정의 파일이나 코드 생성 단계가 없습니다. TypeScript 컴파일러가 이미 타입을 알고 있으니, 그걸 그대로 활용하는 것입니다.

🖥️
서버

Router / Procedure 정의
export type AppRouter

⚙️
TypeScript

타입 자동 추론
스키마 파일 · codegen
필요 없음

💻
클라이언트

자동완성 + 타입 체크
import type AppRouter

2025년 3월에 출시된 v11은 FormData/Blob 지원, React Server Components 개선, 응답 스트리밍 등 실무에서 필요한 기능을 대폭 추가했습니다. GitHub 스타 39,700개 이상, npm 주간 다운로드 70만 회 이상으로 TypeScript 생태계에서 확고한 위치를 잡았습니다.

핵심 개념 네 가지

tRPC를 이해하려면 네 가지 개념만 알면 됩니다.

Router

API 엔드포인트의 네임스페이스를 정의합니다. REST의 라우트 그룹과 비슷한 역할입니다. 중첩 라우터로 도메인별 구조화가 가능합니다.

import { initTRPC } from '@trpc/server'

const t = initTRPC.create()
export const router = t.router
export const publicProcedure = t.procedure

Procedure

API 엔드포인트의 실제 로직을 담당합니다. 세 가지 유형이 있습니다.

  • query — 데이터 조회 (REST의 GET에 대응)
  • mutation — 데이터 변경 (REST의 POST/PUT/DELETE에 대응)
  • subscription — 실시간 데이터 스트림 (SSE/WebSocket)

Context

요청별로 공유되는 데이터를 담는 객체입니다. 인증 정보, DB 연결 등을 프로시저에 전달하는 용도로 사용합니다.

Middleware

프로시저 실행 전후에 로직을 삽입합니다. 인증 확인, 로깅, 권한 검사 등에 사용하며, 컨텍스트의 타입을 좁히는 역할도 합니다.

기본 서버/클라이언트 설정

실제 코드로 tRPC의 동작 방식을 살펴보겠습니다. 먼저 필요한 패키지를 설치합니다.

pnpm add @trpc/server @trpc/client zod

서버 설정

tRPC 초기화 파일을 만듭니다.

// server/trpc.ts
import { initTRPC } from '@trpc/server'

const t = initTRPC.create()

export const router = t.router
export const publicProcedure = t.procedure

라우터를 정의합니다. Zod를 사용해 입력 검증과 타입 추론을 동시에 처리하는 것이 tRPC의 핵심 패턴입니다.

// server/appRouter.ts
import { z } from 'zod'
import { publicProcedure, router } from './trpc'

type User = { id: string; name: string }

const users: User[] = [{ id: '1', name: 'Alice' }]

export const appRouter = router({
  userList: publicProcedure
    .query(async () => users),

  userById: publicProcedure
    .input(z.string())
    .query(async ({ input }) => {
      const user = users.find((u) => u.id === input)
      return user
    }),

  userCreate: publicProcedure
    .input(z.object({ name: z.string() }))
    .mutation(async ({ input }) => {
      const user: User = { id: String(users.length + 1), ...input }
      users.push(user)
      return user
    }),
})

// 이 타입을 클라이언트에서 import합니다
export type AppRouter = typeof appRouter

서버를 실행합니다.

// server/index.ts
import { createHTTPServer } from '@trpc/server/adapters/standalone'
import { appRouter } from './appRouter'

const server = createHTTPServer({
  router: appRouter,
})

server.listen(3000)
console.log('tRPC server listening on port 3000')

클라이언트 설정

클라이언트에서는 AppRouter 타입만 import하면 됩니다. import type을 사용하므로 서버 코드가 클라이언트 번들에 포함되지 않습니다.

// client/index.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from '../server/appRouter'

const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000',
    }),
  ],
})

// 타입 안전한 호출 — 자동완성이 동작합니다
const users = await trpc.userList.query()
const user = await trpc.userById.query('1')
const newUser = await trpc.userCreate.mutate({ name: 'Bob' })

여기서 주목할 점은 trpc.userCreate.mutate()를 호출할 때 { name: z.string() } 스키마에 맞지 않는 값을 넘기면 컴파일 타임에 에러가 발생 한다는 것입니다. 서버의 userById 프로시저를 삭제하면 클라이언트의 trpc.userById.query() 호출에서 즉시 타입 에러가 나타납니다. 런타임이 아니라 에디터에서 바로 확인할 수 있습니다.

Zod와의 통합: 입력 검증 + 타입 추론

tRPC의 .input() 메서드는 Zod 스키마를 받아 두 가지 역할을 동시에 수행합니다. 런타임에서 입력값을 검증하고, 컴파일 타임에서 TypeScript 타입을 추론합니다. Zod의 기본 사용법이 궁금하다면 Zod: TypeScript 런타임 검증의 정석 글을 참고해 주세요.

import { z } from 'zod'
import { publicProcedure, router } from './trpc'

// Zod 스키마 정의
const createUserInput = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
})

const updateUserInput = z.object({
  id: z.string(),
  name: z.string().min(2).max(50).optional(),
  email: z.string().email().optional(),
})

export const appRouter = router({
  userCreate: publicProcedure
    .input(createUserInput)
    .mutation(async ({ input }) => {
      // input 타입이 자동 추론됩니다:
      // { name: string; email: string; age?: number }
      return db.user.create({ data: input })
    }),

  userUpdate: publicProcedure
    .input(updateUserInput)
    .mutation(async ({ input }) => {
      // input.id는 string, 나머지는 optional
      const { id, ...data } = input
      return db.user.update({ where: { id }, data })
    }),
})

Zod 스키마 하나로 런타임 검증, 서버 타입, 클라이언트 타입이 모두 해결됩니다. 타입 정의를 별도로 관리할 필요가 없으니 코드가 훨씬 간결해집니다.

Next.js App Router 통합

tRPC가 가장 빛나는 환경은 Next.js입니다. v11에서는 App Router와의 통합이 한층 개선되었습니다.

tRPC 초기화

// trpc/init.ts
import { initTRPC, TRPCError } from '@trpc/server'
import superjson from 'superjson'

export const createContext = async (opts: { req: Request }) => {
  const session = await getSession(opts.req)
  return { user: session?.user ?? null }
}

type Context = Awaited<ReturnType<typeof createContext>>

const t = initTRPC.context<Context>().create({
  transformer: superjson,
})

export const router = t.router
export const publicProcedure = t.procedure

export const protectedProcedure = t.procedure.use(async (opts) => {
  if (!opts.ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' })
  }
  return opts.next({ ctx: { user: opts.ctx.user } })
})

API 핸들러

Next.js의 Route Handler로 tRPC를 연결합니다.

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/trpc/router'
import { createContext } from '@/trpc/init'

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => createContext({ req }),
  })

export { handler as GET, handler as POST }

클라이언트 프로바이더

tRPC v11은 TanStack React Query와 통합되어 캐싱, 낙관적 업데이트 등을 자연스럽게 사용할 수 있습니다.

// trpc/client.tsx
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import { createTRPCContext } from '@trpc/tanstack-react-query'
import { useState } from 'react'
import superjson from 'superjson'
import type { AppRouter } from './router'

export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>()

export function TRPCReactProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: { queries: { staleTime: 30 * 1000 } },
      })
  )

  const [trpcClient] = useState(() =>
    createTRPCClient<AppRouter>({
      links: [
        httpBatchLink({
          url: '/api/trpc',
          transformer: superjson,
        }),
      ],
    })
  )

  return (
    <QueryClientProvider client={queryClient}>
      <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
        {children}
      </TRPCProvider>
    </QueryClientProvider>
  )
}

클라이언트 컴포넌트에서 사용

// components/UserList.tsx
'use client'

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTRPC } from '@/trpc/client'

export function UserList() {
  const trpc = useTRPC()
  const queryClient = useQueryClient()
  const { data: users, isLoading } = useQuery(trpc.user.list.queryOptions())

  const createUser = useMutation(
    trpc.user.create.mutationOptions({
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: trpc.user.list.queryKey() })
      },
    })
  )

  if (isLoading) return <p>로딩 중...</p>

  return (
    <div>
      {users?.map((user) => (
        <p key={user.id}>{user.name}</p>
      ))}
      <button onClick={() => createUser.mutate({ name: 'New User' })}>
        사용자 추가
      </button>
    </div>
  )
}

서버 컴포넌트에서는 HTTP 요청 없이 프로시저를 직접 호출할 수도 있습니다. 이는 초기 로딩 성능을 크게 개선합니다.

// app/page.tsx
import { prefetch } from '@/trpc/server'
import { trpc } from '@/trpc/server'

export default async function Page() {
  // 서버에서 데이터 프리페칭 (HTTP 요청 없이 직접 호출)
  void prefetch(trpc.user.list.queryOptions())

  return <UserList />
}

인증 미들웨어와 에러 핸들링

실무에서 API를 만들 때 인증과 권한 관리는 빠질 수 없습니다. tRPC의 미들웨어는 컨텍스트 타입을 좁혀주기 때문에, 인증된 프로시저에서는 ctx.user항상 존재하는 것이 타입으로 보장 됩니다.

import { initTRPC, TRPCError } from '@trpc/server'
import { z } from 'zod'

const t = initTRPC.context<Context>().create()

// 1. 인증 미들웨어
const isAuthed = t.middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: '로그인이 필요합니다',
    })
  }
  // ctx.user가 non-null로 타입이 좁혀집니다
  return next({ ctx: { user: ctx.user } })
})

// 2. 역할 기반 미들웨어
const hasRole = (role: string) =>
  t.middleware(async ({ ctx, next }) => {
    if (!ctx.user?.roles.includes(role)) {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: `${role} 권한이 필요합니다`,
      })
    }
    return next({ ctx: { user: ctx.user } })
  })

// 3. 프로시저 레벨별 정의
const publicProcedure = t.procedure
const protectedProcedure = t.procedure.use(isAuthed)
const adminProcedure = t.procedure.use(isAuthed).use(hasRole('admin'))

// 4. 라우터에서 사용
const appRouter = t.router({
  // 누구나 접근 가능
  publicData: publicProcedure.query(() => ({ message: 'public' })),

  // 로그인 사용자만
  myProfile: protectedProcedure.query(({ ctx }) => {
    // ctx.user는 non-null이 보장됩니다
    return db.user.findUnique({ where: { id: ctx.user.id } })
  }),

  // 관리자만
  deleteUser: adminProcedure
    .input(z.object({ userId: z.string() }))
    .mutation(({ input }) => {
      return db.user.delete({ where: { id: input.userId } })
    }),
})

이 패턴의 장점은 publicProcedure, protectedProcedure, adminProcedure 를 한 번 정의해두면 프로젝트 전체에서 재사용할 수 있다는 것입니다. 각 프로시저에서 인증 로직을 반복 작성할 필요가 없습니다.

에러 핸들링도 직관적입니다. TRPCError를 던지면 적절한 HTTP 상태 코드로 자동 매핑됩니다.

import { TRPCError } from '@trpc/server'

const appRouter = router({
  userById: publicProcedure
    .input(z.string())
    .query(async ({ input }) => {
      const user = await db.user.findUnique({ where: { id: input } })
      if (!user) {
        throw new TRPCError({
          code: 'NOT_FOUND',        // → HTTP 404
          message: `User ${input} not found`,
        })
      }
      return user
    }),
})

BAD_REQUEST는 400, UNAUTHORIZED는 401, FORBIDDEN은 403, NOT_FOUND는 404, INTERNAL_SERVER_ERROR는 500으로 자동 변환됩니다.

REST vs GraphQL vs tRPC

이쯤에서 기존 선택지들과 비교해 보겠습니다. 각 접근법은 서로 다른 문제를 해결하기 위해 만들어졌기 때문에, "무엇이 최고인가"보다는 "언제 무엇이 적합한가"가 중요합니다.

🌐
REST
공개 API · 마이크로서비스
타입 안전성
수동 정의 / codegen 필요
보일러플레이트
중간
학습 곡선
낮음
다중 언어
모든 언어 ✅
캐싱
HTTP 캐시 활용 ✅
실시간
별도 구현 필요
공개 API
최적 ✅
🔷
GraphQL
복잡한 데이터 페칭
타입 안전성
스키마 기반 codegen
보일러플레이트
높음
학습 곡선
높음
다중 언어
모든 언어 ✅
캐싱
별도 설정 필요
실시간
Subscription 내장 ✅
공개 API
적합 ✅
tRPC
TypeScript 풀스택 내부 API
타입 안전성
TypeScript 자동 추론 ✅
보일러플레이트
낮음
학습 곡선
낮음
다중 언어
TypeScript 전용 ⚠️
캐싱
React Query 통합 ✅
실시간
SSE 내장 ✅
공개 API
부적합 ❌

REST를 선택할 때

공개 API를 제공해야 하거나, 다양한 언어의 클라이언트를 지원해야 한다면 REST + OpenAPI가 여전히 최선의 선택입니다. HTTP 캐싱, CDN 활용, curl로 바로 테스트할 수 있는 접근성도 REST의 강점입니다. Hono 같은 경량 프레임워크와 함께 사용하면 쾌적한 REST API를 구축할 수 있습니다.

GraphQL을 선택할 때

여러 데이터 소스를 조합하는 복잡한 쿼리가 필요하거나, 모바일과 웹에서 서로 다른 데이터 구조를 요구하는 경우에 GraphQL이 적합합니다. 다만 스키마 정의, codegen, DataLoader 패턴 등 학습 곡선이 가파릅니다.

tRPC를 선택할 때

TypeScript 풀스택 프로젝트, 특히 모노레포 구조에서 tRPC의 장점이 극대화됩니다. 서버와 클라이언트가 같은 코드베이스에 있고, 같은 팀이 관리하는 내부 API라면 tRPC가 가장 빠르고 안전한 선택입니다. Next.js 기반 프로젝트라면 더욱 그렇습니다.

실용적 팁과 주의사항

Request Batching 활용

tRPC의 httpBatchLink는 여러 프로시저 호출을 단일 HTTP 요청으로 묶어 전송합니다. 컴포넌트 여러 곳에서 동시에 API를 호출하더라도 네트워크 요청은 하나로 합쳐집니다.

import { createTRPCClient, httpBatchLink } from '@trpc/client'

const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000',
      maxURLLength: 2083,  // URL 길이 제한 (초과 시 자동 분할)
      maxItems: 10,        // 배치당 최대 항목 수
    }),
  ],
})

// 이 세 호출은 1개의 HTTP 요청으로 처리됩니다
const [post1, post2, post3] = await Promise.all([
  trpc.post.byId.query(1),
  trpc.post.byId.query(2),
  trpc.post.byId.query(3),
])

라우터 분할

프로젝트가 커지면 도메인별로 라우터를 분할하는 것이 좋습니다.

// routers/user.ts
export const userRouter = router({
  list: publicProcedure.query(() => { /* ... */ }),
  byId: publicProcedure.input(z.string()).query(() => { /* ... */ }),
  create: protectedProcedure.input(createUserInput).mutation(() => { /* ... */ }),
})

// routers/post.ts
export const postRouter = router({
  list: publicProcedure.query(() => { /* ... */ }),
  create: protectedProcedure.input(createPostInput).mutation(() => { /* ... */ }),
})

// router.ts
import { userRouter } from './routers/user'
import { postRouter } from './routers/post'

export const appRouter = router({
  user: userRouter,
  post: postRouter,
})

export type AppRouter = typeof appRouter

주의해야 할 점

tRPC는 훌륭한 도구이지만 만능은 아닙니다. 도입 전에 다음 사항을 반드시 고려해야 합니다.

  • TypeScript 전용입니다. Python, Go, Java 등 다른 언어의 클라이언트는 작성할 수 없습니다. 모바일 앱이 Swift나 Kotlin으로 작성되어 있다면 tRPC는 적합하지 않습니다.
  • 공개 API에는 부적합합니다. 외부 개발자에게 API를 제공해야 한다면 REST + OpenAPI 문서가 필요합니다.
  • 서버와 클라이언트가 강하게 결합됩니다. 서버와 클라이언트를 독립적으로 배포하고 버전 관리해야 하는 환경에서는 타입 불일치 문제가 발생할 수 있습니다.
  • HTTP 표준 캐싱을 활용하기 어렵습니다. URL 기반 캐싱이나 CDN 캐싱 등 REST의 HTTP 레벨 최적화를 그대로 사용할 수 없습니다.

oRPC: 떠오르는 대안

최근 oRPC라는 대안도 주목받고 있습니다. oRPC는 tRPC와 비슷한 개발 경험을 제공하면서도, OpenAPI 문서 자동 생성과 다중 프레임워크(Vue, Svelte) 지원을 내장하고 있습니다. 번들 크기도 약 5KB로 tRPC(약 30KB)보다 가볍습니다. 다만 v1.0이 출시된 지 얼마 되지 않아 커뮤니티와 생태계는 아직 초기 단계입니다. 내부 API와 외부 API를 동시에 제공해야 하는 경우라면 살펴볼 가치가 있습니다.

마무리

tRPC는 TypeScript 풀스택 개발에서 API 계약 관리 비용을 사실상 0으로 줄여주는 도구입니다. 서버에서 함수를 정의하면 클라이언트에서 타입이 자동으로 따라오고, 서버 코드를 변경하면 클라이언트에서 즉시 컴파일 타임 에러로 알려줍니다. 스키마 파일도, codegen 파이프라인도 필요 없습니다.

정리하면 다음과 같은 팀에 tRPC를 추천합니다.

  • TypeScript로 서버와 클라이언트를 함께 개발하는 풀스택 팀
  • Next.js 기반 프로젝트에서 API 레이어를 고민하는 팀
  • REST API의 타입 수동 관리에 피로감을 느끼는 개발자
  • 빠른 프로토타이핑이 필요한 스타트업이나 소규모 팀

반대로 공개 API를 제공해야 하거나, 다중 언어 클라이언트를 지원해야 하거나, 서버-클라이언트를 독립적으로 배포해야 하는 환경에서는 REST나 GraphQL이 더 적합합니다. 도구는 문제에 맞게 선택하는 것이 중요합니다.

TypeScript로 풀스택 개발을 하고 있다면, tRPC로 "Move Fast and Break Nothing"을 직접 경험해 보시길 바랍니다.