NestJS tRPC 타입 안전 API

tRPC란?

tRPC는 API 스키마 없이 클라이언트-서버 간 완전한 타입 안전성을 제공하는 프레임워크다. REST의 엔드포인트 정의나 GraphQL의 스키마 작성 없이, TypeScript 타입이 서버에서 클라이언트로 자동 전파된다. NestJS와 통합하면 기존 DI·Guard·Interceptor 인프라를 유지하면서 tRPC의 타입 안전성을 얻을 수 있다.

NestJS + tRPC 설정

# 패키지 설치
npm install @trpc/server @trpc/client zod superjson
npm install -D @trpc/react-query  # React 클라이언트 사용 시
// src/trpc/trpc.module.ts
import { Module } from '@nestjs/common';
import { TrpcRouter } from './trpc.router';
import { TrpcService } from './trpc.service';
import { UserModule } from '../user/user.module';
import { OrderModule } from '../order/order.module';

@Module({
  imports: [UserModule, OrderModule],
  providers: [TrpcService, TrpcRouter],
})
export class TrpcModule {}
// src/trpc/trpc.service.ts
import { Injectable } from '@nestjs/common';
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { type Context } from './trpc.context';

@Injectable()
export class TrpcService {
  trpc = initTRPC.context<Context>().create({
    transformer: superjson,  // Date, Map, Set 등 자동 직렬화
    errorFormatter({ shape, error }) {
      return {
        ...shape,
        data: {
          ...shape.data,
          zodError: error.cause instanceof ZodError 
            ? error.cause.flatten() 
            : null,
        },
      };
    },
  });

  // 기본 프로시저
  procedure = this.trpc.procedure;
  router = this.trpc.router;
  mergeRouters = this.trpc.mergeRouters;

  // 인증된 사용자만 접근 가능한 프로시저
  protectedProcedure = this.trpc.procedure.use(async ({ ctx, next }) => {
    if (!ctx.user) {
      throw new TRPCError({
        code: 'UNAUTHORIZED',
        message: '로그인이 필요합니다',
      });
    }
    return next({ ctx: { ...ctx, user: ctx.user } });
  });
}

Context: 요청 컨텍스트 전달

// src/trpc/trpc.context.ts
import { type Request } from 'express';

export interface Context {
  req: Request;
  user: { id: number; email: string; role: string } | null;
}

// Express 미들웨어에서 컨텍스트 생성
export function createContext(req: Request): Context {
  // JWT 토큰에서 유저 정보 추출
  const token = req.headers.authorization?.replace('Bearer ', '');
  const user = token ? verifyToken(token) : null;

  return { req, user };
}

라우터 정의: Zod 스키마

// src/trpc/routers/user.router.ts
import { Injectable } from '@nestjs/common';
import { z } from 'zod';
import { TrpcService } from '../trpc.service';
import { UserService } from '../../user/user.service';

// Zod 스키마 정의 — 입출력 타입이 자동 추론됨
const createUserSchema = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
});

const userFilterSchema = z.object({
  status: z.enum(['ACTIVE', 'INACTIVE']).optional(),
  search: z.string().optional(),
  page: z.number().int().min(1).default(1),
  limit: z.number().int().min(1).max(100).default(20),
});

@Injectable()
export class UserRouter {
  constructor(
    private readonly trpc: TrpcService,
    private readonly userService: UserService,
  ) {}

  router = this.trpc.router({
    // Query: 목록 조회
    list: this.trpc.procedure
      .input(userFilterSchema)
      .query(async ({ input }) => {
        return this.userService.findAll(input);
      }),

    // Query: 단건 조회
    byId: this.trpc.procedure
      .input(z.object({ id: z.number() }))
      .query(async ({ input }) => {
        const user = await this.userService.findById(input.id);
        if (!user) {
          throw new TRPCError({
            code: 'NOT_FOUND',
            message: `User ${input.id} not found`,
          });
        }
        return user;
      }),

    // Mutation: 생성 (인증 필요)
    create: this.trpc.protectedProcedure
      .input(createUserSchema)
      .mutation(async ({ input, ctx }) => {
        return this.userService.create({
          ...input,
          createdBy: ctx.user.id,
        });
      }),

    // Mutation: 수정
    update: this.trpc.protectedProcedure
      .input(z.object({
        id: z.number(),
        data: createUserSchema.partial(),  // 모든 필드 optional
      }))
      .mutation(async ({ input }) => {
        return this.userService.update(input.id, input.data);
      }),

    // Mutation: 삭제
    delete: this.trpc.protectedProcedure
      .input(z.object({ id: z.number() }))
      .mutation(async ({ input }) => {
        await this.userService.delete(input.id);
        return { success: true };
      }),
  });
}

