2025-08-11 04:07

Tags:

Next.js와 tRPC: 타입-안전 API 구축 가이드 (App Router)

1. tRPC, 왜 Next.js와 환상의 조합일까?

기존의 REST API 방식에서는 프론트엔드와 백엔드가 서로 다른 타입 정의를 가지거나, API 명세서가 변경될 때마다 양쪽 코드를 모두 수정해야 하는 번거로움이 있었습니다.

tRPC는 이 문제를 TypeScript의 타입 추론(Type Inference)으로 해결합니다.

  • 하나의 타입 정의: 백엔드에서 API 로직을 TypeScript 함수로 작성하면, 그 함수의 타입이 자동으로 프론트엔드 클라이언트에 반영됩니다. API 요청 파라미터부터 응답 데이터까지 모든 것이 자동으로 타입 검사됩니다.

  • 코드 생성 불필요: OpenAPI-generator나 GraphQL Code-generator 같은 별도의 코드 생성 도구가 필요 없습니다. 백엔드 코드를 바꾸면 프론트엔드에서 즉시 타입 에러나 자동 완성을 통해 변경 사항을 알 수 있습니다.

  • 뛰어난 개발 경험: API를 사용할 때 마치 일반적인 TypeScript 함수를 호출하는 것처럼 자동 완성이 완벽하게 지원됩니다. 어떤 API가 있는지, 어떤 인자를 넘겨야 하는지 문서를 찾아볼 필요가 줄어듭니다.

2. 핵심 구조: 어떻게 가능한가?

tRPC는 클라이언트와 서버가 코드를 공유한다는 단순한 아이디어에 기반합니다. Next.js App Router 환경에서도 이 장점은 그대로 유지됩니다.

  • Router (라우터): 백엔드에서 API 엔드포인트들을 정의하는 곳입니다. 라우터는 여러 프로시저(Procedure)의 컬렉션입니다.

  • Procedure (프로시저): API 엔드포인트 하나에 해당하는 실제 함수입니다. 데이터를 조회하는 query와 데이터를 생성/수정/삭제하는 mutation으로 나뉩니다.

  • Client (클라이언트): 프론트엔드에서 사용하는 tRPC 클라이언트입니다. 백엔드에 정의된 라우터의 타입 정보만을 가져와, 타입-안전한 API 호출 객체를 만들어냅니다.

3. 구현 단계 (App Router)

1단계: 필요 라이브러리 설치

설치는 이전과 동일합니다.

# tRPC 관련 라이브러리
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next

# tRPC는 React Query를 기반으로 동작합니다
npm install @tanstack/react-query

# 입력값 유효성 검사를 위한 Zod (강력 추천)
npm install zod

2단계: 백엔드(서버) 설정

trpc.ts, _app.ts, post.ts 등 서버 라우터 설정은 Pages Router 방식과 동일합니다. API 핸들러 생성 방식만 변경됩니다.

app/api/trpc/[trpc]/route.ts: API 핸들러 생성 (App Router 방식)

Next.js App Router의 Route Handler를 사용해 tRPC 서버를 외부에 노출시킵니다.

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { type NextRequest } from 'next/server';

import { appRouter } from '@/server/routers/_app';
import { createTRPCContext } from '@/server/trpc';

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

export { handler as GET, handler as POST };

createTRPCContext는 인증 정보 등 컨텍스트를 생성하는 함수로, 필요에 따라 별도 파일로 분리하여 관리합니다.

3단계: 프론트엔드(클라이언트) 설정

App Router에서는 HOC(withTRPC) 대신, Provider 컴포넌트를 만들어 Root Layout을 감싸주는 방식을 사용합니다.

lib/trpc/client.ts: tRPC 클라이언트 생성

API를 호출하는 클라이언트 객체를 생성합니다.

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

export const trpc = createTRPCReact<AppRouter>({});

lib/trpc/Provider.tsx: 클라이언트 Provider 생성

TanStack Query와 tRPC의 Provider를 함께 설정하는 클라이언트 컴포넌트를 만듭니다.

// lib/trpc/Provider.tsx
"use client";

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

import { trpc } from './client';

function getBaseUrl() {
  if (typeof window !== 'undefined') return '';
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
  return `http://localhost:${process.env.PORT ?? 3000}`;
}

export default function Provider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({}));
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
        }),
      ],
    })
  );
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
}

app/layout.tsx: Root Layout에 Provider 적용

생성한 Provider로 <body> 태그의 children을 감싸줍니다.

// app/layout.tsx
import Provider from '@/lib/trpc/Provider';

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

4단계: 컴포넌트에서 API 호출

이제 클라이언트 컴포넌트에서 tRPC 훅을 사용할 수 있습니다. 훅을 사용하는 컴포넌트는 반드시 "use client" 지시어를 사용해야 합니다.

// app/page.tsx
"use client"; // 훅을 사용하기 위해 클라이언트 컴포넌트로 지정

import { useState } from 'react';
import { trpc } from '@/lib/trpc/client';

