본문 바로가기

React

React와 tRPC를 활용한 타입 안전 API 통신 구축하기

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 환경을 적용하면, 스펙 문서 없이도 타입 안전성을 확보하고, 코드 변경이 즉시 클라이언트에 반영되는 개발 경험을 누릴 수 있습니다.