2025-08-24 14:01

  • tRPC는 프론트엔드와 백엔드 간의 API를 별도의 스키마나 코드 생성 없이 완벽하게 타입 세이프하게 만들어주는 프레임워크입니다.

  • 타입스크립트의 타입 추론(inference) 기능을 핵심 원리로 사용하여, 백엔드에 정의된 API 라우터의 타입 정보를 프론트엔드에서 그대로 활용합니다.

  • 이를 통해 개발자는 API의 입력값과 반환값에 대한 자동 완성 기능을 지원받고, API 명세 변경으로 인한 런타임 에러를 빌드 시점에 잡아낼 수 있어 개발 생산성을 극대화할 수 있습니다.

tRPC 완벽 핸드북 타입스크립트 개발자의 API 고민 끝내기

오늘날 웹 개발 생태계에서 API는 프론트엔드와 백엔드를 연결하는 필수적인 다리 역할을 합니다. 하지만 이 다리는 종종 불안정하고 예측 불가능한 문제들을 야기하곤 합니다. 백엔드에서 API의 응답 형식을 살짝 바꾸기만 해도, 프론트엔드에서는 예상치 못한 런타임 에러가 발생하여 전체 서비스가 마비될 수 있습니다.

이러한 문제를 해결하기 위해 우리는 OpenAPI(Swagger) 같은 명세 도구를 사용해 문서를 만들고, GraphQL을 도입해 필요한 데이터만 요청하는 등 다양한 노력을 기울여왔습니다. 하지만 문서는 결국 수동으로 관리해야 하는 또 다른 작업이 되고, GraphQL은 강력한 만큼 초기 설정과 학습 곡선이 만만치 않습니다.

만약 프론트엔드와 백엔드가 서로의 언어를 완벽하게 이해하고, 마치 텔레파시처럼 실시간으로 소통할 수 있다면 어떨까요? 백엔드 개발자가 API 코드를 수정하는 즉시, 프론트엔드 개발자의 코드 에디터에서 관련 타입 오류가 바로 표시되고, 사용 가능한 모든 API 목록과 필요한 인자들이 자동으로 완성된다면 말입니다.

이러한 꿈 같은 개발 경험을 현실로 만들어주는 기술이 바로 tRPC (TypeScript Remote Procedure Call) 입니다. tRPC는 타입스크립트 생태계에 혁신을 가져온 기술로, API 개발의 패러다임을 바꾸고 있습니다. 이 핸드북에서는 tRPC가 왜 만들어졌는지, 어떤 원리로 동작하는지, 그리고 어떻게 우리 프로젝트에 적용하여 개발 생산성을 폭발적으로 향상시킬 수 있는지 A부터 Z까지 상세하게 알아보겠습니다.

1. tRPC, 왜 만들어졌을까요? API 개발의 오랜 숙원

tRPC의 탄생 배경을 이해하려면 먼저 기존 API 방식들이 가진 고질적인 문제, 즉 ‘API 계약(Contract) 불일치’ 문제를 짚고 넘어가야 합니다.

API 계약 불일치의 문제

API 계약이란 “프론트엔드와 백엔드 간에 데이터를 주고받는 형식과 규칙에 대한 약속”을 의미합니다. 예를 들어, GET /users/{id} 라는 API는 숫자 형태의 id를 받아서 id, name, email 필드를 포함한 객체를 반환하기로 약속하는 것입니다.

문제는 이 약속이 깨지기 너무나 쉽다는 점에 있습니다.

  • REST API의 한계: REST API는 그 자체로는 타입 정보를 담고 있지 않습니다. 백엔드에서 사용자 정보에 age 필드를 추가해도, 프론트엔드에서는 이 사실을 알 길이 없습니다. 프론트엔드 개발자는 여전히 age가 없는 객체를 기대하고 코드를 작성하다가, 실제 데이터가 들어왔을 때 undefined 관련 버그를 마주하게 됩니다. 이를 방지하기 위해 OpenAPI 같은 도구로 명세를 작성하지만, API 코드가 변경될 때마다 명세 파일도 함께 수정해야 하는 번거로움이 따릅니다. 이 과정에서 실수가 발생하면 명세와 실제 구현이 달라지는 문제가 또다시 발생합니다.

  • GraphQL의 복잡성: GraphQL은 강력한 타입 시스템을 기반으로 이 문제를 해결합니다. 클라이언트가 필요한 데이터 구조를 직접 정의하여 요청할 수 있고, 서버는 스키마에 따라 정확한 타입의 데이터를 반환합니다. 하지만 이를 위해서는 별도의 스키마 정의 언어(SDL)를 배워야 하고, 리졸버(Resolver), 타입 정의 등 GraphQL만의 복잡한 생태계를 구축해야 합니다. 간단한 프로젝트에 도입하기에는 배보다 배꼽이 더 커지는 경우가 많습니다.

