tRPC: End-to-End 타입 안전 API, 미들웨어·Zod·React Query 통합과 REST 비교

이 글의 핵심

tRPC는 서버 함수의 타입을 클라이언트가 그대로 가져다 쓰게 해서 필드를 추가하면 빌드가 먼저 깨지고, API 스키마를 따로 맞출 필요가 줄어듭니다. 대신 서버리스 환경에서 WebSocket 구독이 잘 맞지 않는 등 배포·권한·캐시 결정과 함께 봐야 하는 부분이 있어, REST·GraphQL과의 비교와 운영 시 따져 볼 장단점을 함께 정리합니다.

tRPC(TypeScript Remote Procedure Call)는 서버에 정의한 함수의 타입을 클라이언트가 그대로 가져다 쓰게 해 주는 라이브러리입니다. 별도의 스키마 파일이나 코드 생성 없이, 서버의 라우터 타입을 import하는 것만으로 클라이언트 호출의 입력과 출력 타입이 정해집니다. 서버에서 필드 이름을 바꾸면 그 필드를 쓰는 클라이언트 코드가 바로 컴파일 오류를 냅니다.

이 글의 코드는 tRPC v11과 TanStack Query v5를 기준으로 합니다. v10 예제에서 보던 createTRPCProxyClient, trpc.useContext(), 뮤테이션의 isLoading은 각각 createTRPCClient, trpc.useUtils(), isPending으로 바뀌었습니다.

tRPC란?

// 서버
const userRouter = t.router({
  getById: t.procedure
    .input(z.object({ id: z.number() }))
    .query(({ input }) => {
      return db.users.findById(input.id);
    }),
});

// 클라이언트(바닐라 클라이언트)
const user = await trpc.user.getById.query({ id: 1 });
// user의 타입은 db.users.findById의 반환 타입에서 추론됨

GraphQL은 스키마를 정의하고 그 스키마에서 클라이언트 타입을 생성하는 단계를 거칩니다. tRPC는 TypeScript 컴파일러의 타입 추론을 그대로 쓰므로 생성 단계가 없습니다. 대신 클라이언트와 서버가 모두 TypeScript여야 하고, 클라이언트가 서버 코드의 타입에 접근할 수 있어야 합니다(보통 모노레포). React에서는 TanStack Query와 통합된 훅을 씁니다.

const { data, isLoading } = trpc.user.getById.useQuery({ id: 1 });

핵심 개념: Procedure와 End-to-End 타입 안정성

tRPC에서 서버의 모든 호출 경로는 procedure로 표현됩니다. initTRPC로 만든 t의 t.procedure에 미들웨어(use)와 input, 선택적인 output을 붙이고, 마지막에 query, mutation, subscription 중 하나로 마무리해 하나의 작업을 만듭니다.

타입 안정성의 근원은 서버의 AppRouter 타입입니다. 클라이언트는 createTRPCReact<AppRouter>()나 createTRPCClient<AppRouter>()에 이 타입을 넘겨 받으므로, 각 procedure의 입력, 출력, 에러 형태를 그대로 알게 됩니다. 서버의 Zod 스키마나 핸들러의 반환 타입이 바뀌면 클라이언트 빌드가 깨지므로, 런타임에야 드러날 필드 누락이나 오타를 컴파일 단계에서 잡을 수 있습니다. 클라이언트는 import type으로 타입만 가져오기 때문에 서버 코드가 클라이언트 번들에 들어가지 않습니다.

Query, Mutation, Subscription

query는 읽기 전용 작업입니다. HTTP GET으로 전송되고, React에서는 useQuery의 캐싱과 리페치를 그대로 활용합니다. mutation은 생성, 수정, 삭제처럼 부수 효과가 있는 작업으로 POST로 전송되며, 성공 후 관련 query를 invalidate해 다시 읽게 하는 패턴이 일반적입니다. subscription은 서버가 클라이언트로 이벤트를 밀어 주는 작업으로, WebSocket이나 v11부터 지원하는 SSE(Server-Sent Events)로 전송됩니다.

