tRPC로 서버·클라이언트 타입 공유하기: Router·Procedure, 미들웨어, React Query, App Router, Zod

이 글의 핵심

백엔드가 DTO를 정의하고 프론트엔드가 interface로 옮겨 적는 방식에서는 필드 하나가 바뀌어도 런타임이나 프로덕션에서야 문제가 드러나곤 합니다. tRPC로 서버에 procedure를 추가하는 순간 클라이언트 호출 타입이 함께 바뀌는 구조를 만들고, 인증 로직을 미들웨어로 분리하는 방법까지 실무 흐름으로 보여줍니다.

타입이 끊기는 지점에서 시작된 이야기

과거에는 백엔드가 DTO를 정의하고, 프론트엔드가 그 구조를 interface로 옮겨 적고, OpenAPI 문서는 대략 맞춰두는 식의 작업 흐름이 흔했습니다. 이런 구조에서는 런타임에서야 문제가 드러나는 경우가 많았습니다. 한쪽 팀이 필드 하나를 바꾸면 다른 팀은 문서를 뒤져보거나, 최악의 경우 프로덕션에서 처음 그 변경을 알아차리게 됩니다. 타입이 있는 것처럼 보이지만 실제로는 서버와 클라이언트 사이에서 연결이 끊겨 있는 상태입니다.

tRPC를 도입하면 이 흐름이 달라집니다. 서버에 procedure를 하나 추가하는 순간, 그 시그니처가 클라이언트의 trpc.* 호출 전체에 그대로 반영됩니다. 네트워크를 오가는 것은 여전히 JSON이지만, 그 전 단계에서 TypeScript 컴파일러가 “이 필드가 실제로 존재하는가”를 함께 검증해 주는 셈입니다. 풀스택이 전부 TypeScript로 구성되어 있고(모노레포든, 하나의 저장소에 프론트엔드와 API를 함께 두는 구조든) 공개 API가 아니라면, tRPC는 기본적으로 고려해볼 만한 선택지입니다. REST나 GraphQL이 열등하다는 의미가 아니라, 내부 제품에서는 스펙을 동기화하는 비용을 굳이 두 번 지불할 필요가 없다는 뜻입니다.

이 글에서는 Router와 Procedure, Context와 미들웨어, React Query 연동, Next.js App Router 통합, WebSocket과 subscription, 그리고 Zod와 TRPCError를 이용한 검증·에러 처리까지 실무에서 자주 막히는 지점을 중심으로 정리합니다.


tRPC가 해결하는 문제

API 계층에서 타입이 겉도는 문제

REST는 익숙하고 검증된 방식입니다. 다만 “타입을 공유한다”고 말하려면 실제로는 코드 생성기, 수동으로 작성한 DTO, 또는 팀 내부 규칙에 의존하게 됩니다. 백엔드는 이미 변경되었는데 프론트엔드 타입은 예전 상태 그대로인 상황을 한 번이라도 겪고 나면, 문서보다 컴파일러(tsc)를 더 신뢰하게 됩니다.

tRPC는 서버 라우터의 타입을 그대로 클라이언트의 메서드 체인으로 연결합니다. 경로, 인자, 반환값이 하나의 타입 그래프로 이어집니다. 이 방식의 장점은 코드 생성 단계를 별도로 돌리지 않아도 된다는 점입니다(필요하다면 여전히 엄격한 검증 절차를 유지할 수도 있습니다).

프로시저 트리 구조

URL 패턴이 계속 늘어나는 대신 router와 procedure로 구조를 묶으면, trpc.user.list.useQuery() 같은 호출에서 이름, 인자, 반환 타입까지 IDE가 전부 추론해줍니다. 신규 합류자가 “어떤 엔드포인트를 써야 하지?”를 찾아 헤매는 시간이 크게 줄어듭니다. 팀 규모가 클수록 체감 효과도 커집니다.

Zod와의 조합이 잘 맞는 이유

TypeScript 타입은 컴파일 시점에만 존재하지만, 네트워크를 통해 오가는 데이터는 런타임에 검증해야 합니다. 이 경계에 Zod를 얹는 패턴이 널리 쓰입니다. 같은 스키마로 파싱과 타입 추론을 동시에 처리할 수 있기 때문에, API 검증 라이브러리 조사에서 tRPC와 Zod 조합이 자주 언급되는 이유를 실제로 써보면 이해하게 됩니다.


initTRPC, Router, Procedure

initTRPC로 진입점 만들기

서버에서는 initTRPC로 인스턴스를 만들고, 그 위에 router, procedure, 미들웨어를 쌓아 올립니다. Context 타입을 한 번 연결해두면 이후 모든 곳에서 ctx가 정확하게 추론됩니다.