tRPC의 철학: 타입스크립트를 신뢰의 단일 근원으로

tRPC는 이러한 문제들을 아주 단순하고 우아한 방식으로 해결합니다. 바로 **“별도의 스키마나 코드 생성 없이, 오직 타입스크립트의 타입 추론 기능만을 사용하자”**는 것입니다.

tRPC의 핵심 철학은 타입스크립트를 ‘신뢰의 단일 근원(Single Source of Truth)‘으로 삼는 것입니다. 백엔드에서 타입스크립트로 작성된 API 라우터 코드가 곧 API 명세 그 자체가 됩니다. 프론트엔드는 이 라우터의 ‘타입 정보’만을 가져와서 API 클라이언트를 생성합니다.

이것이 가능한 이유는 tRPC가 새로운 통신 프로토콜이 아니라, 타입스크립트의 정적 분석 능력을 극대화한 영리한 ‘구현 패턴’에 가깝기 때문입니다. 통신 자체는 기존의 HTTP를 그대로 사용하므로 추가적인 네트워크 오버헤드가 없습니다.

결과적으로 개발자는 다음과 같은 이점을 얻게 됩니다.

  • 엔드 투 엔드(End-to-end) 타입 안정성: 백엔드 API 함수의 시그니처가 변경되면, 해당 API를 사용하는 모든 프론트엔드 코드에서 즉시 타입 에러가 발생합니다. 런타임이 아닌 빌드 시점에 오류를 잡을 수 있습니다.

  • 궁극의 개발 경험(DX): 프론트엔드에서 tRPC 클라이언트 객체에 점(.)을 찍는 순간, 사용 가능한 모든 API 프로시저(procedure) 목록이 자동 완성으로 나타납니다. 각 프로시저에 필요한 입력값(input)과 반환되는 출력값(output)의 타입까지 완벽하게 추론됩니다.

  • 코드 생성 불필요: 스키마 파일로부터 클라이언트 코드를 생성하는 과정이 전혀 필요 없습니다. 백엔드 코드를 수정하면 그 즉시 프론트엔드에 타입 정보가 반영됩니다.

2. tRPC의 핵심 구조와 동작 원리

“어떻게 스키마 파일도 없이 프론트엔드가 백엔드 API의 타입을 알 수 있을까?” 이것이 tRPC를 처음 접하는 사람들이 가장 궁금해하는 점입니다. 정답은 **‘타입(Type)은 공유하되, 값(Value)은 공유하지 않는다’**에 있습니다.

전체 그림

  1. 백엔드 (Server): 개발자는 @trpc/server를 사용하여 API 라우터(Router) 를 정의합니다. 라우터는 여러 개의 프로시저(Procedure) 로 구성됩니다. 프로시저는 데이터를 가져오는 쿼리(Query) 와 데이터를 변경하는 뮤테이션(Mutation) 으로 나뉩니다. 이들은 사실상 비동기 함수에 불과합니다.

  2. 타입 공유: 백엔드 프로젝트는 완성된 appRouter타입export type AppRouter = typeof appRouter; 와 같이 내보냅니다. 중요한 것은 실제 라우터 객체(값)가 아니라, 타입스크립트의 typeof를 통해 추출된 순수한 타입 정보만 내보낸다는 점입니다.

  3. 프론트엔드 (Client): 프론트엔드에서는 @trpc/client (또는 @trpc/react-query)를 사용하여 tRPC 클라이언트를 생성합니다. 이때 백엔드에서 내보낸 AppRouter 타입을 제네릭으로 넘겨줍니다.

  4. 타입 추론의 마법: 이제 프론트엔드의 tRPC 클라이언트는 AppRouter 타입을 통해 백엔드의 모든 라우터 구조, 프로시저 이름, 각 프로시저의 입력 및 출력 타입을 완벽하게 알게 됩니다. 이는 타입스크립트 컴파일러가 수행하는 정적 분석 덕분입니다.

  5. API 호출: 프론트엔드에서 trpc.user.getById.useQuery('1')과 같은 코드를 작성하면, 내부적으로는 GET /api/trpc/user.getById?input="1"과 같은 HTTP 요청이 백엔드로 전송됩니다.

  6. 백엔드 처리: 백엔드의 tRPC 핸들러는 요청 경로(user.getById)를 파싱하여 해당하는 프로시저 함수를 실행하고, 그 결과를 JSON 형태로 응답합니다.