세 종류 모두 마지막 단계 직전까지 같은 체인(use 미들웨어, input)을 공유하므로, “인증은 공통, 세부 권한은 procedure별” 같은 횡단 관심사를 미들웨어로 모으기 좋습니다.


Router·Context: 실전에서 자주 쓰는 구조

Router는 router({ ... })로 procedure를 네임스페이스로 묶습니다. appRouter 아래에 user, post처럼 도메인 단위로 나누면 클라이언트에서도 trpc.user.xxx처럼 호출 경로가 코드 구조를 그대로 반영합니다.

Context는 요청마다 createContext가 만들어 어댑터(createNextApiHandler, Fastify, Express 등)를 통해 주입됩니다. 세션, DB 클라이언트, 요청 ID, 로그용 메타데이터를 여기에 둡니다. initTRPC.context<Context>()로 타입을 지정해 두면 모든 procedure의 ctx 타입이 유지됩니다.

// server/trpc.ts — router/procedure/미들웨어를 조립하는 곳
import { initTRPC } from '@trpc/server';
import type { Context } from './context';

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

export const router = t.router;
export const publicProcedure = t.procedure;
// protectedProcedure와 미들웨어는 아래에서 확장

Context에는 무엇이든 넣기 쉬운데, 그러면 어느 procedure가 무엇에 의존하는지 흐려집니다. 미들웨어에서 next({ ctx: { ... } })로 필요한 값만 좁혀 넘기는 습관을 두면, 팀이 커져도 ctx가 비대해지는 속도를 늦출 수 있습니다.


Middleware: 인증, 로깅, 에러 처리

미들웨어는 procedure.use()로 특정 procedure에만 붙이거나, 미들웨어를 붙인 procedure를 export해 재사용합니다. 아래는 요청 ID와 처리 시간을 기록하고, 예상하지 못한 에러를 공통 형식으로 감싸는 예입니다.

// server/trpc.ts (이어서)
import { TRPCError } from '@trpc/server';

const logMiddleware = t.middleware(async ({ next, path, type, ctx }) => {
  const start = performance.now();
  const reqId = ctx.reqId ?? 'na';

  const result = await next();
  const ms = Math.round(performance.now() - start);

  if (result.ok) {
    console.info({ reqId, path, type, ms, level: 'info' });
  } else {
    console.error({ reqId, path, type, ms, err: result.error, level: 'error' });
  }
  return result;
});

export const loggedProcedure = t.procedure.use(logMiddleware);

// 인증: 세션이 있을 때만 통과시키고, ctx.user를 non-null 타입으로 좁힘
export const authedProcedure = loggedProcedure.use(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { user: ctx.user } });
});

tRPC의 next()는 하위 단계에서 던진 에러를 예외로 다시 던지지 않고 { ok: false, error } 형태의 결과로 돌려줍니다. 그래서 try/catch로 감싸도 에러 경로를 잡지 못하므로, 위처럼 result.ok를 확인해야 합니다. 또 핸들러에서 던진 일반 Error는 tRPC가 이미 INTERNAL_SERVER_ERROR 코드의 TRPCError로 감싸 둡니다. 클라이언트로 내보내는 에러 메시지를 바꾸고 싶다면 initTRPC.create({ errorFormatter })에서 처리합니다. next({ ctx })에 넘긴 값은 기존 ctx에 병합되므로 user만 넘겨도 나머지 필드는 유지됩니다.

에러 코드는 의미에 맞게 고릅니다. 로그인하지 않았으면 UNAUTHORIZED, 리소스에 대한 권한이 없으면 FORBIDDEN, 입력 형식은 맞지만 비즈니스 규칙에 어긋나면 BAD_REQUEST나 CONFLICT를 씁니다. 코드를 일관되게 쓰면 클라이언트의 에러 처리(토스트, 재시도, 로그인 페이지 이동)를 코드 기준으로 통일할 수 있습니다. 이 정책은 권한 모델이 복잡해질수록 팀 규칙으로 정해 두지 않으면 화면마다 처리가 달라지기 쉽습니다.


Zod와의 통합: Input 검증을 넘어 Output까지

input에는 Zod 스키마를 넣는 것이 사실상 표준입니다. 서버에서는 들어온 값을 런타임에 검증하고, 핸들러의 input 타입은 스키마에서 추론되며, 클라이언트도 같은 타입 제약을 컴파일 타임에 받습니다.

