Route Handlers vs tRPC: 타입 안전한 API를 만드는 두 가지 방법

as 타입 단언이 불안했던 경험에서 시작해, Route Handlers와 tRPC를 비교합니다.

Frontend
Next.js, TypeScript, API, tRPC
return (await response.json()) as { review: string; message: string };

이 코드를 볼 때마다 불안했습니다.

as는 TypeScript 컴파일러에게 “내 말을 믿어”라고 강요하는 문법입니다. 서버가 실제로 무엇을 반환하든 컴파일러는 검증하지 않습니다. 서버에서 messagestatus로 바꿔도 IDE는 아무 경고도 하지 않고, 런타임에 undefined가 됩니다.

실제로 이런 버그를 겪었습니다. 서버 응답 구조를 변경했는데, 클라이언트의 as 단언을 수정하지 않아서 undefined 에러가 터졌습니다.

이 문제를 해결할 방법을 찾다가 tRPC를 알게 됐습니다.


Route Handlers의 타입 문제

Next.js App Router에서 API를 만드는 가장 기본적인 방법은 Route Handlers입니다.

서버

// app/api/generate-review/route.ts
export async function POST(request: NextRequest) {
  // request.json()의 반환 타입은 any — 여기서도 사실상 타입 단언과 같은 문제가 있음
  const payload: ReviewPayload = await request.json();
 
  if (!payload.placeName || !payload.placeUrl) {
    return NextResponse.json(
      { error: '필수 필드가 누락되었습니다.' },
      { status: 400 }
    );
  }
 
  const review = await generateReview(payload);
  return NextResponse.json({ review, message: '생성 완료' });
}

클라이언트

export const generateReview = async (
  payload: ReviewPayload
): Promise<{ review: string; message: string }> => {
  const response = await fetch('/api/generate-review', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
 
  if (!response.ok) {
    const errorData = await response.json().catch(() => ({}));
    throw new Error(errorData.error || '리뷰 생성에 실패했습니다.');
  }
 
  // ⚠️ 서버가 실제로 이 구조를 반환하는지 검증할 방법이 없음
  return (await response.json()) as { review: string; message: string };
};

문제는 명확합니다.

  • 서버 응답 타입과 클라이언트 타입이 수동으로 동기화됩니다.
  • 서버가 응답 구조를 바꿔도 컴파일 타임에 감지되지 않습니다.
  • fetch URL이 문자열이라 오타도 잡히지 않습니다.
  • 모든 API 호출마다 fetch + 에러 처리 보일러플레이트가 반복됩니다.

tRPC는 이 문제를 어떻게 해결하는가

tRPC(TypeScript Remote Procedure Call)는 서버에서 정의한 타입이 클라이언트에 자동으로 전달되는 라이브러리입니다.

핵심 원리는 단순합니다.
클라이언트가 서버의 타입 정의를 import type으로 직접 가져옵니다.

// 서버 (src/server/routers/review.ts)
export const reviewRouter = router({
  generate: publicProcedure
    .input(reviewPayloadSchema)
    .mutation(async ({ input }) => {
      const review = await generateReview(input);
      return { review, message: '생성 완료' };
    }),
});
// 클라이언트
import type { AppRouter } from '@/server/routers/_app';
 
const { mutate } = trpc.review.generate.useMutation();
 
mutate(
  { placeName: '서울 맛집', placeUrl: 'https://...' },
  {
    onSuccess: (data) => {
      // data: { review: string; message: string } — 자동 추론
      console.log(data.review);
    },
  }
);

서버에서 messagestatus로 바꾸면, 클라이언트에서 data.message를 쓰는 모든 곳에 즉시 컴파일 에러가 발생합니다. as 단언이 필요 없습니다.

같은 코드베이스여야 하는 이유

왜 풀스택 TypeScript 프로젝트에서만 사용할 수 있었을까요? 그 이유는 TypeScript 컴파일러가 서버 코드를 직접 읽어야 하기 때문입니다.
서버가 Java나 Python이면 TypeScript 컴파일러가 반환 타입을 추론할 수 없습니다. 이것이 tRPC의 강점이자 제약입니다.

  • 강점: 별도의 코드 생성 없이 타입이 자동으로 동기화
  • 제약: 프론트엔드와 백엔드가 같은 TypeScript 프로젝트여야 함

Next.js 풀스택 프로젝트라면 이 조건은 자연스럽게 충족됩니다.


Zod 통합: 스키마 하나로 검증과 타입을 동시에

tRPC에서 가장 와닿았던 부분은 Zod 통합이었습니다.

기존 방식에서는 TypeScript 타입과 런타임 검증이 별개였습니다. 타입은 컴파일 타임에만 존재하고 런타임에는 사라집니다. 검증 로직을 따로 작성해야 했고, 타입과 검증이 어긋나면 버그가 됐습니다.

// Zod 스키마 하나로 해결
const reviewPayloadSchema = z.object({
  placeName: z.string().min(1, '장소명을 입력해주세요'),
  placeUrl: z.string().url('올바른 URL을 입력해주세요'),
  userDraft: z.string().optional(),
});
 
// 스키마에서 타입 추출
type ReviewPayload = z.infer<typeof reviewPayloadSchema>;
// → { placeName: string; placeUrl: string; userDraft?: string }
 
// tRPC 프로시저에서 사용하면
publicProcedure
  .input(reviewPayloadSchema)  // 런타임 검증 + 타입 추론
  .mutation(async ({ input }) => {
    // input: ReviewPayload — 자동 추론됨
  })

스키마 하나가 런타임 검증과 타입 정의를 동시에 담당합니다.


미들웨어로 인증 로직 재사용

tRPC의 또 다른 장점은 미들웨어입니다. 인증 로직을 한 번 정의하면 여러 프로시저에서 재사용할 수 있습니다.

// 관리자 전용 프로시저
const adminProcedure = publicProcedure.use(({ ctx, next }) => {
  if (!ctx.user?.isAdmin) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { ...ctx, user: ctx.user } });
});
 