이 모든 과정에서 타입 안정성은 타입스크립트가 보장하며, 실제 데이터 통신은 단순한 HTTP 요청/응답으로 이루어집니다.

핵심 구성 요소

  • 프로시저 (Procedure): API의 개별 엔드포인트에 해당합니다. t.procedure.query() 또는 t.procedure.mutation()을 통해 정의됩니다.

    • input(): 프로시저가 받을 입력값의 타입을 정의합니다. 보통 Zod 라이브러리를 사용해 런타임 유효성 검사까지 함께 처리합니다.

    • resolve(): 프로시저의 실제 로직이 담긴 함수입니다. 데이터베이스 조회, 외부 API 호출 등의 작업을 수행하고 결과를 반환합니다.

  • 라우터 (Router): 여러 프로시저를 그룹화하고 중첩 구조를 만들 수 있게 해줍니다. t.router()를 통해 생성하며, 다른 라우터를 포함할 수도 있습니다.

  • AppRouter 타입: 백엔드에서 정의된 최상위 라우터의 타입으로, tRPC의 타입 공유 메커니즘의 핵심입니다.

3. tRPC 시작하기 실전 가이드 (Next.js 기준)

이제 실제 코드를 통해 tRPC를 어떻게 프로젝트에 적용하는지 단계별로 살펴보겠습니다. 여기서는 가장 일반적인 조합인 Next.js와 Prisma를 예시로 사용합니다.

1단계: 프로젝트 설정 및 라이브러리 설치

먼저 필요한 라이브러리들을 설치합니다.

Bash

# tRPC 서버 및 클라이언트
npm install @trpc/server @trpc/client @trpc/next @trpc/react-query
# React Query (tRPC가 내부적으로 사용)
npm install @tanstack/react-query
# 입력값 유효성 검사를 위한 Zod
npm install zod

2단계: 백엔드 구현 (Server-side)

Next.js의 API 라우트 기능을 활용하여 tRPC 서버를 설정합니다.

1. tRPC 인스턴스 초기화 (/server/trpc.ts)

TypeScript

// /server/trpc.ts
import { initTRPC } from '@trpc/server';
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { prisma } from './db'; // Prisma 클라이언트 인스턴스

// Context: 모든 프로시저에서 공통적으로 사용될 값 (DB 커넥션, 사용자 세션 등)
export const createContext = async (opts: CreateNextContextOptions) => {
  const { req, res } = opts;
  // 여기에 사용자 인증 로직 등을 추가할 수 있습니다.
  return {
    prisma,
    req,
    res,
  };
};

const t = initTRPC.context<typeof createContext>().create();

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

2. API 라우터 정의 (/server/routers/post.ts)

게시글(Post)과 관련된 API 라우터를 만들어 보겠습니다.

TypeScript

// /server/routers/post.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';

export const postRouter = router({
  // 모든 게시글을 가져오는 쿼리
  getAll: publicProcedure.query(({ ctx }) => {
    return ctx.prisma.post.findMany();
  }),

  // ID로 특정 게시글을 가져오는 쿼리
  getById: publicProcedure
    .input(z.string()) // 입력값으로 string 타입의 ID를 받음
    .query(({ ctx, input }) => {
      return ctx.prisma.post.findUnique({
        where: { id: input },
      });
    }),

  // 새 게시글을 생성하는 뮤테이션
  create: publicProcedure
    .input(z.object({ title: z.string(), content: z.string() })) // 입력값 타입 정의
    .mutation(async ({ ctx, input }) => {
      const post = await ctx.prisma.post.create({
        data: {
          title: input.title,
          content: input.content,
        },
      });
      return post;
    }),
});

3. 최상위 라우터 결합 (/server/root.ts)

여러 라우터를 하나로 합쳐 최상위 appRouter를 만듭니다.

TypeScript

// /server/root.ts
import { postRouter } from './routers/post';
import { router } from './trpc';

export const appRouter = router({
  post: postRouter, // 'post'라는 네임스페이스로 postRouter를 등록
  // 여기에 userRouter, productRouter 등을 추가할 수 있습니다.
});

// 이 타입이 프론트엔드에서 사용될 핵심입니다.
export type AppRouter = typeof appRouter;

4. API 핸들러 생성 (/pages/api/trpc/[trpc].ts)