import { z } from 'zod';

const createTagSchema = z
  .object({
    name: z.string().min(1).max(32),
    slug: z
      .string()
      .regex(/^[a-z0-9-]+$/)
      .optional(),
  })
  .transform((v) => ({
    ...v,
    slug: v.slug ?? v.name.toLowerCase().replace(/\s+/g, '-'),
  }));

// 핸들러의 input은 transform 이후 타입({ name: string; slug: string })
// 클라이언트가 보내는 값은 transform 이전 타입(slug는 선택)
// authedProcedure.input(createTagSchema).mutation(...)

응답에 공개할 필드만 강제하고 싶다면 procedure에 .output(zodSchema)를 붙입니다. 내부 모델에 비밀번호 해시 같은 필드가 있을 때, 실수로 그대로 반환해도 출력 스키마에서 걸러지거나 검증 오류가 납니다.

refine이나 superRefine으로 “이미 DB에 있는 이름인가” 같은 비동기 검증을 붙일 수도 있지만, 검증과 저장 사이에 다른 요청이 같은 값을 넣을 수 있어 경쟁 조건을 막지 못합니다. 이런 규칙은 DB의 유니크 제약으로 보장하고, 위반 에러를 CONFLICT 코드의 TRPCError로 바꿔 돌려주는 편이 안전합니다.


React Query 통합: useQuery, invalidate, 그 너머

@trpc/react-query는 TanStack Query 위에 얇은 래퍼를 씌운 형태라, staleTime, refetchOnWindowFocus, placeholderData 같은 Query 옵션을 그대로 넘길 수 있습니다. 쿼리 키는 tRPC가 procedure 경로와 입력으로 일관되게 만들어 주므로, utils.post.list.invalidate()처럼 경로 단위로 무효화할 수 있고, utils.post.invalidate()로 post 아래 모든 쿼리를 한 번에 무효화할 수도 있습니다.

// 한 화면에서 여러 쿼리: 각각 독립적으로 캐시되고, 같은 틱에 호출되면 하나의 HTTP 요청으로 배치됨
const me = trpc.user.me.useQuery();
const recent = trpc.post.recent.useQuery({ limit: 5 });

Next.js에서 서버가 데이터를 미리 가져와 하이드레이션하려면 createServerSideHelpers(Pages Router)나 서버 컴포넌트용 호출자를 써서 쿼리를 미리 실행하고, 그 캐시를 클라이언트로 넘깁니다. 구성 방법이 라우터 종류와 tRPC 버전에 따라 다르므로 공식 문서의 해당 가이드를 따르는 것이 안전합니다.

뮤테이션 후에는 invalidate로 다시 읽는 대신 onMutate에서 utils.post.list.setData로 캐시를 먼저 고치고, 실패하면 이전 값으로 되돌리는 낙관적 업데이트도 가능합니다. 어떤 뮤테이션이 어떤 쿼리를 무효화하는지가 코드에 드러나므로 리뷰에서 캐시 갱신 누락을 찾기도 쉽습니다.


HTTP 배치와 성능

httpBatchLink는 같은 이벤트 루프 틱에 발생한 여러 procedure 호출을 하나의 HTTP 요청으로 묶습니다. query 묶음은 GET(입력은 쿼리스트링), mutation 묶음은 POST로 전송됩니다. 첫 화면에서 쿼리를 여러 개 보낼 때 왕복 횟수가 줄어듭니다.

배치 GET 요청은 입력이 URL에 들어가므로 프록시나 게이트웨이의 URL 길이 제한에 걸릴 수 있습니다. 이때는 maxURLLength 옵션을 설정하면 그 길이를 넘지 않게 배치를 나눕니다. 또 서버는 한 배치 안의 호출을 동시에 실행하므로, 같은 배치에 들어간 뮤테이션들이 클라이언트에서 호출한 순서대로 적용된다는 보장이 없습니다. 순서가 중요한 뮤테이션은 앞의 결과를 await한 뒤 다음을 호출하거나, 하나의 procedure로 합칩니다.