// 이 라우터의 모든 프로시저는 관리자만 실행 가능
export const adminRouter = router({
  getWhitelist: adminProcedure.query(async () => {
    return await db.whitelist.findMany();
  }),
  deleteUser: adminProcedure
    .input(z.object({ userId: z.string() }))
    .mutation(async ({ input }) => {
      return await db.user.delete({ where: { id: input.userId } });
    }),
});

Route Handlers에서는 매 핸들러마다 인증 체크를 반복하거나, 별도 유틸로 감싸야 합니다. tRPC 미들웨어는 이런 횡단 관심사를 구조적으로 분리합니다.


Route Handlers에서도 개선할 수 있다

tRPC만이 유일한 해결책은 아닙니다. Route Handlers에서도 Zod + 공용 스키마로 타입 안전성을 높일 수 있습니다.

// 공용 스키마 정의 (src/shared/schemas/review.ts)
export const reviewPayloadSchema = z.object({
  placeName: z.string().min(1),
  placeUrl: z.string().url(),
  userDraft: z.string().optional(),
});
 
export const reviewResponseSchema = z.object({
  review: z.string(),
  message: z.string(),
});
 
export type ReviewPayload = z.infer<typeof reviewPayloadSchema>;
export type ReviewResponse = z.infer<typeof reviewResponseSchema>;
// 서버 — Zod로 입력 검증
export async function POST(request: NextRequest) {
  const body = await request.json();
  const result = reviewPayloadSchema.safeParse(body);
 
  if (!result.success) {
    return NextResponse.json({ error: result.error.message }, { status: 400 });
  }
 
  const review = await generateReview(result.data);
  return NextResponse.json({ review, message: '생성 완료' });
}
// 클라이언트 — as 대신 런타임 검증
import { reviewResponseSchema } from '@/shared/schemas/review';
import type { ReviewPayload, ReviewResponse } from '@/shared/schemas/review';
 
export const generateReview = async (
  payload: ReviewPayload
): Promise<ReviewResponse> => {
  const response = await fetch('/api/generate-review', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
 
  if (!response.ok) {
    const errorData = await response.json().catch(() => ({}));
    throw new Error(errorData.error || '리뷰 생성에 실패했습니다.');
  }
 
  const data = await response.json();
  return reviewResponseSchema.parse(data); // as 대신 런타임 검증
};

as 단언 대신 Zod parse로 런타임 검증을 하고, 공용 스키마로 서버/클라이언트 타입을 동기화할 수 있습니다.

다만 한계는 있습니다.

  • fetch 보일러플레이트는 여전히 반복됩니다.
  • URL 문자열 오타는 컴파일 타임에 잡히지 않습니다.
  • 서버 응답이 바뀌면 공용 스키마를 수동으로 업데이트해야 합니다.

tRPC는 이 모든 것을 자동화합니다. 하지만 Route Handlers + Zod 조합만으로도 as 단언은 제거할 수 있습니다.


언제 무엇을 선택할까

기준Route HandlerstRPC
외부 클라이언트 지원가능 (REST 표준)TypeScript 클라이언트 중심
타입 동기화수동 (공용 스키마)자동
학습 곡선낮음중간 (라우터, 프로시저, React Query)
보일러플레이트많음 (fetch 반복)적음
스트리밍/SSE/웹훅직접 제어 가능제한적
리팩토링 안전성수동 확인 필요컴파일 타임 자동 검증

Route Handlers가 맞는 경우:

  • 외부 클라이언트(모바일 앱, 서드파티)가 API를 사용해야 할 때
  • 스트리밍, SSE, 웹훅 같은 HTTP 기능이 핵심일 때
  • 프로젝트 규모가 작고 API 수가 적을 때

tRPC가 맞는 경우:

  • 풀스택 TypeScript 프로젝트에서 내부 API만 사용할 때
  • API 스펙이 자주 변경되고 리팩토링이 잦을 때
  • 타입 불일치 버그를 컴파일 타임에 잡고 싶을 때

두 방식을 혼용할 수도 있습니다. CRUD는 tRPC로, 스트리밍/웹훅은 Route Handlers로 처리하는 구조입니다. 다만 API 패턴이 이원화되면 팀의 인지 부하가 커질 수 있으니, 혼용 범위는 명확히 정해야 합니다.


마치며

처음에는 단순히 as 단언을 없애고 싶었습니다.

정리하다 보니 결국 “서버와 클라이언트 사이의 타입 계약을 누가 보장하는가”라는 문제였습니다. Route Handlers에서는 개발자가 수동으로 관리해야 하고, tRPC에서는 TypeScript 컴파일러가 자동으로 검증합니다.

모든 프로젝트에 tRPC가 정답은 아닙니다. 외부 API를 제공해야 하거나 스트리밍이 핵심이라면 Route Handlers가 더 적합합니다. 하지만 풀스택 TypeScript 프로젝트에서 타입 불일치 버그를 겪어본 적이 있다면, tRPC는 검토해볼 가치가 있습니다.


참고 자료

관련 글