Next.js가 tRPC 요청을 처리할 수 있도록 API 엔드포인트를 만듭니다.

TypeScript

// /pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/root';
import { createContext } from '../../../server/trpc';

// 모든 /api/trpc/* 요청을 이 핸들러가 처리합니다.
export default createNextApiHandler({
  router: appRouter,
  createContext,
});

3단계: 프론트엔드 연동 (Client-side)

이제 프론트엔드에서 백엔드 API를 타입 안전하게 호출해 보겠습니다.

1. tRPC 클라이언트 설정 (/utils/trpc.ts)

TypeScript

// /utils/trpc.ts
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import type { AppRouter } from '../server/root'; // 백엔드의 AppRouter 타입을 가져옵니다!

function getBaseUrl() {
  if (typeof window !== 'undefined') return ''; // 브라우저에서는 상대 경로
  if (process.env.VERCEL_URL) return `https://{process.env.VERCEL_URL}`; // Vercel
  return `http://localhost:${process.env.PORT ?? 3000}`; // 로컬 개발 환경
}

export const trpc = createTRPCNext<AppRouter>({
  config() {
    return {
      links: [
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
        }),
      ],
    };
  },
  ssr: false,
});

2. _app.tsx 에 tRPC Provider 적용

애플리케이션 전체에서 tRPC를 사용할 수 있도록 _app.tsx를 tRPC HOC(Higher-Order Component)로 감싸줍니다.

TypeScript

// /pages/_app.tsx
import type { AppType } from 'next/app';
import { trpc } from '../utils/trpc';

const MyApp: AppType = ({ Component, pageProps }) => {
  return <Component {...pageProps} />;
};

export default trpc.withTRPC(MyApp);

3. 컴포넌트에서 API 호출

이제 모든 준비가 끝났습니다. 페이지 컴포넌트에서 tRPC 훅을 사용해 봅시다.

TypeScript

// /pages/index.tsx
import { trpc } from '../utils/trpc';