import {
  createTRPCClient,
  createWSClient,
  httpBatchLink,
  splitLink,
  wsLink,
} from '@trpc/client';
import type { AppRouter } from '../server/routers/_app';

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

// 구독은 WebSocket으로, 나머지는 HTTP 배치로
const client = createTRPCClient<AppRouter>({
  links: [
    splitLink({
      condition: (op) => op.type === 'subscription',
      true: wsLink({ client: wsClient }),
      false: httpBatchLink({ url: 'https://api.example.com/trpc' }),
    }),
  ],
});

실제 서비스에서 체감되는 지연은 대부분 응답 압축 여부, DB 쿼리(N+1 등), 네트워크 왕복에서 나옵니다. tRPC의 procedure 호출은 POST 요청이거나 배치된 GET이라 CDN에서 캐시하기 어렵다는 점도 고려해야 합니다. tRPC는 스키마와 클라이언트 코드 생성 비용을 줄여 주지만, 데이터베이스 최적화는 따로 해야 합니다.


Subscription: 언제 쓰고 무엇으로 전송하는가

채팅, 알림, 실시간 대시보드처럼 서버가 먼저 데이터를 보내야 한다면 폴링 대신 subscription을 씁니다. 배포 환경을 먼저 정해야 하는데, 서버리스 함수는 실행 시간이 짧고 연결을 오래 유지하지 못해 WebSocket 서버를 올리기 어렵습니다. v11부터는 HTTP 위의 SSE로 구독하는 httpSubscriptionLink가 추가되어 별도 WebSocket 서버 없이도 구독을 쓸 수 있지만, 이 경우에도 함수 실행 시간 제한 안에서만 연결이 유지됩니다. 실시간 요구가 크다면 실시간 처리만 별도 상주 서비스로 분리하는 결정을 흔히 내립니다.

서버를 여러 인스턴스로 띄운다면 한 프로세스 안의 EventEmitter로는 다른 인스턴스에 연결된 클라이언트에게 이벤트가 전달되지 않습니다. Redis pub/sub, NATS 같은 메시지 버스를 두고 각 인스턴스가 구독하는 구조가 필요합니다. 아래 예제는 단일 프로세스 기준입니다.


tRPC vs REST vs GraphQL

항목tRPCRESTGraphQL
타입 공유TypeScript 타입 추론, 생성 단계 없음OpenAPI 등으로 별도 생성스키마에서 코드 생성
응답 필드 선택서버가 정한 형태 그대로서버가 정한 형태 그대로클라이언트가 필드 선택
클라이언트 언어TypeScript 전제제한 없음제한 없음
HTTP 캐싱어려움(POST, 배치)표준 HTTP 캐싱 활용 쉬움어려움(보통 POST), 클라이언트 캐시 중심
공개 API 적합성낮음높음높음

REST는 HTTP 캐싱, 다양한 클라이언트 언어, 버전 관리, API 게이트웨이와 모니터링 도구 같은 생태계가 이미 갖춰져 있다는 강점이 있습니다. GraphQL은 클라이언트가 필요한 필드만 고를 수 있지만, 서버 쪽 N+1 문제와 스키마 거버넌스에 비용이 듭니다. tRPC는 같은 저장소에서 같은 팀이 클라이언트와 서버를 함께 관리하는 TypeScript 풀스택 프로젝트에서 “함수가 곧 API”가 되는 편의가 가장 큽니다. 반대로 외부 공개 API, 여러 언어의 클라이언트, 네이티브 모바일 앱이 중심이라면 REST(또는 OpenAPI, gRPC)를 앞에 두는 것이 일반적입니다. 내부 BFF는 tRPC로, 외부 API는 REST로 두는 혼합 구성도 가능하며, 이때는 두 계층이 같은 도메인 모듈을 호출하도록 경계를 정해 두면 로직이 중복되지 않습니다.


tRPC 시작하기

설치

# Next.js 프로젝트 (Pages Router 예제)
npx create-next-app@latest my-trpc-app --typescript
cd my-trpc-app

npm install @trpc/server @trpc/client @trpc/react-query @trpc/next
npm install @tanstack/react-query zod

서버 설정

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