Zod 스키마가 런타임 검증과 TypeScript 타입 추론을 동시에 수행한다. createUserSchema를 정의하면 z.infer<typeof createUserSchema>로 타입이 자동 생성되므로, DTO 클래스를 별도로 만들 필요가 없다. Zod 스키마 검증에 대한 자세한 내용은 NestJS Zod 스키마 검증 심화 글을 참고하자.

주문 라우터: 복잡한 비즈니스 로직

// src/trpc/routers/order.router.ts
const orderItemSchema = z.object({
  productId: z.number(),
  quantity: z.number().int().min(1),
  price: z.number().positive(),
});

const createOrderSchema = z.object({
  items: z.array(orderItemSchema).min(1).max(50),
  shippingAddress: z.object({
    street: z.string(),
    city: z.string(),
    zipCode: z.string().regex(/^d{5}$/),
  }),
  couponCode: z.string().optional(),
}).refine(
  (data) => data.items.reduce((sum, i) => sum + i.price * i.quantity, 0) > 0,
  { message: '주문 총액은 0보다 커야 합니다' }
);

@Injectable()
export class OrderRouter {
  constructor(
    private readonly trpc: TrpcService,
    private readonly orderService: OrderService,
  ) {}

  router = this.trpc.router({
    create: this.trpc.protectedProcedure
      .input(createOrderSchema)
      .mutation(async ({ input, ctx }) => {
        return this.orderService.createOrder(ctx.user.id, input);
      }),

    // 주문 상태 변경 — 관리자만 가능
    updateStatus: this.trpc.protectedProcedure
      .input(z.object({
        orderId: z.number(),
        status: z.enum(['CONFIRMED', 'SHIPPED', 'DELIVERED', 'CANCELLED']),
      }))
      .use(async ({ ctx, next }) => {
        // 미들웨어: 관리자 권한 검사
        if (ctx.user.role !== 'ADMIN') {
          throw new TRPCError({
            code: 'FORBIDDEN',
            message: '관리자만 주문 상태를 변경할 수 있습니다',
          });
        }
        return next();
      })
      .mutation(async ({ input }) => {
        return this.orderService.updateStatus(input.orderId, input.status);
      }),

    // 내 주문 통계
    myStats: this.trpc.protectedProcedure
      .query(async ({ ctx }) => {
        return this.orderService.getUserStats(ctx.user.id);
      }),
  });
}

라우터 통합과 Express 마운트

// src/trpc/trpc.router.ts
import { Injectable, type INestApplication } from '@nestjs/common';
import * as trpcExpress from '@trpc/server/adapters/express';
import { TrpcService } from './trpc.service';
import { UserRouter } from './routers/user.router';
import { OrderRouter } from './routers/order.router';
import { createContext } from './trpc.context';

@Injectable()
export class TrpcRouter {
  constructor(
    private readonly trpc: TrpcService,
    private readonly userRouter: UserRouter,
    private readonly orderRouter: OrderRouter,
  ) {}

  // 모든 라우터 통합
  appRouter = this.trpc.router({
    user: this.userRouter.router,
    order: this.orderRouter.router,
    health: this.trpc.procedure.query(() => ({ status: 'ok' })),
  });

  // NestJS 앱에 마운트
  applyMiddleware(app: INestApplication) {
    app.use(
      '/trpc',
      trpcExpress.createExpressMiddleware({
        router: this.appRouter,
        createContext: ({ req }) => createContext(req),
      }),
    );
  }
}

// 타입 내보내기: 클라이언트에서 사용
export type AppRouter = TrpcRouter['appRouter'];
// src/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // tRPC 미들웨어 마운트
  const trpcRouter = app.get(TrpcRouter);
  trpcRouter.applyMiddleware(app);

  await app.listen(3000);
}
bootstrap();

클라이언트: 완전한 타입 추론

// client/src/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import type { AppRouter } from '../../server/src/trpc/trpc.router';

export const trpc = createTRPCProxyClient<AppRouter>({
  transformer: superjson,
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/trpc',
      headers() {
        return {
          authorization: `Bearer ${getToken()}`,
        };
      },
    }),
  ],
});

