tRPC를 이용한 풀스택 TypeScript의 종단간 타입 안전성 달성
Grace Collins
Solutions Engineer · Leapcell

소개
오늘날 빠르게 변화하는 개발 환경에서 강력하고 유지보수 가능한 애플리케이션을 구축하는 것이 무엇보다 중요합니다. JavaScript 생태계가 발전함에 따라 TypeScript는 특히 대규모 프로젝트에서 코드 품질과 개발자 경험을 향상시키는 초석으로 부상했습니다. 그러나 풀스택 TypeScript 애플리케이션에서 지속적인 과제는 전체 스택에 걸쳐 엄격한 타입 안전성을 유지하는 것이었습니다. 일반적으로 개발자는 백엔드와 프론트엔드 간에 API 유형을 수동으로 생성하고 동기화하는 데 의존하는데, 이는 오류가 발생하기 쉽고 유지보수가 번거로우며 마찰의 상당한 원인이 됩니다. 이는 개발 주기 후반 또는 심지어 프로덕션에서만 발견되는 런타임 타입 불일치로 이어지는 경우가 많습니다.
추가 구성이나 중복 유형 정의 없이 프론트엔드가 백엔드 API가 예상하고 반환하는 데이터의 정확한 유형을 마법처럼 알 수 있는 세상을 상상해 보세요. 이것이 바로 tRPC가 제공하고자 하는 이상향입니다. API 통신에 대한 새로운 접근 방식을 도입함으로써 tRPC는 개발자가 진정한 종단간 타입 안전성을 달성하도록 지원하여 개발을 간소화하고 버그를 줄이며 전반적인 개발자 경험을 크게 향상시킵니다. 이 문서는 tRPC가 어떻게 이 놀라운 위업을 달성하는지 살펴보고, 핵심 개념을 설명하고, 실제 코드 예제를 통해 구현을 시연하고, 풀스택 TypeScript 개발에 미치는 혁신적인 영향을 강조할 것입니다.
tRPC의 핵심 개념
구현 세부 사항으로 뛰어들기 전에 tRPC의 강력한 기반을 이루는 기본 개념을 명확하게 이해해 봅시다.
-
tRPC (TypeScript Remote Procedure Call): tRPC는 코드 생성이나 스키마 정의 없이 완전한 타입 안전 API를 구축할 수 있게 해주는 프레임워크입니다. 프론트엔드가 백엔드에 정의된 함수를 로컬 함수인 것처럼 직접 호출할 수 있도록 하여 타입 무결성을 유지합니다. 이것은 스키마나 수동 유형 정의와 같은 중간 계층이 필요한 기존 REST 또는 GraphQL API와는 중요한 차이점입니다.
-
프로시저: tRPC에서 백엔드 API의 기능은 "프로시저"로 노출됩니다. 이는 본질적으로 서버에 상주하며 클라이언트에서 호출할 수 있는 함수입니다. 프로시저는 쿼리(데이터 가져오기), 뮤테이션(데이터 변경) 또는 구독(실시간 업데이트)일 수 있습니다.
-
라우터: 프로시저는 라우터로 구성됩니다. 라우터는 관련 프로시저의 컬렉션으로, 구조화되고 모듈화된 API 디자인을 허용합니다. 더 복잡한 API 계층을 만들기 위해 라우터를 중첩할 수 있습니다.
-
코드로부터의 추론: tRPC의 마법은 백엔드 코드에서 직접 유형을 추론하는 능력에 있습니다. 유형을 별도로 정의하도록 강제하는 대신 tRPC는 TypeScript의 강력한 추론 엔진을 활용하여 서버 측 프로시저 정의를 기반으로 클라이언트에 필요한 유형을 자동으로 생성합니다. 이를 통해 수동 유형 동기화가 필요 없습니다.
-
최소주의: tRPC는 가볍고 인프라에 대한 주장이 없다는 점을 자랑스럽게 생각합니다. 데이터베이스나 프론트엔드 프레임워크를 지정하지 않고 기존 프로젝트에 통합할 수 있는 유연성을 제공합니다.
tRPC를 이용한 종단간 타입 안전성 구현
tRPC가 종단간 타입 안전성을 어떻게 달성하는지 설명하기 위해 실용적인 예제를 살펴보겠습니다. tRPC 백엔드와 React 프론트엔드를 갖춘 간단한 풀스택 애플리케이션을 설정해 보겠습니다.
1. 백엔드 설정
먼저 TypeScript를 사용하여 Node.js 프로젝트를 초기화하고 필요한 tRPC 패키지를 설치해야 합니다.
mkdir trpc-example cd trpc-example npm init -y npm i express @trpc/server zod @trpc/client @trpc/react-query @tanstack/react-query npm i -D typescript ts-node @types/node @types/express npx tsc --init
이제 백엔드 서버를 만들어 보겠습니다. 사용자 목록을 가져오는 간단한 프로시저를 정의하겠습니다.
src/server/index.ts
import { inferAsyncReturnType, initTRPC } from '@trpc/server'; import * as trpcExpress from '@trpc/server/adapters/express'; import express, { Express } from 'express'; import { z } from 'zod'; // 스키마 유효성 검사를 위한 Zod // 데이터베이스 시뮬레이션 const users = [ { id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }, ]; // tRPC 초기화 const t = initTRPC.create(); // 라우터 정의 const appRouter = t.router({ user: t.router({ getUsers: t.procedure.query(() => { return users; }), getUserById: t.procedure .input(z.object({ id: z.string() })) // Zod를 사용하여 입력 스키마 정의 .query(({ input }) => { return users.find((user) => user.id === input.id); }), createUser: t.procedure .input(z.object({ name: z.string().min(3) })) // 뮤테이션을 위한 입력 스키마 정의 .mutation(({ input }) => { const newUser = { id: String(users.length + 1), name: input.name }; users.push(newUser); return newUser; }), }), }); // 타입 안전한 라우터 내보내기 export type AppRouter = typeof appRouter; const app: Express = express(); const port = 3000; app.use( '/trpc', trpcExpress.createExpressMiddleware({ router: appRouter, createContext: ({ req, res }) => ({}), // 일단 기본적인 컨텍스트 }) ); app.listen(port, () => { console.log(`Server listening on port ${port}`); });
설명:
initTRPC.create()
를 사용하여 tRPC를 초기화합니다.- 중첩된
user
라우터가 포함된appRouter
를 정의합니다. getUsers
는 모든 사용자를 반환하는query
프로시저입니다. 여기에는 주석으로 명시적인 반환 유형이 없다는 점에 유의하십시오. tRPC가 자동으로 추론합니다.getUserById
는id
를 입력으로 받는 또 다른query
입니다. 입력에 대한 스키마를 정의하기 위해 Zod(z
)을 사용하며, 타입 안전성과 런타임 유효성을 보장합니다.createUser
는 데이터 생성, 업데이트 또는 삭제 방법을 보여주는mutation
프로시저입니다. 입력 유효성 검사를 위해 Zod도 사용합니다.- 결정적으로
export type AppRouter = typeof appRouter;
를 내보냅니다. 이 줄은 tRPC 타입 추론의 핵심입니다. 프론트엔드에서 이 유형을 가져오면 사용 가능한 모든 프로시저, 해당 입력 및 해당 출력에 대한 완전한 정보를 얻게 됩니다.
2. 프론트엔드 설정
이제 tRPC API를 사용하는 간단한 React 프론트엔드를 만들어 보겠습니다.
src/client/main.tsx
(빠른 React 설정을 위해 Vite 사용)
import React from 'react'; import ReactDOM from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { httpBatchLink } from '@trpc/client'; import { trpc } from './trpc'; // 우리의 tRPC 클라이언트 인스턴스 import App from './App'; const queryClient = new QueryClient(); // tRPC 클라이언트 인스턴스 생성 const trpcClient = trpc.createClient({ links: [ httpBatchLink({ url: 'http://localhost:3000/trpc', // 우리의 tRPC 서버 URL }), ], }); ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> </trpc.Provider> </React.StrictMode> );
src/client/trpc.ts
(이 파일은 tRPC 클라이언트를 부트스트랩합니다)
import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '../server/index'; // 백엔드에서 AppRouter 유형 가져오기 export const trpc = createTRPCReact<AppRouter>();
설명:
src/client/trpc.ts
에서 백엔드에서AppRouter
를 직접 가져옵니다. 여기서 마법이 일어납니다!createTRPCReact<AppRouter>()
는 이 가져온 유형을 사용하여 완전한 타입 안전한trpc
클라이언트 인스턴스를 생성합니다.- 그런 다음
trpc.Provider
를 사용하여trpc
클라이언트를 React 애플리케이션에 제공합니다.
src/client/App.tsx
import React, { useState } from 'react'; import { trpc } from './trpc'; function App() { const [newUserName, setNewUserName] = useState(''); const [userIdInput, setUserIdInput] = useState(''); // 타입 안전한 trpc 클라이언트를 사용하여 백엔드 프로시저 호출 const { data: users, isLoading: isLoadingUsers, refetch: refetchUsers } = trpc.user.getUsers.useQuery(); const { data: userById, isLoading: isLoadingUserById } = trpc.user.getUserById.useQuery( { id: userIdInput }, { enabled: !!userIdInput } // userIdInput이 존재할 때만 쿼리 실행 ); const createUserMutation = trpc.user.createUser.useMutation({ onSuccess: () => { refetchUsers(); // 새 사용자 생성 후 사용자 다시 가져오기 setNewUserName(''); }, }); const handleCreateUser = () => { if (newUserName.trim()) { createUserMutation.mutate({ name: newUserName }); // 타입 안전한 입력! } }; return ( <div> <h1>tRPC 풀스택 예제</h1> <section> <h2>사용자</h2> {isLoadingUsers && <p>사용자 로딩 중...</p>} <ul> {users?.map((user) => ( <li key={user.id}> {user.id}: {user.name} </li> ))} </ul> <h3>새 사용자 생성</h3> <input type="text" value={newUserName} onChange={(e) => setNewUserName(e.target.value)} placeholder="새 사용자 이름" /> <button onClick={handleCreateUser} disabled={createUserMutation.isLoading}> {createUserMutation.isLoading ? '생성 중...' : '사용자 생성'} </button> {createUserMutation.isError && <p style={{ color: 'red' }}>사용자 생성 오류: {createUserMutation.error.message}</p>} </section> <section> <h2>ID로 사용자 찾기</h2> <input type="text" value={userIdInput} onChange={(e) => setUserIdInput(e.target.value)} placeholder="사용자 ID 입력 (예: 1)" /> {isLoadingUserById && <p>사용자 로딩 중...</p>} {userById ? ( <p> 찾은 사용자: {userById.id}: {userById.name} </p> ) : ( userIdInput && !isLoadingUserById && <p>사용자를 찾을 수 없습니다.</p> )} </section> </div> ); } export default App;
실행 중인 종단간 타입 안전성:
- API 호출 자동 완성:
trpc.user.
을 입력하면 IDE에서 백엔드의appRouter
에서 추론된getUsers
,getUserById
,createUser
를 제안합니다. - 입력 유효성 검사:
trpc.user.getUserById.useQuery({ id: ... })
를 호출할 때 TypeScript는 백엔드에서z.string()
으로 정의된 대로id
속성이 문자열인지 확인합니다.id: 123
(숫자)을 전달하려고 하면 TypeScript에서 즉시 오류로 표시됩니다.createUserMutation.mutate({ name: '...' })
도 마찬가지입니다. - 출력 유형:
getUsers
에서 반환되는data
(예:users
)는 자동으로Array<{ id: string; name: string; }>
로 형식 지정됩니다.getUserById
에서 반환되는data
(예:userById
)는({ id: string; name: string; } | undefined)
로 형식 지정됩니다. - 오류 처리:
createUserMutation.error
객체도 타입 안전하여 tRPC 서버에서 반환된 오류 구조에 대한 통찰력을 제공합니다.
수동 유형 중복이나 스키마 생성 단계 없이 백엔드에서 프론트엔드까지 원활하게 흐르는 이 타입은 tRPC의 핵심 가치 제안입니다.
애플리케이션 시나리오
tRPC는 여러 애플리케이션 시나리오에서 빛을 발합니다.
- 모노레포: 프론트엔드와 백엔드가 동일한 리포지토리에 있는 경우 tRPC를 통해 유형을 공유하는 것이 매우 쉽고 자연스럽습니다. 이러한 긴밀한 결합은 개발자 경험에 강점이 됩니다.
- 소규모~중규모 프로젝트: GraphQL 스키마 또는 REST 스웨거 생성이 과도하다고 느껴지는 프로젝트의 경우 tRPC는 가볍고 생산적인 대안을 제공합니다.
- 내부 도구: 빠른 개발과 강력한 타입 보장이 중요한 내부 대시보드 또는 도구 구축.
- 프로토타이프 제작: 팀 간의 API 계약에 대해 걱정하지 않고 완전한 타입 안전 프로토타입을 빠르게 구축합니다.
tRPC는 많은 경우에 훌륭하게 작동하지만, 다양한 클라이언트 기술(예: 모바일 앱, 다른 프로그래밍 언어)을 사용하는 고도로 분리된 다중 팀 프로젝트의 경우, 강제화된 언어 불가지론적 계약으로 인해 명시적 스키마가 있는 기존 REST 또는 GraphQL API가 여전히 더 적절한 선택일 수 있음을 인지하는 것이 중요합니다. 그러나 TypeScript 중심의 풀스택 개발의 경우 tRPC는 비할 데 없는 경험을 제공합니다.
결론
tRPC는 풀스택 TypeScript 개발의 중요한 도약으로, 개발자가 API를 생성하고 소비하는 방식에 근본적인 변화를 가져왔습니다. TypeScript의 강력한 타입 추론 시스템을 활용함으로써 tRPC는 백엔드와 프론트엔드 간의 수동 타입 동기화를 힘들고 오류가 발생하기 쉬운 프로세스를 제거합니다. 이는 지능적인 자동 완성, 실시간 타입 유효성 검사 및 런타임 오류 감소를 특징으로 하는 비할 데 없는 개발자 경험으로 이어집니다. tRPC를 사용하면 프론트엔드가 백엔드를 진정으로 이해하게 되어 더 강력하고 유지보수 가능하며 즐거운 풀스택 개발이 가능합니다. 이는 타입 생성을 위한 런타임 오버헤드 없이 진정한 종단간 타입 안전성을 실제로 구현합니다.