const t = initTRPC.create();

export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/_app.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';

export const appRouter = router({
  hello: publicProcedure
    .input(z.object({ name: z.string() }))
    .query(({ input }) => {
      return { greeting: `Hello ${input.name}!` };
    }),

  getUsers: publicProcedure.query(() => {
    return [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ];
  }),

  createUser: publicProcedure
    .input(z.object({ name: z.string(), email: z.string().email() }))
    .mutation(async ({ input }) => {
      // DB에 저장
      const user = { id: Date.now(), ...input };
      return user;
    }),
});

export type AppRouter = typeof appRouter;
// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';

export default createNextApiHandler({
  router: appRouter,
  createContext: () => ({}),
});

클라이언트 설정

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

export const trpc = createTRPCReact<AppRouter>();
// pages/_app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from '../utils/trpc';
import type { AppProps } from 'next/app';

export default function App({ Component, pageProps }: AppProps) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          // 브라우저에서만 호출한다면 상대 경로로 충분.
          // SSR에서도 호출한다면 배포 환경에 맞는 절대 URL이 필요합니다.
          url: '/api/trpc',
        }),
      ],
    })
  );

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

기본 사용법

Query (데이터 조회)

// pages/index.tsx
import { trpc } from '../utils/trpc';

export default function HomePage() {
  const { data, isLoading, error } = trpc.getUsers.useQuery();

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {data?.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Mutation (데이터 변경)

// components/CreateUser.tsx
import { trpc } from '../utils/trpc';
import { useState } from 'react';

export function CreateUser() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  const utils = trpc.useUtils();

  const createUser = trpc.createUser.useMutation({
    onSuccess: () => {
      // 사용자 목록 다시 가져오기
      utils.getUsers.invalidate();
    },
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await createUser.mutateAsync({ name, email });
    setName('');
    setEmail('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Name"
      />
      <input
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        type="email"
      />
      <button type="submit" disabled={createUser.isPending}>
        {createUser.isPending ? 'Creating...' : 'Create User'}
      </button>
      {createUser.error && <p>{createUser.error.message}</p>}
    </form>
  );
}

mutateAsync는 실패하면 예외를 던지므로, 위 코드에서 실패 시 입력값을 지우는 줄은 실행되지 않습니다. 에러를 화면에 보여 주는 것만으로 충분하다면 mutate와 onSuccess/onError 콜백을 쓰는 편이 처리되지 않은 Promise 거부를 피하기 쉽습니다.


Context와 인증

Context 정의

// server/context.ts
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { getServerSession } from 'next-auth';
import { authOptions } from '../pages/api/auth/[...nextauth]';

export async function createContext({ req, res }: CreateNextContextOptions) {
  const session = await getServerSession(req, res, authOptions);
  return { session };
}

export type Context = Awaited<ReturnType<typeof createContext>>;

서버에서 세션을 읽을 때는 NextAuth v4 기준으로 getServerSession을 씁니다. 클라이언트용 getSession을 서버에서 호출하면 자기 서버로 HTTP 요청을 한 번 더 보내게 됩니다.

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

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

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

// 인증된 사용자만 접근 가능
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      // session.user가 존재한다는 사실을 타입에 반영
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

next({ ctx: { session: ctx.session } })처럼 그대로 넘기면 런타임에는 검사를 통과했어도 타입상 user가 여전히 undefined일 수 있어, 이후 procedure에서 ctx.session.user.id에 타입 오류가 납니다. 위처럼 user를 명시적으로 다시 넣어야 타입이 좁혀집니다.

인증된 라우터

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

export const userRouter = router({
  getProfile: protectedProcedure.query(({ ctx }) => {
    return {
      id: ctx.session.user.id,
      name: ctx.session.user.name,
      email: ctx.session.user.email,
    };
  }),

  updateProfile: protectedProcedure
    .input(z.object({ name: z.string().min(1).max(50) }))
    .mutation(async ({ ctx, input }) => {
      await db.users.update({
        where: { id: ctx.session.user.id },
        data: { name: input.name },
      });
      return { success: true };
    }),
});

session.user.id는 NextAuth 기본 세션 타입에 없으므로, 세션 콜백에서 id를 넣고 타입 선언을 확장해야 합니다.


입력 검증 (Zod)

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

const postRouter = router({
  create: protectedProcedure
    .input(
      z.object({
        title: z.string().min(3).max(100),
        content: z.string().min(10),
        tags: z.array(z.string()).max(5).optional(),
        published: z.boolean().default(false),
        metadata: z.object({
          readTime: z.number().positive(),
          category: z.enum(['tech', 'design', 'business']),
        }).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // 검증을 통과한 input만 여기 도달하며, 타입도 스키마에서 추론됨
      return db.posts.create({
        data: {
          ...input,
          authorId: ctx.session.user.id,
        },
      });
    }),
});

입력이 스키마를 통과하지 못하면 핸들러는 실행되지 않고, 클라이언트는 BAD_REQUEST 코드와 함께 Zod의 오류 정보를 받습니다. 폼 필드별 오류 메시지를 보여 주려면 errorFormatter에서 error.cause가 ZodError일 때 flatten() 결과를 응답 데이터에 넣는 방식이 공식 문서에 소개되어 있습니다.


라우터 병합

// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
import { commentRouter } from './comment';

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

export type AppRouter = typeof appRouter;
// 바닐라 클라이언트에서 사용
const profile = await client.user.getProfile.query();
const posts = await client.post.getAll.query();
const comments = await client.comment.getByPost.query({ postId: 1 });

// React 훅에서는
const { data } = trpc.user.getProfile.useQuery();

Subscription 구현

아래는 단일 인스턴스 기준의 최소 예입니다. v11은 구독을 async generator로 정의하는 방식을 권장하며, 이전의 observable 방식도 계속 지원합니다.

WebSocket 서버

// server/wsServer.ts
import { applyWSSHandler } from '@trpc/server/adapters/ws';
import { WebSocketServer } from 'ws';
import { appRouter } from './routers/_app';
import { createContext } from './context';

const wss = new WebSocketServer({ port: 3001 });

const handler = applyWSSHandler({
  wss,
  router: appRouter,
  createContext,
});

process.on('SIGTERM', () => {
  handler.broadcastReconnectNotification();
  wss.close();
});

WebSocket 연결에는 Next.js API 라우트의 req/res가 없으므로, 여기에 넘기는 createContext는 WebSocket 어댑터의 옵션(CreateWSSContextFnOptions)을 받아 세션을 읽도록 따로 작성해야 합니다. 위 코드는 구조를 보여 주기 위해 같은 이름을 썼습니다.

Subscription 라우터

// server/routers/message.ts
import { EventEmitter, on } from 'node:events';
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';

type Message = { id: number; text: string };
const ee = new EventEmitter();

export const messageRouter = router({
  onNewMessage: publicProcedure.subscription(async function* (opts) {
    // 클라이언트가 구독을 끊으면 signal이 abort되어 루프가 끝남
    for await (const [message] of on(ee, 'newMessage', { signal: opts.signal })) {
      yield message as Message;
    }
  }),

  sendMessage: publicProcedure
    .input(z.object({ text: z.string().min(1) }))
    .mutation(({ input }) => {
      const message: Message = { id: Date.now(), text: input.text };
      ee.emit('newMessage', message);
      return message;
    }),
});

클라이언트에서 구독

// components/Chat.tsx
import { trpc } from '../utils/trpc';
import { useState } from 'react';

type Message = { id: number; text: string };

export function Chat() {
  const [messages, setMessages] = useState<Message[]>([]);

  trpc.message.onNewMessage.useSubscription(undefined, {
    onData(message) {
      setMessages((prev) => [...prev, message]);
    },
  });

  const sendMessage = trpc.message.sendMessage.useMutation();

  return (
    <div>
      <ul>
        {messages.map((msg) => (
          <li key={msg.id}>{msg.text}</li>
        ))}
      </ul>
      <button onClick={() => sendMessage.mutate({ text: 'Hello!' })}>
        Send
      </button>
    </div>
  );
}

이 컴포넌트가 동작하려면 앞의 splitLink 예처럼 클라이언트 링크에 구독용 wsLink(또는 SSE용 httpSubscriptionLink)가 있어야 합니다. 구독은 연결된 이후의 이벤트만 받으므로, 재연결 사이에 놓친 메시지가 문제라면 v11의 tracked()로 이벤트 ID를 붙여 마지막으로 받은 ID 이후부터 다시 보내도록 구현합니다.


에러 처리

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

export const postRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.number() }))
    .query(async ({ input }) => {
      const post = await db.posts.findUnique({
        where: { id: input.id },
      });

      if (!post) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: `Post with id ${input.id} not found`,
        });
      }

      return post;
    }),
});