// server/trpc.ts
import { initTRPC } from '@trpc/server';
import type { Context } from './context';

const t = initTRPC.context<Context>().create();

export const router = t.router;
export const mergeRouters = t.mergeRouters;
export const middleware = t.middleware;
export const publicProcedure = t.procedure;

Context를 지정하지 않으면 ctx가 비어 있는 상태로 추론되어 활용도가 떨어집니다. 세션, DB 커넥션, 요청 ID 등을 한곳에서만 채워 넣는 습관을 들여두면 이후 보안 검토나 로그 추적이 훨씬 수월해집니다. 이 부분을 소홀히 했다가 protectedProcedure와 publicProcedure를 섞어 쓰는 과정에서 문제를 겪은 경험이 있습니다.

Router: 나누고 다시 합치기

user, post 라우터를 각각 나눠서 작성한 뒤 루트에서 병합하고, 클라이언트에는 AppRouter 타입 하나만 export합니다. 클라이언트는 이 타입 하나만 import하면 됩니다. 경로 오타가 런타임이 아니라 빌드 단계에서 걸리는 경험은 기존 REST 방식과는 확실히 다릅니다.

// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';

export const userRouter = router({
  list: publicProcedure.query(async ({ ctx }) => {
    return await ctx.db.user.findMany();
  }),
  byId: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ ctx, input }) => {
      return await ctx.db.user.findUnique({ where: { id: input.id } });
    }),
});
// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';

export const appRouter = router({
  user: userRouter,
  post: postRouter,
});

export type AppRouter = typeof appRouter;

query / mutation / subscription

  • query: 읽기 작업이며, 캐시 단위로 세밀하게 나눌 수 있습니다.
  • mutation: 실행 후 invalidate나 낙관적 업데이트로 이어지는 경우가 많습니다.
  • subscription: 실시간 기능을 구현하려면 WebSocket 인프라까지 함께 고려해야 하므로, 운영 비용을 먼저 짚고 넘어가는 편을 권장합니다(6절 참고).

.input을 지정하지 않으면 인자는 사실상 void로 취급되고, 반환값은 핸들러 로직에서 자동으로 추론됩니다. 입력값에는 Zod 검증을 반드시 적용하되, 반환값까지 전부 Zod 스키마로 감쌀지는 비용 대비 효과를 팀 상황에 맞게 판단하는 것이 좋습니다.


Context와 미들웨어

Context

Context는 요청마다 한 번씩 생성됩니다. 쿠키에서 세션을 읽어오고 DB 커넥션을 주입하는 로직이 여기 들어갑니다. 어댑터마다 req나 headers의 형태가 조금씩 다르기 때문에, 한 프로젝트 안에서는 이 패턴을 통일하는 것이 무엇보다 중요합니다. 여기서 방향이 흔들리면 createContext 디버깅에만 하루를 소모하는 상황이 벌어질 수 있습니다.

// server/context.ts
import type { inferAsyncReturnType } from '@trpc/server';

export async function createContext(opts: { headers: Headers }) {
  const session = await getSessionFromCookie(opts.headers);
  return {
    session,
    db,
  };
}

export type Context = inferAsyncReturnType<typeof createContext>;

미들웨어로 인증 로직 분리하기

인증되지 않은 요청은 TRPCError로 즉시 차단하고, 인증된 요청은 next를 통해 user 정보를 ctx에 좁혀서 전달하는 패턴이 가장 깔끔합니다.

// server/trpc.ts (일부)
import { TRPCError, initTRPC } from '@trpc/server';
import type { Context } from './context';

const t = initTRPC.context<Context>().create();

const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      session: ctx.session,
      user: ctx.session.user,
    },
  });
});

export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);

protectedProcedure를 통과한 이후의 procedure에서는 user가 더 이상 optional로 취급되지 않으므로, 널 체크를 반복하지 않고 바로 사용할 수 있습니다.

미들웨어 실행 순서

.use(a).use(b)처럼 미들웨어를 여러 개 체이닝할 때 어느 것이 먼저 실행되는지 헷갈리면, 로그가 두 번 찍히거나 인증 검사가 중복 실행되는 문제가 생길 수 있습니다. 레이트 리밋이나 감사 로그처럼 요청 전반에 걸친 로직은 바깥쪽에, 인증·권한 검사는 실제 비즈니스 로직 직전에 배치하는 순서가 디버깅에 유리했습니다.


TanStack React Query와의 연동

