tRPC를 사용하여 Next.js에서 엔드투엔드 타입 세이프티 달성하기
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
현대 웹 개발 세계에서 견고하고 유지 관리 가능한 애플리케이션을 구축하려면 프론트엔드와 백엔드 간의 원활한 통신이 필요합니다. 개발자가 직면하는 지속적인 과제 중 하나는 특히 TypeScript를 사용할 때 이 경계를 넘어서 타입 세이프티를 보장하는 것입니다. 전통적으로 이는 수동 타입 정의, 코드 생성 도구 또는 복잡한 GraphQL 설정과 같은 자체 오버헤드와 복잡성을 가진 방법을 사용했습니다. 이러한 방법은 종종 프론트엔드와 백엔드 타입을 동기화하기 위해 추가 단계가 필요한 단절을 도입하여 잠재적인 버그와 최적이 아닌 개발자 경험으로 이어집니다. 이 글에서는 혁신적인 프레임워크인 tRPC가 이 문제를 정면으로 해결하여 번거로운 코드 생성 없이 Next.js 애플리케이션과 Node.js 백엔드 간의 엔드투엔드 타입 세이프티를 달성하기 위한 우아한 솔루션을 제공하는 방법을 살펴봅니다.
tRPC를 사용하여 타입 세이프티 잠금 해제
tRPC의 강력함을 완전히 이해하기 위해 몇 가지 기본 개념을 먼저 명확히 하겠습니다.
핵심 용어
- RPC (Remote Procedure Call): 프로그램이 네트워크상의 다른 컴퓨터에 있는 프로그램으로부터 네트워크의 세부 사항을 이해할 필요 없이 서비스를 요청할 수 있도록 하는 프로토콜입니다. 더 간단히 말해서, 로컬 함수처럼 다른 위치에 있는 함수를 호출하는 것입니다.
- 엔드투엔드 타입 세이프티: 백엔드(예: API 스키마, 함수 시그니처)에서 정의한 타입이 프론트엔드에서 자동으로 추론되고 적용되어 컴파일 타임에 타입 불일치 및 관련 오류를 제거하여 더 안정적인 애플리케이션을 구축할 수 있음을 보장합니다.
- 제로 생성: tRPC의 핵심 특징으로, 중간 클라이언트 측 코드 파일을 생성하지 않고 타입 세이프티를 달성한다는 의미입니다. 대신 백엔드 코드에서 직접 TypeScript의 강력한 추론 기능을 활용합니다.
tRPC 원칙
tRPC는 간단하면서도 심오한 원칙에 따라 작동합니다. 즉, TypeScript의 추론 엔진을 활용하여 백엔드 함수 정의에서 프론트엔드로 타입을 직접 공유합니다. TypeScript를 사용하여 Node.js 백엔드에서 API 엔드포인트(tRPC 용어로는 "프로시저")를 정의하면 tRPC는 해당 입력 인수, 반환 타입 및 잠재적 오류를 추론합니다. 이 타입 정보는 Next.js 프론트엔드에 노출되어 이러한 API 엔드포인트를 로컬 함수를 호출하는 것처럼 완전한 타입 세이프티로 사용할 수 있습니다.
tRPC 구현
tRPC가 어떻게 작동하는지 보여주는 실질적인 예제를 살펴보겠습니다.
백엔드 설정 (Node.js)
먼저 tRPC와 Zod(스키마 유효성 검사에 일반적으로 사용됨)를 설치합니다.
npm install @trpc/server zod
이제 tRPC 라우터와 프로시저를 정의합니다.
// src/server/trpc.ts import { initTRPC } from '@trpc/server'; import { z } from 'zod'; const t = initTRPC.create(); // tRPC 초기화 export const router = t.router; export const publicProcedure = t.procedure; // src/server/routers/_app.ts import { publicProcedure, router } from './trpc'; import { z } from 'zod'; const appRouter = router({ // 데이터 페칭을 위한 'query' 프로시저 getUser: publicProcedure .input(z.object({ id: z.string().uuid() })) // Zod를 사용하여 입력 스키마 정의 .query(async ({ input }) => { // 실제 앱에서는 데이터베이스에서 가져올 것입니다. console.log(`Fetching user with ID: ${input.id}`); return { id: input.id, name: 'John Doe', email: 'john@example.com' }; }), // 데이터 전송/부작용을 위한 'mutation' 프로시저 createUser: publicProcedure .input(z.object({ name: z.string(), email: z.string().email() })) .mutation(async ({ input }) => { // 실제 앱에서는 데이터베이스에 저장할 것입니다. console.log('Creating user:', input); return { id: 'some-generated-uuid', ...input }; }), }); export type AppRouter = typeof appRouter; // 라우터 타입 내보내기
다음으로, tRPC 요청을 처리하기 위해 Next.js에서 API 엔드포인트를 설정합니다. 일반적으로 pages/api/trpc/[trpc].ts
또는 Next.js 앱 라우터 경로 핸들러 내에서 수행됩니다.
// pages/api/trpc/[trpc].ts (Pages Router의 경우) import { createNextApiHandler } from '@trpc/server/adapters/next'; import { appRouter } from '../../../server/routers/_app'; export default createNextApiHandler({ router: appRouter, createContext: () => ({ /* 프로시저를 위한 컨텍스트, 예: 데이터베이스 연결 */ }), });
프론트엔드 설정 (Next.js)
tRPC 클라이언트 라이브러리와 React Query(데이터 페칭에 일반적으로 사용됨)를 설치합니다.
npm install @trpc/client @tanstack/react-query @trpc/react-query
이제 tRPC 클라이언트와 프로바이더를 만듭니다.
// src/utils/trpc.ts import { httpBatchLink } from '@trpc/client'; import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '../server/routers/_app'; // 백엔드 라우터 타입 가져오기 export const trpc = createTRPCReact<AppRouter>(); // 타입을 직접 추론! // src/pages/_app.tsx (또는 App Router의 layout.tsx) import { useState } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { trpc } from '../utils/trpc'; import type { AppProps } from 'next/app'; function MyApp({ Component, pageProps }: AppProps) { const [queryClient] = useState(() => new QueryClient()); const [trpcClient] = useState(() => trpc.createClient({ links: [ httpBatchLink({ url: '/api/trpc', // tRPC API 엔드포인트 }), ], }) ); return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> <Component {...pageProps} /> </QueryClientProvider> </trpc.Provider> ); } export default MyApp;
프론트엔드에서 API 프로시저 사용
이제 모든 React 컴포넌트에서 tRPC 훅을 완전한 타입 세이프티로 사용할 수 있습니다.
// src/components/UserDisplay.tsx import { trpc } from '../utils/trpc'; import React from 'react'; function UserDisplay() { // useQuery 훅 사용, 타입은 AppRouter에서 추론됨! const { data: user, isLoading, error } = trpc.getUser.useQuery({ id: 'a1b2c3d4-e5f6-7890-1234-567890abcdef' }); if (isLoading) return <div>로딩 중...</div>; if (error) return <div>오류: {error.message}</div>; return ( <div> <h2>사용자 세부 정보</h2> <p>ID: {user?.id}</p> <p>이름: {user?.name}</p> <p>이메일: {user?.email}</p> </div> ); } function CreateUserForm() { const createUserMutation = trpc.createUser.useMutation({ onSuccess: (data) => { alert(`사용자 생성됨: ${data.name}`); // 필요한 경우 쿼리 무효화 또는 데이터 다시 페치 }, onError: (err) => { alert(`사용자 생성 오류: ${err.message}`); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.target as HTMLFormElement); const name = formData.get('name') as string; const email = formData.get('email') as string; createUserMutation.mutate({ name, email }); // 입력 인수는 타입 검사됨! }; return ( <form onSubmit={handleSubmit}> <h3>새 사용자 생성</h3> <input name="name" type="text" placeholder="이름" required /> <input name="email" type="email" placeholder="이메일" required /> <button type="submit" disabled={createUserMutation.isLoading}> {createUserMutation.isLoading ? '생성 중...' : '사용자 생성'} </button> </form> ); } export default function Home() { return ( <div> <UserDisplay /> <CreateUserForm /> </div> ) }
trpc.getUser.useQuery
는 자동으로 input
이 string
타입의 id
를 가진 객체여야 하고, data
는 백엔드의 { id: string, name: string, email: string }
모양을 따른다는 것을 알 수 있습니다. 마찬가지로 createUserMutation.mutate
는 백엔드 createUser
프로시저에 정의된 대로 name
과 email
을 예상합니다. 잘못된 타입을 전달하거나 필수 필드를 누락하면 코드가 실행되기 훨씬 전에 TypeScript가 컴파일 타임에 즉시 오류를 표시합니다. 이것이 제로 생성 엔드투엔드 타입 세이프티의 마법입니다!
애플리케이션 시나리오
tRPC는 특히 다음을 위해 적합합니다.
- 모놀리식 또는 긴밀하게 결합된 풀스택 애플리케이션: 프론트엔드와 백엔드가 동일한 팀이나 동일한 저장소 내에서 개발되어 타입 공유가 효율적입니다.
- 내부 도구/관리 패널: 빠른 개발과 강력한 타입 보장이 내부 운영 오류를 방지하는 데 중요합니다.
- 개발자 경험 우선 프로젝트: tRPC는 컨텍스트 전환 및 수동 타입 동기화 노력을 크게 줄입니다.
- 공유 타입이 있는 마이크로서비스: tRPC는 모놀리스에서 탁월하지만, 서비스가 공유 타입 정의로 개발된 경우 마이크로서비스 아키텍처에서도 이점을 제공할 수 있습니다.
결론
tRPC는 Next.js와 Node.js 백엔드 간의 타입 세이프티 격차를 해소하는 데 매우 효과적인 솔루션으로 두드러집니다. TypeScript의 추론 기능을 지능적으로 활용하여 수동 타입 정의 또는 복잡한 코드 생성의 필요성을 제거하고 진정으로 원활하고 즐거운 개발자 경험을 제공합니다. tRPC를 사용하면 프론트엔드와 백엔드가 항상 완벽한 타입 조화를 이루며 통신한다는 사실을 알면서 자신 있게 풀스택 애플리케이션을 구축할 수 있습니다. 개발자는 더 빠르게 견고하고 오류 없는 애플리케이션을 배포할 수 있으며, 이는 현대 웹 개발 생태계에서 강력한 도구로서의 입지를 공고히 합니다.