2025-08-24 13:59
-
tRPC는 서버와 클라이언트 간의 API를 완벽하게 타입 안전하게 만들어주는 프레임워크입니다.
-
별도의 스키마 정의나 코드 생성 없이, TypeScript의 타입 추론 기능을 활용해 개발 경험을 극대화합니다.
-
풀스택 TypeScript 프로젝트에서 API 관련 런타임 에러를 원천적으로 방지하고 생산성을 높이는 최고의 선택입니다.
tRPC 완벽 핸드북 API 개발의 미래를 만나다
오늘날 웹 개발 환경은 그 어느 때보다 복잡하고 빠릅니다. 프론트엔드와 백엔드가 분리되면서 둘 사이의 원활한 데이터 통신, 즉 API의 중요성은 이루 말할 수 없이 커졌습니다. 우리는 REST, GraphQL 등 다양한 방식으로 API를 설계하고 사용해왔습니다. 하지만 이 방식들에는 항상 풀리지 않는 숙제가 있었습니다. 바로 **‘타입 안전성(Type Safety)‘**의 문제입니다.
백엔드 API 명세가 변경되었을 때, 프론트엔드 개발자는 그 사실을 어떻게 알 수 있을까요? 대부분은 런타임 환경에서 직접 에러를 마주하거나, 별도의 커뮤니케이션 채널을 통해 변경 사항을 전달받아야 했습니다. 이 과정에서 수많은 버그가 발생하고 개발 속도는 더뎌졌습니다.
이러한 문제를 해결하기 위해 등장한 것이 바로 **tRPC(TypeScript Remote Procedure Call)**입니다. tRPC는 “서버의 코드가 곧 API 명세서가 된다면 어떨까?”라는 혁신적인 아이디어에서 출발했습니다. 이 핸드북에서는 tRPC가 무엇이며, 어떤 문제를 해결하고, 어떻게 우리의 개발 경험을 송두리째 바꿀 수 있는지 깊이 있게 탐구해 보겠습니다.
1. tRPC의 탄생 배경 기존 API의 문제점
tRPC를 이해하기 위해서는 먼저 기존 API 방식들이 가졌던 근본적인 한계를 알아야 합니다.
REST API의 도전 과제
REST는 가장 보편적이고 유연한 API 아키텍처입니다. 하지만 이 유연함은 타입 안전성과는 거리가 멉니다.
-
타입의 단절: 서버는 JSON 데이터를 보내고, 클라이언트는 이 데이터의 형태를 ‘추측’하거나 수동으로 TypeScript 타입을 정의해야 합니다.
-
수동 동기화: 서버 API의 응답 데이터 구조가 바뀌면(예:
userName
필드가name
으로 변경), 클라이언트의 타입 정의도 수동으로 수정해야 합니다. 이를 잊으면 타입 에러 없이 애플리케이션이 배포되고, 런타임에서만 데이터가 제대로 표시되지 않는 끔찍한 버그가 발생합니다. -
문서 의존성: API의 구조를 파악하기 위해 Swagger나 OpenAPI 같은 별도의 문서화 도구에 의존해야 합니다. 문서는 코드와 항상 100% 일치한다고 보장할 수 없습니다.
GraphQL의 시도와 한계
GraphQL은 강력한 타입 시스템을 도입하여 REST의 많은 문제를 해결했습니다. 클라이언트는 필요한 데이터만 정확히 요청할 수 있고, 스키마 정의 언어(SDL)를 통해 API 구조가 명확하게 정의됩니다.
하지만 GraphQL 역시 완벽하지는 않았습니다.
-
복잡한 설정: GraphQL을 사용하려면 스키마, 리졸버, 타입 정의 등 설정해야 할 것이 많습니다.
-
코드 생성의 필요성: 스키마로부터 클라이언트에서 사용할 타입과 훅(hook)을 생성하기 위해
graphql-codegen
과 같은 별도의 도구와 빌드 과정이 필요합니다. 이 과정은 프로젝트의 복잡도를 높이는 요인이 됩니다. -
두 개의 진실 공급원(Source of Truth): 서버의 리졸버 로직과 별개로 스키마라는 또 다른 ‘진실’을 관리해야 하는 부담이 있습니다.
이러한 배경 속에서 tRPC는 “만약 별도의 스키마나 코드 생성 없이, 오직 TypeScript의 힘만으로 완벽한 타입 안전성을 구현할 수 있다면?”이라는 질문에 대한 해답으로 등장했습니다.
2. tRPC란 무엇인가?
tRPC는 종단 간(end-to-end) 타입 안전 API를 쉽게 구축할 수 있도록 설계된 가벼운 라이브러리입니다.
여기서 ‘종단 간 타입 안전’이라는 말이 핵심입니다. 서버에서부터 데이터베이스를 거쳐 클라이언트의 UI 컴포넌트에 이르기까지 데이터의 흐름 전체가 TypeScript에 의해 타입이 검사되고 보호된다는 의미입니다.
tRPC를 비유하자면, 서버 개발자가 작성한 함수를 클라이언트 개발자가 마치 자신의 프로젝트에 원래부터 있던 함수처럼 사용하는 경험과 같습니다. 클라이언트에서 서버 함수를 호출하면, Visual Studio Code와 같은 에디터는 해당 함수의 이름, 필요한 인자(argument)의 종류와 타입, 그리고 반환될 값의 타입을 모두 알고 있습니다. 덕분에 자동완성, 타입 검사, 리팩토링 등의 기능을 100% 활용할 수 있습니다.
tRPC의 핵심 원리
tRPC의 마법은 새로운 통신 프로토콜이나 복잡한 기술에 있는 것이 아닙니다. 오히려 매우 단순한 아이디어에 기반합니다.
-
하나의 진실 공급원 (Single Source of Truth): tRPC에서는 서버에 정의된 라우터(Router)가 API의 유일한 진실 공급원입니다. API의 모든 로직, 입력값 검증, 반환값 형태가 이 라우터 코드 안에 TypeScript로 정의됩니다.
-
타입 추론, وليس 코드 생성 (Type Inference, Not Code Generation): tRPC는 서버 라우터의 타입 정의(type definition)만을 클라이언트로 가져옵니다. 실제 서버 코드를 가져오는 것이 아니기 때문에 보안에 안전하며 매우 가볍습니다. 클라이언트의 tRPC 라이브러리는 이 타입 정보를 바탕으로 서버의 API와 똑같은 모양의 타입 안전한 클라이언트 객체를 ‘추론’해냅니다.
-
프로토콜에 얽매이지 않음 (Protocol Agnostic): 겉보기에는 마법 같지만, 내부적으로 tRPC는 단순한 HTTP 요청(GET/POST)을 사용합니다. 즉, 기존의 웹 기술과 완벽하게 호환되며, 네트워크 탭에서 모든 요청을 투명하게 확인할 수 있습니다. 실시간 통신이 필요할 경우 WebSockets도 지원합니다.
3. tRPC의 구조 파헤치기
tRPC 애플리케이션은 크게 세 부분으로 구성됩니다: 서버(라우터와 프로시저), 클라이언트, 그리고 이 둘을 잇는 연결 고리.
1. 서버: 라우터와 프로시저 (Router & Procedure)
서버는 tRPC의 심장입니다. 이곳에서 API의 모든 것을 정의합니다.
-
initTRPC
: tRPC 백엔드를 초기화하는 함수입니다. 여기서 컨텍스트(Context) 타입을 정의하고, 재사용 가능한 프로시저나 미들웨어를 만들 수 있습니다. -
Router
: API 엔드포인트들의 집합입니다. 여러 개의 작은 라우터를 만들어 하나로 합칠 수도 있어, 코드를 체계적으로 관리하기 용이합니다. -
Procedure
: 라우터 내에 정의되는 개별 API 엔드포인트입니다. 프로시저는 세 가지 종류로 나뉩니다.-
query
: 데이터 조회를 위한 프로시저입니다. HTTP GET 요청과 유사합니다. (예: 특정 게시물 가져오기) -
mutation
: 데이터 변경(생성, 수정, 삭제)을 위한 프로시저입니다. HTTP POST, PUT, DELETE 요청과 유사합니다. (예: 새 게시물 작성하기) -
subscription
: 실시간 연결을 통해 지속적으로 데이터를 주고받기 위한 프로시저입니다. WebSockets을 사용합니다. (예: 실시간 채팅 메시지 수신)
-
-
입력값 검증 (Input Validation): 클라이언트로부터 받은 입력값이 유효한지 검증하는 것은 매우 중요합니다. tRPC는 Zod와 같은 라이브러리와 환상적인 궁합을 자랑합니다. Zod 스키마를 사용해 입력값의 형태를 정의하면, tRPC는 자동으로 해당 타입을 추론하여 클라이언트에게까지 알려줍니다.
서버 코드 예시 (/server/routers/post.ts
)
TypeScript
import { z } from 'zod';
import { publicProcedure, router } from '../trpc';
export const postRouter = router({
// 모든 게시물 목록을 가져오는 쿼리
getAll: publicProcedure
.query(async ({ ctx }) => {
return await ctx.prisma.post.findMany();
}),
// ID로 특정 게시물을 가져오는 쿼리
getById: publicProcedure
.input(z.object({ id: z.string() })) // 입력값으로 id(string)를 받음
.query(async ({ ctx, input }) => {
return await ctx.prisma.post.findUnique({ where: { id: input.id } });
}),
// 새 게시물을 생성하는 뮤테이션
create: publicProcedure
.input(z.object({
title: z.string().min(1),
content: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const post = await ctx.prisma.post.create({ data: input });
return post;
}),
});
// 전체 앱 라우터
export const appRouter = router({
post: postRouter,
// ... 다른 라우터들
});
// 이 타입은 클라이언트에서 사용됩니다!
export type AppRouter = typeof appRouter;
2. 클라이언트: 타입 추론의 마법
클라이언트는 서버에서 정의한 AppRouter
의 타입만을 가져와 사용합니다.
TypeScript
// /client/utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../../server/routers/_app'; // 서버의 라우터 타입을 import
export const trpc = createTRPCReact<AppRouter>();
이 한 줄의 코드가 모든 것을 가능하게 합니다. createTRPCReact
함수는 AppRouter
타입을 보고, 서버에 있는 모든 라우터와 프로시저의 구조를 완벽하게 이해합니다. 그리고 trpc
라는 객체를 만들어내는데, 이 객체는 다음과 같은 마법 같은 기능을 제공합니다.
-
trpc.post.getAll.useQuery()
-
trpc.post.getById.useQuery({ id: 'some-id' })
-
trpc.post.create.useMutation()
React 환경에서는 @tanstack/react-query
와 통합되어 useQuery
, useMutation
과 같은 강력한 데이터 페칭 훅을 그대로 사용할 수 있습니다. 데이터를 가져오는 로딩 상태, 에러 상태, 캐싱, 데이터 재검증 등의 기능을 별도 설정 없이 바로 활용할 수 있습니다.
클라이언트 컴포넌트 예시 (/pages/index.tsx
)
TypeScript
import { trpc } from '../utils/trpc';
function PostList() {
// `getAll` 쿼리 호출. data의 타입은 Post[]로 완벽하게 추론됩니다.
const { data: posts, isLoading, error } = trpc.post.getAll.useQuery();
const createPost = trpc.post.create.useMutation();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
const handleCreatePost = () => {
// create.mutate를 호출할 때, input 객체의 타입을 모르면 에디터가 바로 알려줍니다.
createPost.mutate({
title: 'New Post from Client',
content: 'This is amazing!',
});
};
return (
<div>
<ul>
{posts?.map(post => (
// post 객체의 모든 속성(id, title, content)이 자동완성됩니다.
<li key={post.id}>{post.title}</li>
))}
</ul>
<button onClick={handleCreatePost}>Add Post</button>
</div>
);
}
4. tRPC 심화 탐구
tRPC의 기본 개념을 이해했다면, 이제 더 강력한 기능들을 살펴볼 차례입니다.
미들웨어 (Middleware)
미들웨어는 프로시저가 실행되기 전후에 공통 로직을 실행할 수 있게 해주는 강력한 기능입니다. 주로 인증, 로깅, 권한 검사 등에 사용됩니다.
예를 들어, 로그인한 사용자만 호출할 수 있는 ‘보호된 프로시저’를 미들웨어로 쉽게 만들 수 있습니다.
TypeScript
// /server/trpc.ts
// 사용자가 로그인했는지 확인하는 미들웨어
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
// `next`의 컨텍스트에 user 정보를 추가합니다.
session: { ...ctx.session, user: ctx.session.user },
},
});
});
// 미들웨어를 사용해 보호된 프로시저를 생성
export const protectedProcedure = t.procedure.use(isAuthed);
이제 이 protectedProcedure
를 사용해 만든 프로시저는 자동으로 로그인 여부를 검사하게 됩니다.
TypeScript
// /server/routers/user.ts
export const userRouter = router({
getSecretData: protectedProcedure
.query(({ ctx }) => {
// 이 프로시저는 로그인한 사용자만 접근 가능하며,
// ctx.session.user는 항상 존재함이 보장됩니다.
return {
secret: 'you can see this because you are logged in',
userEmail: ctx.session.user.email,
};
}),
});
라우터 병합 (Router Merging)
애플리케이션이 커지면 하나의 파일에 모든 라우터를 정의하기 어렵습니다. tRPC는 기능별로 분리된 라우터 파일들을 만들어 나중에 하나로 쉽게 병합하는 기능을 제공합니다.
TypeScript
// /server/routers/_app.ts
import { router } from '../trpc';
import { postRouter } from './post';
import { userRouter } from './user';
export const appRouter = router({
post: postRouter, // postRouter를 'post' 네임스페이스에 병합
user: userRouter, // userRouter를 'user' 네임스페이스에 병합
});
export type AppRouter = typeof appRouter;
이렇게 하면 클라이언트에서는 trpc.post.getAll.useQuery()
또는 trpc.user.getProfile.useQuery()
와 같이 체계적으로 API를 호출할 수 있습니다.
5. tRPC vs REST vs GraphQL 비교
각 API 방식은 저마다의 장단점과 최적의 사용 사례를 가집니다.
특징 (Feature) | tRPC | REST | GraphQL |
---|---|---|---|
타입 안전성 | ✅ 최상 (종단 간) | ❌ 없음 (수동 또는 도구 필요) | ✅ 강함 (스키마 기반) |
개발 경험 | ✅ 최상 (자동완성, 타입 추론) | ⚠️ 보통 (문서 확인 필요) | ✅ 좋음 (타입 생성 필요) |
스키마/코드 생성 | ❌ 필요 없음 | ⚠️ 선택 (OpenAPI 등) | ✅ 필수 (스키마 및 코드 생성) |
캐싱 | ✅ 쉬움 (React Query 통합) | ✅ 쉬움 (HTTP 표준 활용) | ⚠️ 복잡 (클라이언트 라이브러리 의존) |
학습 곡선 | ✅ 낮음 (TypeScript 함수 작성) | ✅ 매우 낮음 (HTTP 지식) | ⚠️ 높음 (스키마, 리졸버 등) |
최적 사용 사례 | 풀스택 TypeScript 모노레포 | 마이크로서비스, 공개 API | 다양한 클라이언트, 데이터 요구사항이 복잡할 때 |
6. 결론: 왜 tRPC를 선택해야 하는가?
tRPC는 모든 상황을 위한 만병통치약은 아닐 수 있습니다. 예를 들어, 여러 언어로 개발된 클라이언트가 사용하는 공개 API를 만든다면 REST나 GraphQL이 더 나은 선택일 수 있습니다.
하지만 풀스택 TypeScript 프로젝트, 특히 Next.js, SvelteKit, Nuxt 등과 함께 모노레포 환경에서 개발하고 있다면 tRPC는 다른 어떤 대안보다 압도적인 개발 경험과 안정성을 제공합니다.
-
버그의 원천 제거: 서버와 클라이언트 간의 타입 불일치로 인해 발생하는 런타임 에러를 컴파일 시점에 모두 잡아낼 수 있습니다.
-
폭발적인 생산성: API 문서를 찾아보거나, 수동으로 타입을 작성하거나, 코드 생성기를 기다릴 필요가 없습니다. 마치 하나의 애플리케이션을 개발하는 것처럼 서버와 클라이언트 코드를 넘나들며 작업할 수 있습니다.
-
자신감 있는 리팩토링: 서버 프로시저의 이름이나 반환 타입을 변경하면, TypeScript 컴파일러가 클라이언트 코드에서 수정이 필요한 모든 부분을 즉시 알려줍니다.
tRPC는 단순히 새로운 기술이 아니라, API 개발에 대한 우리의 생각을 근본적으로 바꾸는 패러다임의 전환입니다. 더 이상 서버와 클라이언트 사이의 불안한 다리를 건너지 마세요. tRPC가 제공하는 타입 안전성이라는 견고한 다리 위에서, 더 빠르고 안정적으로 위대한 제품을 만들어나가시길 바랍니다.