tRPC의 React 통합 계층은 내부적으로 React Query를 사용하므로, 캐시 관리, 리페치, 낙관적 업데이트를 타입 안정성을 유지한 채로 사용할 수 있습니다.

createTRPCReact

// lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';

export const trpc = createTRPCReact<AppRouter>();

Provider 구성

QueryClient와 tRPC client를 함께 감싸주면 됩니다. SSR/CSR 전략에 맞춰 staleTime만 상황에 맞게 조절하면 됩니다.

// providers/trpc-provider.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from '@/lib/trpc';
import superjson from 'superjson';

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
          transformer: superjson,
        }),
      ],
    }),
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
}

Date나 Map 같은 직렬화 불가능한 타입을 다룬다면 서버와 클라이언트 양쪽에서 superjson 설정을 반드시 맞춰야 합니다. 한쪽에서만 설정을 켜두면 필드가 이유 없이 undefined로 보이는 문제가 반복해서 발생합니다.

캐시 무효화

const utils = trpc.useUtils();

const create = trpc.post.create.useMutation({
  onSuccess: async () => {
    await utils.post.list.invalidate();
  },
});

useUtils는 AppRouter 구조를 그대로 알고 있으므로, invalidate 대상 경로를 잘못 지정하는 실수가 줄어듭니다. 문자열 기반 queryKey를 팀마다 다르게 관리하다가 겪는 혼란을 생각하면, 이 부분만으로도 tRPC를 채택할 이유가 충분하다고 판단합니다.


Next.js App Router 통합

과거에는 createNextApiHandler를 사용했지만, 최근에는 fetch 어댑터를 이용해 GET과 POST를 하나의 route 파일에서 처리하는 방식이 일반적입니다.

Route Handler

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/context';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    router: appRouter,
    req,
    createContext: () => createContext({ headers: req.headers }),
  });

export { handler as GET, handler as POST };

레이아웃에 Provider 연결하기

// app/layout.tsx
import { TRPCProvider } from '@/providers/trpc-provider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <TRPCProvider>{children}</TRPCProvider>
      </body>
    </html>
  );
}

RSC와 클라이언트 컴포넌트의 역할 분담

React Server Component에는 훅을 사용할 수 없으므로, 서버 쪽에서는 createCaller나 별도의 서비스 레이어를 통해 직접 호출하고, useQuery가 필요한 상호작용은 클라이언트 컴포넌트에 맡기는 구조가 일반적입니다. 이 역할 분담은 팀마다 방식이 다르지만, 하나의 API를 RSC와 클라이언트 양쪽에서 동시에 사용한다면 캐시 일관성을 미리 합의해두어야 합니다. 이 부분을 명확히 하지 않으면 나중에 캐시가 서로 다른 시점의 데이터를 보여주는 문제로 이어질 수 있습니다.


WebSocket과 subscription

채팅이나 진행률 표시처럼 진짜 실시간성이 필요한 기능에는 subscription과 WebSocket을 사용하고, 나머지는 일반 HTTP로 처리하는 것이 일반적입니다. 클라이언트에서는 splitLink로 두 경로를 분기하는 구조가 흔합니다. 다만 tRPC 자체보다 먼저 WebSocket 인프라(스티키 세션, 인증 토큰 전달, 타임아웃 처리)를 설계해야 하며, 실무에서는 이 부분에서 더 많은 시간이 소요되는 경우가 많습니다.

서버: observable

import { observable } from '@trpc/server/observable';
import { z } from 'zod';
import { publicProcedure, router } from '../trpc';

export const realtimeRouter = router({
  onMessage: publicProcedure
    .input(z.object({ channelId: z.string() }))
    .subscription(({ input }) => {
      return observable<{ text: string }>((emit) => {
        const handler = (payload: { text: string }) => emit.next(payload);
        messageBus.subscribe(input.channelId, handler);
        return () => messageBus.unsubscribe(input.channelId, handler);
      });
    }),
});

observable 콜백이 반환하는 함수는 구독 해제 시 호출됩니다. 이 정리(cleanup) 로직을 빠뜨리면 리스너가 계속 누적되어 메모리 누수로 이어질 수 있으므로 반드시 구현해야 합니다.

클라이언트: WebSocket 링크

import { createWSClient, wsLink } from '@trpc/client';
import { splitLink, httpBatchLink } from '@trpc/client';

const wsClient = createWSClient({ url: 'wss://api.example.com/trpc' });

const link = splitLink({
  condition: (op) => op.type === 'subscription',
  true: wsLink({ client: wsClient }),
  false: httpBatchLink({ url: '/api/trpc' }),
});