export default function Home() {
  // 모든 게시글을 가져오는 쿼리 훅
  const { data: posts, isLoading, error } = trpc.post.getAll.useQuery();

  // 새 게시글을 생성하는 뮤테이션 훅
  const createPost = trpc.post.create.useMutation({
    onSuccess: () => {
      // 뮤테이션 성공 시 'post.getAll' 쿼리를 다시 실행하여 목록을 갱신
      utils.post.getAll.invalidate();
    },
  });

  const handleCreatePost = () => {
    createPost.mutate({
      title: '새로운 tRPC 게시글',
      content: '정말 멋져요!',
    });
  };

  if (isLoading) return <div>로딩 중...</div>;
  if (error) return <div>에러 발생: {error.message}</div>;

  return (
    <div>
      <h1>게시글 목록</h1>
      <button onClick={handleCreatePost} disabled={createPost.isLoading}>
        {createPost.isLoading ? '생성 중...' : '게시글 생성'}
      </button>
      <ul>
        {posts?.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.content}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

이 코드에서 trpc.post.getAll.useQuery()를 입력하는 순간, 코드 에디터는 post라는 네임스페이스와 그 하위의 getAll, getById, create 프로시저를 자동으로 추천해 줍니다. createPost.mutate()의 인자로는 { title: string, content: string } 객체가 필요하다는 것을 타입스크립트가 정확히 알려줍니다. 이것이 바로 tRPC가 제공하는 마법 같은 개발 경험입니다.

4. tRPC 심화 탐구

tRPC는 기본적인 CRUD 외에도 강력하고 확장 가능한 기능들을 제공합니다.

  • 미들웨어 (Middleware): 프로시저가 실행되기 전후에 공통 로직을 실행할 수 있는 기능입니다. 마치 Express.js의 미들웨어와 유사합니다. 주로 사용자 인증, 로깅, 권한 검사 등에 사용됩니다. 예를 들어, 로그인한 사용자만 호출할 수 있는 protectedProcedure를 미들웨어를 통해 쉽게 만들 수 있습니다.

  • 에러 핸들링 (Error Handling): tRPC는 에러 처리와 포맷팅을 위한 내장 기능을 제공합니다. 백엔드에서 발생한 에러는 정해진 형식의 JSON으로 변환되어 클라이언트에 전달되며, 클라이언트는 이를 쉽게 파싱하여 처리할 수 있습니다.

  • 링크 (Links): 클라이언트의 요청-응답 흐름을 커스터마이징할 수 있는 강력한 기능입니다. 대표적인 예로 httpBatchLink가 있는데, 이는 짧은 시간 동안 발생하는 여러 tRPC 요청을 하나로 묶어(batching) 단일 HTTP 요청으로 보내는 역할을 합니다. 이를 통해 네트워크 부하를 줄이고 성능을 향상시킬 수 있습니다.

  • 구독 (Subscriptions): HTTP 외에 웹소켓(WebSocket)을 지원하여 실시간 양방향 통신이 가능합니다. 채팅, 실시간 알림, 협업 도구 등과 같은 기능을 구현할 때 유용합니다.

다른 기술과의 비교

특징tRPCREST API (+OpenAPI)GraphQL
타입 안정성엔드 투 엔드 자동 (코드 자체가 명세)수동/도구 의존적 (명세 파일과 코드 동기화 필요)강력한 타입 시스템 (스키마 기반)
개발 경험최상 (자동 완성, 타입 추론)보통 (문서 참조, 수동 타입 작성)좋음 (타입 생성 도구 활용)
설정/학습 곡선낮음 (타입스크립트 지식만 필요)낮음 (보편적인 HTTP 지식)높음 (스키마, 리졸버, 생태계 학습 필요)
네트워크HTTP (요청 배칭 가능)HTTPHTTP (주로 단일 엔드포인트 사용)
코드 생성필요 없음선택 사항 (명세로부터 클라이언트 생성)필수적 (스키마로부터 타입/클라이언트 생성)
주요 사용 사례풀스택 타입스크립트 앱, 모노레포, 사내 도구공개 API, 마이크로서비스, 이종 언어 환경다양한 클라이언트가 있는 공개 API, 데이터 요구사항이 복잡한 앱

5. 언제 tRPC를 사용해야 할까?

tRPC는 모든 상황에 맞는 만병통치약은 아닙니다. tRPC가 가장 빛을 발하는 환경은 다음과 같습니다.

  • 풀스택 타입스크립트 프로젝트: 프론트엔드와 백엔드 모두 타입스크립트로 개발되는 환경(예: Next.js, SvelteKit, Nuxt.js 등)에서 tRPC의 장점은 극대화됩니다.

  • 모노레포 (Monorepo): 프론트엔드와 백엔드 코드가 하나의 레포지토리에서 관리될 때, 타입 공유가 매우 간편해져 tRPC를 사용하기에 이상적인 환경이 됩니다.

  • 빠르게 프로토타이핑 및 개발해야 하는 스타트업/개인 프로젝트: API 명세를 작성하고 동기화하는 데 드는 시간을 절약하여 핵심 비즈니스 로직 개발에만 집중할 수 있습니다.

  • 사내 도구나 어드민 대시보드: 외부 공개가 필요 없고, 개발 속도와 안정성이 중요한 내부용 애플리케이션에 매우 적합합니다.

반면, 다음과 같은 상황에서는 tRPC가 최적의 선택이 아닐 수 있습니다.

  • 공개 API (Public API): 불특정 다수의 서드파티 개발자들이 사용해야 하는 API의 경우, 특정 언어(타입스크립트)에 종속되지 않는 REST나 GraphQL이 더 나은 선택입니다.

  • 프론트엔드와 백엔드가 다른 언어로 개발되는 경우: tRPC는 타입스크립트의 타입 시스템에 깊이 의존하므로, 백엔드가 Python, Java, Go 등으로 작성된 경우에는 사용할 수 없습니다.

결론: API 개발의 새로운 표준을 향하여

tRPC는 단순히 또 하나의 새로운 프레임워크가 아닙니다. 이는 타입스크립트가 가진 잠재력을 최대한으로 활용하여 API 개발 과정에서 발생하는 고질적인 문제들을 근본적으로 해결하려는 시도이며, 그 결과는 매우 성공적입니다.

API 명세와 구현의 불일치에서 오는 불안감, 반복적인 타입 코드 작성의 번거로움, 런타임에서야 발견되는 데이터 타입 에러의 공포로부터 개발자를 해방시켜 줍니다. 그 대신, 마치 하나의 애플리케이션을 개발하는 것처럼 프론트엔드와 백엔드가 매끄럽게 연결되는 놀라운 개발 경험을 선사합니다.

만약 당신이 타입스크립트로 풀스택 애플리케이션을 개발하고 있다면, tRPC는 더 이상 선택이 아닌 필수입니다. 지금 바로 당신의 프로젝트에 tRPC를 도입하여, API 개발의 스트레스는 줄이고 코딩의 즐거움은 배가시키는 혁신을 경험해 보시길 바랍니다.

레퍼런스(References)

tRPC