// 클라이언트
const { data, error } = trpc.post.getById.useQuery({ id: 999 });

if (error) {
  console.log(error.data?.code); // 'NOT_FOUND'
  console.log(error.message);    // 'Post with id 999 not found'
}

TRPCError의 코드는 HTTP 상태 코드로도 매핑되어(NOT_FOUND는 404, UNAUTHORIZED는 401) 응답에 반영되므로, 서버 로그나 모니터링 도구에서도 일반 HTTP API처럼 상태 코드로 오류를 구분할 수 있습니다. TanStack Query는 기본적으로 실패한 쿼리를 재시도하므로, NOT_FOUND나 UNAUTHORIZED처럼 재시도해도 결과가 같은 에러는 retry 옵션에서 코드로 걸러 재시도하지 않게 하는 것이 좋습니다.


실전 프로젝트: Todo 앱

서버 라우터

// server/routers/todo.ts
import { router, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';

export const todoRouter = router({
  getAll: protectedProcedure.query(({ ctx }) => {
    return db.todos.findMany({
      where: { userId: ctx.session.user.id },
      orderBy: { createdAt: 'desc' },
    });
  }),

  create: protectedProcedure
    .input(z.object({ title: z.string().min(1).max(200) }))
    .mutation(({ ctx, input }) => {
      return db.todos.create({
        data: {
          title: input.title,
          userId: ctx.session.user.id,
          completed: false,
        },
      });
    }),

  toggle: protectedProcedure
    .input(z.object({ id: z.number() }))
    .mutation(async ({ ctx, input }) => {
      const todo = await db.todos.findFirst({
        where: { id: input.id, userId: ctx.session.user.id },
      });

      if (!todo) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }

      return db.todos.update({
        where: { id: input.id },
        data: { completed: !todo.completed },
      });
    }),

  delete: protectedProcedure
    .input(z.object({ id: z.number() }))
    .mutation(async ({ ctx, input }) => {
      // 소유자 조건을 함께 걸어 다른 사용자의 todo는 지우지 못하게 함
      const { count } = await db.todos.deleteMany({
        where: { id: input.id, userId: ctx.session.user.id },
      });
      if (count === 0) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }
      return { success: true };
    }),
});