export default function Home() {
  const [newPostTitle, setNewPostTitle] = useState('');

  // 1. 데이터 조회 (useQuery)
  const { data: posts, refetch } = trpc.post.getAll.useQuery();

  // 2. 데이터 생성 (useMutation)
  const createPost = trpc.post.create.useMutation({
    onSuccess: () => {
      refetch();
    },
  });

  const handleCreatePost = () => {
    if (newPostTitle.trim() === '') return;
    createPost.mutate({ title: newPostTitle });
    setNewPostTitle('');
  };

  return (
    <div style={{ padding: '2rem' }}>
      <h1>Posts</h1>
      <ul>
        {posts?.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <hr />
      <h2>Create New Post</h2>
      <input
        type="text"
        value={newPostTitle}
        onChange={(e) => setNewPostTitle(e.target.value)}
        placeholder="New post title"
      />
      <button onClick={handleCreatePost} disabled={createPost.isLoading}>
        {createPost.isLoading ? 'Creating...' : 'Create'}
      </button>
    </div>
  );
}

5. 점진적 마이그레이션 (App Router)

App Router 환경에서도 점진적 마이그레이션 전략은 동일하게 유효합니다.

  1. API 라우트 공존: 기존 REST API 라우트(예: app/api/users/route.ts)와 tRPC 라우트(app/api/trpc/[trpc]/route.ts)는 함께 존재할 수 있습니다.

  2. TanStack Query 공유: 설정한 Provider 덕분에 기존의 useQuery와 tRPC 훅은 동일한 캐시를 공유합니다.

마이그레이션 단계

  1. tRPC 기본 설정: 위의 1~3단계를 따라 프로젝트에 tRPC를 설정합니다.

  2. 엔드포인트 전환: 기존 app/api/posts/route.ts의 로직을 server/routers/post.ts의 tRPC 프로시저로 옮깁니다.

  3. 프론트엔드 교체: fetch를 사용하던 useQuerytrpc.post.getAll.useQuery()와 같은 tRPC 훅으로 교체합니다.

  4. 기존 코드 제거: 전환이 완료되면 기존 app/api/posts/route.ts 파일을 삭제합니다.

이 과정을 반복하여 전체 프로젝트를 안전하게 최신 tRPC 아키텍처로 전환할 수 있습니다.

1. tRPC, 왜 Next.js와 환상의 조합일까?

기존의 REST API 방식에서는 프론트엔드와 백엔드가 서로 다른 타입 정의를 가지거나, API 명세서가 변경될 때마다 양쪽 코드를 모두 수정해야 하는 번거로움이 있었습니다.

tRPC는 이 문제를 TypeScript의 타입 추론(Type Inference)으로 해결합니다.

  • 하나의 타입 정의: 백엔드에서 API 로직을 TypeScript 함수로 작성하면, 그 함수의 타입이 자동으로 프론트엔드 클라이언트에 반영됩니다. API 요청 파라미터부터 응답 데이터까지 모든 것이 자동으로 타입 검사됩니다.

  • 코드 생성 불필요: OpenAPI-generator나 GraphQL Code-generator 같은 별도의 코드 생성 도구가 필요 없습니다. 백엔드 코드를 바꾸면 프론트엔드에서 즉시 타입 에러나 자동 완성을 통해 변경 사항을 알 수 있습니다.

  • 뛰어난 개발 경험: API를 사용할 때 마치 일반적인 TypeScript 함수를 호출하는 것처럼 자동 완성이 완벽하게 지원됩니다. 어떤 API가 있는지, 어떤 인자를 넘겨야 하는지 문서를 찾아볼 필요가 줄어듭니다.

2. 핵심 구조: 어떻게 가능한가?

tRPC는 클라이언트와 서버가 코드를 공유한다는 단순한 아이디어에 기반합니다. Next.js는 프론트엔드와 백엔드(API Routes) 코드가 한 프로젝트 안에 공존하므로 tRPC의 장점을 극대화할 수 있습니다.

  • Router (라우터): 백엔드에서 API 엔드포인트들을 정의하는 곳입니다. 라우터는 여러 프로시저(Procedure)의 컬렉션입니다.

  • Procedure (프로시저): API 엔드포인트 하나에 해당하는 실제 함수입니다. 데이터를 조회하는 query와 데이터를 생성/수정/삭제하는 mutation으로 나뉩니다.

  • Client (클라이언트): 프론트엔드에서 사용하는 tRPC 클라이언트입니다. 백엔드에 정의된 라우터의 타입 정보만을 가져와, 타입-안전한 API 호출 객체를 만들어냅니다. 실제 서버 로직 코드를 가져오는 것이 아니므로 번들 크기에 영향을 주지 않습니다.

3. 구현 단계 (Step-by-Step)

Next.js 프로젝트에 tRPC를 설정하는 과정을 단계별로 알아봅니다.

1단계: 필요 라이브러리 설치

# tRPC 관련 라이브러리
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next

# tRPC는 React Query를 기반으로 동작합니다
npm install @tanstack/react-query

# 입력값 유효성 검사를 위한 Zod (강력 추천)
npm install zod

2단계: 백엔드(서버) 설정

server/trpc.ts: tRPC 인스턴스 생성

API의 컨텍스트를 정의하고, 재사용 가능한 프로시저를 만드는 기본 파일입니다.

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

// 1. 컨텍스트 없이 tRPC 인스턴스 초기화
const t = initTRPC.create();

// 2. 재사용 가능한 라우터와 프로시저 헬퍼 export
export const router = t.router;
export const publicProcedure = t.procedure;

server/routers/post.ts: 특정 기능 라우터 정의

게시물(Post)과 관련된 API 프로시저를 정의하는 예시입니다.

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

// 간단한 인메모리 데이터베이스 역할
const posts = [
  { id: '1', title: 'Hello tRPC' },
  { id: '2', title: 'Next.js + tRPC = ❤️' },
];

export const postRouter = router({
  // 'post.getAll' 프로시저 (Query)
  getAll: publicProcedure.query(() => {
    return posts;
  }),
  // 'post.create' 프로시저 (Mutation)
  create: publicProcedure
    .input(z.object({ title: z.string() })) // 입력값 검증
    .mutation(({ input }) => {
      const newPost = { id: `${Math.random()}`, title: input.title };
      posts.push(newPost);
      return newPost;
    }),
});

server/routers/_app.ts: 메인 앱 라우터

여러 기능 라우터들을 하나로 통합하는 최상위 라우터입니다.

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

export const appRouter = router({
  // postRouter를 'post'라는 네임스페이스로 통합
  post: postRouter,
});

// 백엔드 라우터의 타입 정의를 export
export type AppRouter = typeof appRouter;

pages/api/trpc/[trpc].ts: API 핸들러 생성

Next.js의 API 라우트를 통해 tRPC 서버를 외부에 노출시키는 파일입니다.

// pages/api/trpc/[trpc].ts
import * as trpcNext from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';

// 모든 tRPC API 요청을 처리하는 핸들러 export
export default trpcNext.createNextApiHandler({
  router: appRouter,
  createContext: () => ({}), // 컨텍스트 생성 (예: DB 커넥션, 세션 정보)
});

3단계: 프론트엔드(클라이언트) 설정

utils/trpc.ts: tRPC 클라이언트 생성

프론트엔드에서 사용할 타입-안전한 클라이언트를 설정합니다.

// utils/trpc.ts
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import type { AppRouter } from '../server/routers/_app';

function getBaseUrl() {
  if (typeof window !== 'undefined') return ''; // 브라우저에서는 상대 경로
  if (process.env.VERCEL_URL) return `https://{process.env.VERCEL_URL}`; // Vercel
  return `http://localhost:${process.env.PORT ?? 3000}`; // 로컬 개발 환경
}

export const trpc = createTRPCNext<AppRouter>({
  config() {
    return {
      links: [
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
        }),
      ],
    };
  },
  ssr: false, // 서버 사이드 렌더링 여부
});

pages/_app.tsx: 앱 전체에 Provider 적용

tRPC 클라이언트를 앱 전체에서 사용할 수 있도록 _app.tsx를 Provider로 감싸줍니다.

// pages/_app.tsx
import type { AppType } from 'next/app';
import { trpc } from '../utils/trpc';

const MyApp: AppType = ({ Component, pageProps }) => {
  return <Component {...pageProps} />;
};

// trpc.withTRPC로 컴포넌트를 감싸줍니다.
export default trpc.withTRPC(MyApp);

4단계: 컴포넌트에서 API 호출

이제 모든 설정이 끝났습니다. 페이지 컴포넌트에서 tRPC 훅을 사용하여 API를 호출할 수 있습니다.

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

export default function Home() {
  const [newPostTitle, setNewPostTitle] = useState('');

  // 1. 데이터 조회 (useQuery)
  // 'post.getAll' 프로시저를 호출합니다.
  // data, isLoading 등의 상태를 제공합니다.
  const { data: posts, refetch } = trpc.post.getAll.useQuery();

  // 2. 데이터 생성 (useMutation)
  // 'post.create' 프로시저를 호출합니다.
  const createPost = trpc.post.create.useMutation({
    onSuccess: () => {
      // 성공 시 게시물 목록을 다시 불러옵니다.
      refetch();
    },
  });

  const handleCreatePost = () => {
    if (newPostTitle.trim() === '') return;
    // mutate 함수로 API를 실행합니다.
    // 입력값은 완벽하게 타입-체크됩니다.
    createPost.mutate({ title: newPostTitle });
    setNewPostTitle('');
  };

  return (
    <div style={{ padding: '2rem' }}>
      <h1>Posts</h1>
      <ul>
        {posts?.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>

      <hr />

      <h2>Create New Post</h2>
      <input
        type="text"
        value={newPostTitle}
        onChange={(e) => setNewPostTitle(e.target.value)}
        placeholder="New post title"
      />
      <button onClick={handleCreatePost} disabled={createPost.isLoading}>
        {createPost.isLoading ? 'Creating...' : 'Create'}
      </button>
    </div>
  );
}

References

tRPC