
tRPC: API 명세 없이 풀스택 타입 안전성을 얻다
REST API를 만들 때마다 프론트엔드와 백엔드의 타입이 어긋났다. tRPC를 도입하고 API 명세서 없이 타입이 자동으로 맞춰지는 경험을 했다.

REST API를 만들 때마다 프론트엔드와 백엔드의 타입이 어긋났다. tRPC를 도입하고 API 명세서 없이 타입이 자동으로 맞춰지는 경험을 했다.
페이스북은 왜 REST API를 버렸을까? 원하는 데이터만 쏙쏙 골라 담는 GraphQL의 매력과 치명적인 단점 (캐싱, N+1 문제) 분석.

백엔드: 'API 다 만들었어요.' 프론트엔드: '어떻게 써요?' 이 지겨운 대화를 끝내주는 Swagger(OpenAPI)의 마법.

any를 쓰면 타입스크립트를 쓰는 의미가 없습니다. 제네릭(Generics)을 통해 유연하면서도 타입 안전성(Type Safety)을 모두 챙기는 방법을 정리합니다. infer, keyof 등 고급 기법 포함.

부모에서 전달한 props가 undefined로 나와서 앱이 크래시되는 문제 해결

작은 사이드 프로젝트를 혼자 만들고 있었다. 백엔드는 Express, 프론트엔드는 Next.js. 둘 다 TypeScript를 쓰고 있었고, 코드 구조도 꽤 깔끔하다고 생각했다.
어느 날 사용자 프로필 API를 리팩토링했다. user_name이라는 필드가 마음에 안 들어서 username으로 바꿨다. 백엔드에서 수정했다. 테스트도 통과했다. PR 머지하고 배포했다.
다음날 아침, Slack에 에러 알림이 쌓여 있었다.
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
프론트엔드 코드에서 user.user_name.toUpperCase()를 호출하고 있었다. 백엔드가 username을 내려주고 있으니, 프론트에서 user_name은 undefined였다. 필드명 하나 바꿨을 뿐인데, 프로덕션 사용자들이 프로필 페이지를 못 보고 있었다.
TypeScript를 쓰고 있었는데, 왜 컴파일 에러가 안 났냐고?
백엔드의 User 타입과 프론트엔드의 User 타입이 별개의 파일에 따로 정의되어 있었기 때문이다. 백엔드에서 타입을 바꿔도, 프론트엔드의 타입 정의를 손으로 직접 바꾸지 않으면 TypeScript는 알 방법이 없다. 두 타입이 서로 다른 세계에 살고 있었다.
이 사건 이후로 "백엔드와 프론트엔드가 타입을 진짜로 공유할 수는 없을까"를 찾기 시작했다. 그때 tRPC를 만났다.
REST API는 HTTP 요청과 JSON 응답으로 통신한다. 이 자체는 문제가 없다. 문제는 타입 정보가 HTTP를 넘어가지 않는다는 것이다.
백엔드에서 이런 함수를 만들었다고 하자.
// 백엔드 (Express)
interface User {
id: string;
username: string; // user_name → username으로 바꿨다
email: string;
createdAt: Date;
}
app.get('/api/user/:id', async (req, res) => {
const user: User = await db.users.findById(req.params.id);
res.json(user);
});
그리고 프론트엔드에서 이렇게 부른다.
// 프론트엔드 (Next.js) - 별개의 파일에 타입을 복사해놓음
interface User {
id: string;
user_name: string; // 여기는 아직 옛날 이름
email: string;
createdAt: string; // Date가 JSON을 거치면 string이 됨
}
const response = await fetch(`/api/user/${userId}`);
const user: User = await response.json(); // 이 타입 단언은 거짓말이다
fetch().json()의 반환 타입은 any다. 거기에 as User로 타입을 단언하는 순간, TypeScript는 그게 진짜인지 확인하지 않는다. 그냥 믿는다. 그리고 백엔드가 다른 구조를 내려보내면, 런타임에 터진다.
이걸 비유하면 국제 우편으로 편지를 주고받는 것과 같다. 한국에서 편지를 쓰고 미국에 보낸다. 미국에서 편지를 받는 사람은 "이 편지에 뭐가 적혀 있겠지"라고 추측해서 읽는다. 편지를 쓴 사람이 내용 형식을 바꿨는데 읽는 사람한테 알리지 않으면, 오해가 생긴다.
REST API에서 API 명세서(OpenAPI 스펙 등)가 이 편지 양식 합의 역할을 한다. 그런데 명세서도 결국 사람이 손으로 관리해야 한다. 백엔드 코드가 바뀌면 명세서를 업데이트해야 하고, 프론트엔드 타입도 업데이트해야 한다. 세 곳이 항상 동기화되어 있어야 한다. 혼자 만드는 프로젝트에서도 이걸 빠뜨리기 쉬운데, 팀이 되면 더 어렵다.
tRPC는 TypeScript RPC(Remote Procedure Call) 프레임워크다. 핵심 아이디어는 단순하다.
백엔드의 TypeScript 타입을 프론트엔드에서 그대로 쓸 수 있게 한다.
HTTP 요청과 JSON 응답은 여전히 일어난다. 하지만 그 위에 TypeScript의 타입 추론이 흐른다. 백엔드에서 라우터를 정의하면, 그 타입이 자동으로 프론트엔드 클라이언트에 적용된다. API 명세서가 필요 없다. 코드젠(codegen)도 필요 없다. 타입이 살아있는 소스 코드에서 바로 흘러간다.
이걸 비유하면 내선 직통 전화와 같다.
기존 REST 방식은 부서 간 업무 연락을 공문서로 주고받는 것이다. 요청 형식, 응답 형식을 문서로 합의하고, 문서대로 보내고, 문서대로 받는다. 문서 형식이 바뀌면 공문서를 다시 발행해야 하는데, 그 사실을 상대 부서에 알리는 걸 잊으면 혼선이 생긴다.
tRPC는 내선 직통 전화다. 프론트엔드 개발자가 백엔드 함수를 직접 호출하는 것처럼 코드를 작성한다. TypeScript가 "이 함수는 어떤 인자를 받고, 무엇을 반환하는지" 실시간으로 알려준다. 백엔드 함수 시그니처가 바뀌면, 프론트엔드 코드에서 즉시 빨간 밑줄이 생긴다. 배포 전에 컴파일 타임에 잡힌다.
실제 코드로 살펴보자. Next.js 앱에 tRPC를 붙이는 가장 기본적인 형태다.
// src/server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// src/server/routers/user.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const userRouter = router({
// query: 데이터를 읽는 프로시저 (GET과 비슷)
getById: publicProcedure
.input(z.object({ id: z.string() })) // Zod로 입력 타입 정의
.query(async ({ input }) => {
const user = await db.users.findById(input.id);
// 반환 타입이 자동으로 추론됨
return {
id: user.id,
username: user.username, // 이걸 바꾸면 프론트에서 바로 에러
email: user.email,
createdAt: user.createdAt,
};
}),
// mutation: 데이터를 변경하는 프로시저 (POST/PUT/DELETE와 비슷)
updateUsername: publicProcedure
.input(z.object({
id: z.string(),
username: z.string().min(3).max(20),
}))
.mutation(async ({ input }) => {
const updated = await db.users.update(input.id, {
username: input.username,
});
return updated;
}),
});
// src/server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
export const appRouter = router({
user: userRouter,
});
// 이 타입을 export해서 프론트엔드에서 import함
export type AppRouter = typeof appRouter;
// src/lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';
// AppRouter 타입이 여기서 import됨
export const trpc = createTRPCReact<AppRouter>();
// src/components/UserProfile.tsx
import { trpc } from '@/lib/trpc';
function UserProfile({ userId }: { userId: string }) {
// 여기서 타입 자동완성이 된다
// trpc.user.getById.useQuery() — user, getById 다 자동완성
const { data: user, isLoading } = trpc.user.getById.useQuery({ id: userId });
if (isLoading) return <div>로딩 중...</div>;
if (!user) return null;
// user.username — 타입이 정확히 추론됨
// user.user_name 이라고 쓰면 컴파일 에러 발생
return (
<div>
<h2>{user.username}</h2>
<p>{user.email}</p>
</div>
);
}
// mutation 사용
function UpdateUsernameForm({ userId }: { userId: string }) {
const updateUsername = trpc.user.updateUsername.useMutation();
const handleSubmit = (newUsername: string) => {
updateUsername.mutate({
id: userId,
username: newUsername,
// 잘못된 필드를 넣으면 여기서 바로 에러
});
};
return (/* ... */);
}
이제 백엔드에서 username을 displayName으로 바꾸면 어떻게 될까?
UserProfile 컴포넌트의 user.username에 즉시 빨간 줄이 생긴다. Property 'username' does not exist on type '...'. 배포 전에, 심지어 브라우저를 열기도 전에 잡힌다.
그게 tRPC의 전부다. 간단한데, 효과가 크다.
tRPC를 쓰기 전에 고민했던 부분이 있다. "그냥 GraphQL 쓰면 되는 거 아닌가?"
정리해본다.
| 비교 항목 | REST | GraphQL | tRPC |
|---|---|---|---|
| 타입 안전성 | 수동 관리 | 코드젠 필요 | 자동 (TypeScript 추론) |
| 학습 곡선 | 낮음 | 높음 | 중간 |
| 생태계 | 매우 넓음 | 넓음 | 성장 중 |
| 외부 API 지원 | 완벽 | 완벽 | 불가 |
| 팀 규모 | 모든 규모 | 중~대형 팀 | 소형 팀 / 모노레포 |
| 런타임 유효성 검사 | 직접 구현 | 직접 구현 | Zod 통합 |
| 오버헤드 | 없음 | 중간 | 낮음 |
REST의 문제는 타입 안전성이 없다는 것이다. OpenAPI + codegen으로 어느 정도 해결할 수 있지만, 세팅이 복잡하고 명세서를 별도로 관리해야 한다.
GraphQL의 장점은 클라이언트가 필요한 데이터만 쿼리할 수 있다는 것이다. 대형 팀에서 여러 팀이 같은 API를 다른 방식으로 소비해야 할 때 빛난다. 단점은 학습 곡선이 높고, 스키마 정의 + 리졸버 + codegen 세팅이 복잡하다. 혼자 만드는 프로젝트에서 GraphQL을 도입하면 배보다 배꼽이 큰 상황이 된다.
tRPC의 핵심 제약은 TypeScript 모노레포에서만 제대로 동작한다는 것이다. 백엔드와 프론트엔드가 같은 레포에 있어야 AppRouter 타입을 import할 수 있다. 외부에 API를 공개해야 하거나, 프론트엔드가 TypeScript가 아닌 팀이라면 tRPC는 선택지가 아니다.
하지만 솔로 개발자나 작은 팀이 Next.js 풀스택으로 빠르게 만들고 있다면? tRPC가 가장 빠르고 안전한 선택이다.
직접 써보면서 정리한 판단 기준이다.
tRPC가 맞는 상황:REST에서 tRPC로 마이그레이션하는 전략은 점진적으로 가는 게 현실적이다. 기존 REST 엔드포인트를 그대로 두고, 새로운 기능부터 tRPC로 만들기 시작한다. Next.js에서는 /api/trpc/[trpc] 엔드포인트 하나만 추가하면 기존 /api/* 라우트와 공존할 수 있다. 혼재 기간이 있어도 충분히 괜찮다. 타입 안전성이 필요한 곳부터 하나씩 옮기면 된다.
tRPC를 쓰면서 Zod의 가치를 제대로 이해했다. tRPC는 Zod와 함께 쓰도록 설계되어 있다. 프로시저에 .input(z.object({...}))로 입력 스키마를 정의하면 두 가지가 동시에 해결된다.
첫째, 컴파일 타임 타입 체크. TypeScript가 잘못된 입력을 넘기려 하면 에러가 난다.
둘째, 런타임 유효성 검사. 실제 HTTP 요청이 들어올 때 Zod가 입력값을 검증한다. 악의적인 요청이나 잘못 형성된 데이터는 핸들러 함수에 도달하기 전에 걸러진다.
이게 중요한 이유가 있다. TypeScript의 타입은 컴파일하면 사라진다. 실제 런타임에는 타입 정보가 없다. string이라고 타입을 정의해도, 외부에서 숫자를 보내면 JavaScript는 그걸 그냥 받는다. Zod는 런타임에도 실제로 검증을 수행한다. TypeScript 타입과 Zod 스키마가 같은 곳에 정의되어 있으니, 두 레이어의 보호를 동시에 얻는다.
이 조합을 비유하면 공항 보안 검색대와 비자 심사와 같다. TypeScript는 비자 심사다. 여행 계획 단계(컴파일 타임)에서 이상한 점을 잡아낸다. Zod는 공항 보안 검색대다. 실제로 비행기에 타기 전(런타임)에 현장에서 한 번 더 검증한다. 하나만 있어도 어느 정도 안전하지만, 둘 다 있으면 훨씬 안심된다.
REST API의 타입 불일치 문제는 구조적이다. 백엔드와 프론트엔드의 타입 정의가 분리되어 있는 한, 한 쪽이 바뀌면 다른 쪽에서 런타임 에러가 난다. TypeScript를 쓴다고 저절로 해결되지 않는다.
tRPC는 AppRouter 타입 하나를 공유한다. 백엔드 라우터의 타입을 프론트엔드 클라이언트가 import해서, 양쪽이 동일한 타입 정보를 기반으로 동작한다.
백엔드가 바뀌면 프론트엔드에서 컴파일 에러가 난다. 런타임이 아니라 컴파일 타임에 잡힌다. 프로덕션 배포 전에 문제를 알 수 있다.
tRPC는 TypeScript 모노레포에서만 동작한다. 외부 API를 공개해야 하거나, 비TypeScript 클라이언트가 있다면 맞지 않는다. 솔로/소규모 풀스택 팀에 최적화되어 있다.
Zod와 함께 쓰면 컴파일 타임 + 런타임 유효성 검사를 동시에 얻는다. API 명세서 없이도, 입력값이 항상 기대하는 형태임을 보장할 수 있다.
프로덕션 버그 하나가 방향을 바꿨다. 필드명 하나 바꿨다가 아침에 에러 알림을 받은 이후로, API를 어떻게 설계할지에 대한 생각이 달라졌다. tRPC가 모든 상황에 맞는 정답은 아니다. 하지만 혼자, 또는 작은 팀이 TypeScript 풀스택을 빠르게 만들 때만큼은 꽤 좋은 도구다.