기존 REST API 방식의 문제점
많은 웹 프로젝트를 구성하다 보면 필연적으로 클라이언트-서버 간의 통신이 필요하고, 클라이언트 입장에서 이 과정을 해결하려면 fetch
부터 시작해서 axios
, @tanstack/react-query
등 여러 방식으로 컨트롤할 수 있습니다.
하지만 위 모든 과정의 공통점은 생각보다 반복 작업을 수행해야 한다는 점입니다.
여기서 말하는 반복 작업이란 정말 사소하지만 중요한 부분입니다. → 바로 엔드포인트 작성, 타입 정의 등이 될 수 있습니다.
사실 개발할 때 단순 JS가 아니라 TS로 진행하는 많은 이유 중 하나는 타입 추론을 통한 안정성과 개발 경험 향상입니다.
하지만 서버 응답은 별도의 처리가 없으면 타입 추론이 되지 않습니다.
당연한 이유가 서버 응답은 클라이언트 코드 기준 외부에서 들어오는 데이터이고, 이를 따로 알려주지 않으면 코드 입장에서는 이걸 알 방법이 없기 때문입니다.
const fetchTodoList = async () => {
const res = await fetch('/api/todo');
const data = await res.json(); // 여기서 data의 타입은 any
};
따라서 이를 해결하기 위해 보통 서버의 응답 타입을 공유받아(Swagger, Postman 등) 클라이언트에 별도의 타입을 정의하고 이를 추론 가능하게 만듭니다.
const fetchWrapper = async <T = unknown>(url: string, opt?: RequestInit) => {
const res = await fetch(url, opt);
const data = (await res.json()) as T;
return data; // T로 추론
};
const fetchTodoList = async () => {
const data = await fetchWrapper<string[]>('/api/todo');
// data의 타입은 string[]로 추론
};
마찬가지로 엔드포인트도 지금처럼 /api/todo
한 개면 문제가 안 되지만, 이게 수십 개~수백 개로 늘어나면 반복 작업 증가 및 실수 확률이 증가합니다.
const END_POINT = {
TODOS: '/api/todos',
TODO: (todoId: string) => `/api/todos/${todoId}`,
// ...
};
그리고 가장 핵심적인 문제는 여기서 런타임 에러가 발생할 가능성이 있다는 점입니다.
물론 실무에서는 백엔드의 응답 필드가 변경되기 전에 소통을 통해 클라이언트가 대응할 수 있도록 합니다.
또는 애초에 클라이언트가 백엔드와의 커플링을 느슨하게 해놓았을 수도 있습니다.
하지만 만약 서버 필드 중 하나가 삭제되었고, 이를 인지하지 못한 채로 서비스가 된다면 그 필드를 참조하는 곳에서 런타임 에러가 발생할 것입니다.
실전 비교: Before vs After
// Before (REST API)
// 1. 타입 정의 (수동)
interface Todo {
id: number;
text: string;
completed: boolean;
}
// 2. 엔드포인트 정의 (수동)
const API_URL = '/api/todos';
// 3. fetch 함수 작성 (수동)
const getTodos = async (): Promise<Todo[]> => {
const res = await fetch(API_URL);
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
};
// 4. 에러 처리 (수동)
try {
const todos = await getTodos();
} catch (error) {
// 에러 처리 로직
}
// After (tRPC)
const todos = await trpc.getTodos.query();
// tRPC -> 타입도 자동, 에러 처리도 내장
tRPC란?
만약 로컬 함수를 사용하듯이 API 함수를 사용할 수 있으면 어떻게 될까?
라는 의문점에서 시작합니다.
// 꿈꾸는 이상적인 모습
// Server
const todos = ['1', '2', '3'];
const getTodos = async () => {
return todos;
};
// Client
import { getTodos } from '@/server/todo';
const fetchTodos = async () => {
const todos = await getTodos();
return todos; // todos는 string[]로 자동 추론!
};
위처럼 서버에서 타입을 정의(또는 추론)하고 → 이를 통해 서비스 로직을 작성하고 → 클라이언트에서는 단순히 임포트해서 사용한다면 위에서 말한 문제가 대부분 해결됩니다.
실제로 tRPC
를 이용하면 이러한 효과를 얻을 수 있습니다.
// Server
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const todoSchema = z.object({
id: z.number(),
text: z.string(),
completed: z.boolean(),
});
export const appRouter = t.router({
getTodos: t.procedure.query(() => {
return todos; // Todo[] 타입이 자동으로 추론됨
}),
addTodo: t.procedure
.input(z.object({ text: z.string() }))
.mutation(({ input }) => {
const newTodo = {
id: Date.now(),
text: input.text,
completed: false,
};
todos.push(newTodo);
return newTodo;
}),
});
export type AppRouter = typeof appRouter;
// Client
import { createTRPCProxyClient } from '@trpc/client';
import type { AppRouter } from '../server/trpc';
const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpLink({
url: 'http://localhost:3000/api/trpc',
}),
],
});
async function TodoApp() {
const todos = await trpc.getTodos.query();
// ^^^^^ { id: number; text: string; completed: boolean }[]
const newTodo = await trpc.addTodo.mutate({ text: 'Learn tRPC' });
// ^^^^^^^ 타입이 자동으로 추론됨
}
그런데 이게 어떻게 가능한가?
"마법 같은데, 실제로 어떻게 동작하는 거지?"라는 의문이 드실 겁니다.
1. 컴파일 타임: TypeScript 타입 시스템 활용
// 서버에서 타입을 export
export type AppRouter = typeof appRouter;
// 클라이언트에서 타입만 import (런타임에 사라짐)
import type { AppRouter } from '../server/trpc';
TypeScript의 type
import는 컴파일 후 완전히 사라집니다. 즉, 런타임에는 아무것도 남지 않죠.
하지만 개발 중에는 완벽한 타입 추론과 자동완성을 제공합니다.
2. 런타임: 일반적인 HTTP 통신
실제로 브라우저 개발자 도구를 열어보면:
POST http://localhost:3000/api/trpc/getTodos
POST http://localhost:3000/api/trpc/addTodo
Request Body: {
"0": {
"json": {
"text": "Learn tRPC"
}
}
}
결국 런타임에는 일반적인 HTTP 요청이 날아갑니다. tRPC는 이를 자동으로 처리해줄 뿐이죠.
tRPC가 모든 상황에 완벽한 해결책은 아니지만, TypeScript 기반의 풀스택 프로젝트나 monorepo 환경에서는 정말 강력한 도구입니다. 특히 빠른 프로토타이핑이나 내부 관리 도구 개발 시 생산성을 크게 향상시킬 수 있습니다.
따라서 저는 해당 Blog
프로젝트를 tRPC
로 개선할 계획입니다.
이러한 개선이 이루어진 뒤 제가 직접 사용해보고 개선해본 내용도 다음 글에서 작성해볼 계획입니다.