개발 초기에는 HTTP 폴링이나 SSE로 버티다가, 실제로 실시간성이 꼭 필요해지는 시점에 WebSocket으로 전환하는 팀도 많습니다. 처음부터 WebSocket 인프라를 구축하는 것보다 점진적으로 도입하는 편이 리스크를 줄이는 데 도움이 됩니다.


에러 처리와 Zod 검증

TRPCError

UNAUTHORIZED, NOT_FOUND 같은 표준 코드를 사용하면 클라이언트가 error 객체를 기준으로 UI 분기를 처리하기 쉬워집니다.

import { TRPCError } from '@trpc/server';

throw new TRPCError({
  code: 'NOT_FOUND',
  message: '사용자를 찾을 수 없습니다.',
});

.input에 Zod 스키마 적용하기

const createPostInput = z.object({
  title: z.string().min(1, '제목은 필수입니다.'),
  body: z.string().max(50_000),
});

export const postRouter = router({
  create: publicProcedure.input(createPostInput).mutation(async ({ ctx, input }) => {
    return await ctx.db.post.create({ data: input });
  }),
});

폼 에러 매핑

Zod가 발생시키는 issue를 그대로 클라이언트에 전달할지, 아니면 TRPCError의 cause에 요약해서 담을지는 팀 내부 규약으로 정해두어야 합니다. 규약 없이 각 개발자가 onError 핸들러에서 개별적으로 파싱하기 시작하면, 프로젝트가 커질수록 에러 처리 방식이 제각각으로 흩어지는 문제가 발생합니다.


운영 단계에서 점검할 사항

  • tRPC와 클라이언트 버전을 함께 관리하기: 버전이 미묘하게 어긋나면 원인을 찾기 어려운 문제가 생길 수 있으므로, 업그레이드는 가급적 동시에 진행합니다.
  • httpBatchLink의 배치 특성 이해하기: 왕복 시간(RTT)은 줄어들지만, 여러 요청이 하나로 묶여 전송되므로 개발 환경에서는 디버깅을 위해 배치를 끄거나 별도 설정을 두는 것이 편합니다.
  • 미들웨어에 request ID와 user ID 심어두기: 나중에 분산 트레이싱이나 로그 추적을 연결할 때 큰 도움이 됩니다.
  • procedure마다 권한 범위를 명시하기: publicProcedure와 protectedProcedure를 구분해서 사용하는 습관을 팀 전체에 정착시켜야 합니다.

tRPC를 고를 때와 REST·GraphQL로 돌아갈 때

tRPC의 이점은 프론트엔드와 백엔드가 같은 저장소에서 TypeScript로 작성될 때 가장 큽니다. 서버 프로시저의 입력·출력 타입이 클라이언트에 그대로 전달되므로, REST 스펙 문서를 따로 맞추던 수고가 사라집니다. 반대로 공개 API를 제공하거나, 다른 언어로 된 클라이언트나 모바일 네이티브 앱이 같은 API를 써야 한다면 타입 공유라는 장점이 사라지므로 그 시점에 REST나 GraphQL 도입을 다시 논의하는 순서가 현실적입니다.


자주 묻는 질문 (FAQ)

Q. REST와 tRPC를 한 프로젝트에서 함께 쓸 수 있나요? A. 점진적으로 도입하는 경우가 많습니다. 다만 캐시 전략, 인증 방식, 에러 처리 스타일이 이원화되므로, apiRest와 trpc처럼 경계를 명확히 나눈 모듈 구조를 권장합니다.

Q. 다른 언어로 작성된 클라이언트도 붙일 수 있나요? A. tRPC는 TypeScript 생태계에 최적화된 도구입니다. 공개 API이거나 TypeScript가 아닌 클라이언트를 지원해야 한다면 REST나 GraphQL 같은 언어 중립적인 레이어를 별도로 두는 설계가 일반적입니다.

Q. RSC만 사용하면 tRPC 없이도 충분하지 않나요? A. RSC는 서버 사이드 데이터 페칭에 강점이 있지만, 낙관적 업데이트나 클라이언트 캐시 무효화 같은 기능은 React Query(즉 tRPC 훅)를 함께 쓰는 편이 더 수월한 경우가 많습니다. 실제로 두 방식을 함께 사용하는 팀을 자주 볼 수 있습니다.

Q. subscription은 언제 도입하는 것이 적절한가요? A. 실시간성이 비즈니스 요구사항에 필수적이고, WebSocket과 로드밸런서의 스티키 세션까지 운영할 여력이 있을 때 도입하는 것이 좋습니다. 그렇지 않다면 폴링, SSE, 메시지 큐 기반 처리를 먼저 검토하는 것을 권장합니다.


같이 보면 좋은 글