tRPC는 타입스크립트 기반 애플리케이션에서 서버와 클라이언트 간 타입을 공유해, 별도의 스키마 생성 없이도 끝까지 타입 안전한 API 통신을 제공하는 솔루션입니다. React와 결합하면 생산성과 안정성을 모두 확보할 수 있습니다. 이 글은 Express + Vite(React)로 tRPC v10 기반 타입 안전 API를 빠르게 구축하는 실무 가이드입니다.
1. 왜 tRPC인가
tRPC는 REST나 GraphQL처럼 스키마/스펙을 별도로 관리하지 않아도 됩니다. 서버에서 정의한 라우터와 입력/출력 타입을 클라이언트에서 그대로 사용하므로, 변경이 즉시 컴파일 타임에 반영됩니다. 덕분에 다음과 같은 이점을 얻습니다.
- 타입 불일치로 인한 런타임 에러 감소 - 개발 속도 향상 및 중복 정의 제거 - React Query와의 자연스러운 통합으로 캐싱/상태관리 간소화
2. 프로젝트 구조 및 설치
서버와 클라이언트를 폴더로 분리하되, 타입 공유를 위해 공통 타입을 서버에서 export하여 클라이언트가 import하는 구조를 사용합니다.
// 폴더 구조 예시 (설명용)
// server/
// trpc/
// index.ts (router, context, 타입 export)
// main.ts (Express 서버 진입점)
// client/
// src/
// trpc.ts (tRPC React 설정)
// App.tsx
// Todos.tsx
// 설치 패키지 (설명용)
// 서버: @trpc/server, zod, @trpc/server/adapters/express, express, cors, superjson
// 클라이언트: @trpc/client, @trpc/react-query, @tanstack/react-query, superjson, react, react-dom, vite
3. 서버: tRPC 라우터와 컨텍스트
zod로 입력 검증을 정의하고, 라우터를 구성합니다. 컨텍스트에는 요청별 리소스(예: DB, 사용자 정보)를 주입합니다.
// server/trpc/index.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import superjson from 'superjson';
// 간단한 인메모리 DB 예시
class InMemoryDB {
constructor() { this.items = []; this._id = 1; }
nextId() { return this._id++; }
save(todo) { this.items.push(todo); }
list() { return this.items; }
}
export type Context = {
db: InMemoryDB;
};
export function createContext(): Context {
return { db: new InMemoryDB() };
}
const t = initTRPC.context<Context>().create({
transformer: superjson,
});
const publicProcedure = t.procedure;
export const appRouter = t.router({
hello: publicProcedure
.input(z.object({ name: z.string().min(1) }))
.query(({ input }) => ({ message: `안녕하세요, ${input.name}!` })),
addTodo: publicProcedure
.input(z.object({ title: z.string(), done: z.boolean().optional() }))
.mutation(({ input, ctx }) => {
const todo = { id: ctx.db.nextId(), title: input.title, done: input.done ?? false };
ctx.db.save(todo);
return todo;
}),
listTodos: publicProcedure
.query(({ ctx }) => ctx.db.list()),
});
// 클라이언트에서 타입을 가져가 사용합니다.
export type AppRouter = typeof appRouter;
4. 서버: Express에 tRPC 연결
Express에 tRPC 미들웨어를 붙여 /trpc 엔드포인트로 노출합니다. CORS를 설정해 개발 중 다른 포트의 React 앱 접근을 허용합니다.
// server/main.ts
import express from 'express';
import cors from 'cors';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import { appRouter, createContext } from './trpc';
const app = express();
app.use(cors());
app.use('/trpc', createExpressMiddleware({
router: appRouter,
createContext,
}));
app.listen(4000, () => {
console.log('tRPC server on http://localhost:4000');
});
5. 클라이언트: tRPC React Query 설정
React에서 @trpc/react-query로 훅을 생성하고, httpBatchLink로 서버와 통신합니다. superjson을 transformer로 맞추면 Date 등 복잡 타입도 안전하게 전달됩니다.
// client/src/trpc.ts
import React from 'react';
import { createTRPCReact } from '@trpc/react-query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import type { AppRouter } from '../../server/trpc';
export const trpc = createTRPCReact<AppRouter>();
export function AppProviders({ children }: { children: React.ReactNode }) {
const [queryClient] = React.useState(() => new QueryClient());
const [trpcClient] = React.useState(() => trpc.createClient({
links: [
httpBatchLink({ url: 'http://localhost:4000/trpc' }),
],
transformer: superjson,
}));
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
6. React 컴포넌트에서 사용
서버 라우터의 프로시저가 그대로 클라이언트 훅으로 노출됩니다. useQuery/useMutation으로 간결하게 데이터 요청과 변경을 처리합니다.
// client/src/App.tsx
import React from 'react';
import { AppProviders } from './trpc';
import { Todos } from './Todos';
export default function App() {
return (
<AppProviders>
<Todos />
</AppProviders>
);
}
// client/src/Todos.tsx
import React from 'react';
import { trpc } from './trpc';
export function Todos() {
const { data: todos, isLoading, error } = trpc.listTodos.useQuery();
const utils = trpc.useUtils();
const addTodoMutation = trpc.addTodo.useMutation({
onSuccess: () => utils.listTodos.invalidate(),
});
const onAdd = () => addTodoMutation.mutate({ title: '새 할일' });
if (isLoading) return <p>로딩 중입니다...</p>;
if (error) return <p>에러: {error.message}</p>;
return (
<div>
<button onClick={onAdd}>추가</button>
<ul>
{todos?.map((t: any) => (
<li key={t.id}>{t.title} {t.done ? '✅' : ''}</li>
))}
</ul>
</div>
);
}
7. 타입 안전성 확인 포인트
서버에서 zod로 입력 스키마를 정의했기 때문에, 클라이언트에서 타입이 즉시 반영됩니다. 다음과 같은 오타/타입 오류는 컴파일 타임에 잡힙니다.
// 컴파일 에러 예시: title은 string이어야 합니다.
addTodoMutation.mutate({ title: 123 }); // ❌ TS 오류
// 존재하지 않는 필드 접근
// const x = trpc.unknownProc.useQuery(); // ❌ TS 오류 (정의되지 않은 프로시저)
8. 에러/변환 처리와 공통 응답
에러는 TRPCError를 통해 의미 있는 코드를 전달하고, 클라이언트에서는 error 객체로 접근합니다. superjson으로 Date/Map 같은 복합 타입을 안전하게 직렬화합니다.
// 에러 처리 예시 (서버)
import { TRPCError } from '@trpc/server';
// ...router 내부
secureData: publicProcedure
.query(({ ctx }) => {
const allowed = true; // 예시
if (!allowed) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '접근 불가' });
}
return { ok: true };
}),
9. 배포 및 최적화 팁
- httpBatchLink로 호출을 배치해 네트워크 효율을 높입니다. - React Query의 캐시 키는 프로시저 입력으로 안정적으로 생성되므로, invalidate를 적절히 사용합니다. - SSR/SSG가 필요한 경우 Next.js와 tRPC의 공식 예제를 참고하면 초기 데이터 프리패치가 쉽습니다. - 인증/권한은 컨텍스트에 사용자 정보를 주입하고, 미들웨어로 공통 보호 로직을 구성합니다.
10. 실무 체크리스트
- 서버/클라이언트 모두 TypeScript 엄격 모드 사용합니다. - 공통 타입(AppRouter)을 클라이언트로 안전하게 import합니다. - 입력 검증은 반드시 zod로 명시하고, 에러 메시지/코드를 표준화합니다. - superjson transformer를 서버/클라이언트 모두 동일하게 설정합니다. - 배포 환경에서 CORS/HTTPS/프록시 설정을 점검합니다.
이 구성으로 React + tRPC 환경을 적용하면, 스펙 문서 없이도 타입 안전성을 확보하고, 코드 변경이 즉시 클라이언트에 반영되는 개발 경험을 누릴 수 있습니다.
'React' 카테고리의 다른 글
| React 앱에서 Command Palette 인터페이스 구현하기 (1) | 2026.07.21 |
|---|---|
| React에서 브라우저 Geolocation API 활용하기 (0) | 2026.07.21 |
| React 앱에서 Feature Flag 시스템 설계 및 적용하기 (0) | 2026.07.16 |
| React에서 TanStack Table로 고성능 데이터 테이블 구현하기 (0) | 2026.07.15 |
| React 앱에서 주기적인 데이터 동기화 스케줄러 구축하기 (0) | 2026.07.15 |