// 사용: 완전한 자동완성 + 타입 검사
async function example() {
  // ✅ user.list의 input 타입이 자동 추론됨
  const users = await trpc.user.list.query({
    status: 'ACTIVE',  // 'ACTIVE' | 'INACTIVE'만 허용
    page: 1,
    limit: 20,
  });

  // ✅ 반환 타입도 자동 추론
  console.log(users[0].name);  // string
  console.log(users[0].email); // string

  // ✅ mutation도 타입 안전
  const order = await trpc.order.create.mutate({
    items: [{ productId: 1, quantity: 2, price: 29900 }],
    shippingAddress: {
      street: '강남대로 123',
      city: '서울',
      zipCode: '06234',
    },
  });

  // ❌ 컴파일 에러: zipCode는 5자리 숫자여야 함
  // zipCode: 'abc' → TypeScript + Zod가 모두 잡음
}

핵심은 AppRouter 타입만 import하면 클라이언트에서 서버의 모든 프로시저, 입력 스키마, 반환 타입을 자동으로 알 수 있다는 것이다. REST API의 Swagger 문서나 GraphQL의 codegen이 필요 없다.

React Query 통합

// React에서 tRPC + React Query 사용
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../../server/src/trpc/trpc.router';

export const trpc = createTRPCReact<AppRouter>();

// 컴포넌트에서 사용
function UserList() {
  // useQuery 자동 타입 추론
  const { data, isLoading, error } = trpc.user.list.useQuery({
    status: 'ACTIVE',
    page: 1,
  });

  // useMutation 자동 타입 추론
  const createUser = trpc.user.create.useMutation({
    onSuccess: (newUser) => {
      // newUser 타입 자동 추론
      console.log(`Created: ${newUser.name}`);
    },
    onError: (err) => {
      // Zod 에러 구조화
      if (err.data?.zodError) {
        console.log(err.data.zodError.fieldErrors);
      }
    },
  });

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

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

REST + tRPC 공존

// NestJS 컨트롤러(REST)와 tRPC 라우터를 동시에 사용
// REST: 외부 API, Webhook, 파일 업로드
// tRPC: 내부 프론트엔드 통신

@Controller('api/webhooks')
export class WebhookController {
  @Post('stripe')
  handleStripeWebhook(@Body() body: any, @Headers('stripe-signature') sig: string) {
    // REST가 적합한 경우: 외부 서비스 콜백
  }
}

// tRPC: 프론트엔드 전용 → 타입 안전
// REST: 외부 API → 표준 호환
// 같은 NestJS 서비스를 공유하므로 비즈니스 로직 중복 없음

테스트

// tRPC 프로시저 직접 테스트
import { createCaller } from '../trpc.router';

describe('UserRouter', () => {
  let caller: ReturnType<typeof createCaller>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      imports: [TrpcModule],
    }).compile();

    const router = module.get(TrpcRouter);
    caller = router.appRouter.createCaller({
      req: {} as any,
      user: { id: 1, email: 'test@test.com', role: 'USER' },
    });
  });

  it('유저 목록 조회', async () => {
    const result = await caller.user.list({ page: 1, limit: 10 });
    expect(result).toHaveLength(10);
  });

  it('Zod 검증 실패 시 에러', async () => {
    await expect(
      caller.user.create({ name: '', email: 'invalid' })
    ).rejects.toThrow();
  });

  it('미인증 시 UNAUTHORIZED', async () => {
    const unauthCaller = router.appRouter.createCaller({
      req: {} as any,
      user: null,
    });
    await expect(
      unauthCaller.user.create({ name: 'Test', email: 'a@b.com' })
    ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
  });
});

tRPC의 createCaller로 HTTP 요청 없이 프로시저를 직접 테스트할 수 있다. NestJS 테스트 전략에 대한 자세한 내용은 NestJS 테스트 전략 심화 글을 참고하자.

마무리

방식 타입 안전 스키마 작성 적합한 상황
REST ❌ (Swagger로 보완) DTO 클래스 외부 API, 표준 호환
GraphQL ✅ (codegen 필요) SDL 스키마 복잡한 데이터 페칭
tRPC ✅ (자동) 불필요 풀스택 TypeScript

tRPC는 풀스택 TypeScript 프로젝트에서 가장 빠르고 안전한 API 통신 방법이다. Zod로 검증과 타입을 통합하고, NestJS의 DI·서비스 계층을 그대로 활용하며, 클라이언트에서는 함수 호출처럼 API를 사용한다. REST가 필요한 외부 API와 공존시키면 각자의 장점을 살릴 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux