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 환경에서도 점진적 마이그레이션 전략은 동일하게 유효합니다.
-
API 라우트 공존: 기존 REST API 라우트(예:
app/api/users/route.ts
)와 tRPC 라우트(app/api/trpc/[trpc]/route.ts
)는 함께 존재할 수 있습니다. -
TanStack Query 공유: 설정한 Provider 덕분에 기존의
useQuery
와 tRPC 훅은 동일한 캐시를 공유합니다.
마이그레이션 단계
-
tRPC 기본 설정: 위의 1~3단계를 따라 프로젝트에 tRPC를 설정합니다.
-
엔드포인트 전환: 기존
app/api/posts/route.ts
의 로직을server/routers/post.ts
의 tRPC 프로시저로 옮깁니다. -
프론트엔드 교체:
fetch
를 사용하던useQuery
를trpc.post.getAll.useQuery()
와 같은 tRPC 훅으로 교체합니다. -
기존 코드 제거: 전환이 완료되면 기존
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>
);
}