toggle과 delete 모두 userId 조건을 함께 걸어, 다른 사용자의 todo ID를 보내도 수정하거나 지울 수 없게 했습니다. ID만으로 조회해 수정하면 인증은 되어 있지만 권한 확인이 빠진 IDOR 취약점이 됩니다.

클라이언트 컴포넌트

// components/TodoList.tsx
import { trpc } from '../utils/trpc';
import { useState } from 'react';

export function TodoList() {
  const [title, setTitle] = useState('');
  const utils = trpc.useUtils();

  const { data: todos, isLoading } = trpc.todo.getAll.useQuery();

  const invalidate = () => utils.todo.getAll.invalidate();

  const createTodo = trpc.todo.create.useMutation({
    onSuccess: () => {
      invalidate();
      setTitle('');
    },
  });
  const toggleTodo = trpc.todo.toggle.useMutation({ onSuccess: invalidate });
  const deleteTodo = trpc.todo.delete.useMutation({ onSuccess: invalidate });

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          createTodo.mutate({ title });
        }}
      >
        <input
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="New todo..."
        />
        <button type="submit" disabled={createTodo.isPending}>Add</button>
      </form>

      <ul>
        {todos?.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo.mutate({ id: todo.id })}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.title}
            </span>
            <button onClick={() => deleteTodo.mutate({ id: todo.id })}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

같이 보면 좋은 글