개요
REST API의 한계점과 RPC의 해결 방법을 살펴봅니다. Tanstack Start와 Next.js 구현 사례로 RPC의 핵심 개념과 타입 안전성을 설명하고, RPC 도입 판단 기준을 제시합니다.
1. REST API 개발의 현실적 문제점
반복되는 boilerplate 코드 작성
REST API 개발에서 반복되는 작업들입니다.
클라이언트에서는 API 엔드포인트별로 fetch 함수를 작성하고, 에러 처리 로직을 반복해서 구현해야 합니다. 서버에서는 라우터 설정, 미들웨어 연결, 요청/응답 검증 코드를 계속 작성하게 됩니다.
// 클라이언트 - 매번 반복되는 패턴 async function getUser(id) { try { const response = await fetch(`/api/users/${id}`) if (!response.ok) { throw new Error('Failed to fetch user') } return await response.json() } catch (error) { console.error('Error fetching user:', error) throw error } } // 서버 - 라우터와 검증 로직 app.get('/api/users/:id', async (req, res) => { try { const { id } = req.params if (!id) { return res.status(400).json({ error: 'User ID is required' }) } const user = await getUserById(id) res.json(user) } catch (error) { res.status(500).json({ error: 'Internal server error' }) } })
클라이언트-서버 간 타입 불일치 문제
TypeScript를 사용해도 클라이언트와 서버 간 타입 동기화는 쉽지 않습니다. API 스펙이 변경되면 클라이언트 타입도 수동으로 업데이트해야 하고, 런타임에서 예상과 다른 데이터가 올 수 있습니다.
// 서버에서 User 타입을 변경했지만 interface User { id: string name: string email: string createdAt: Date // 새로 추가된 필드 } // 클라이언트는 여전히 이전 타입을 사용 interface ClientUser { id: string name: string email: string // createdAt이 없어서 런타임 에러 발생 가능 }
API 문서와 실제 구현의 동기화 어려움
개발 과정에서 API 스펙이 변경되면 문서 업데이트를 깜빡하기 쉽습니다. 프론트엔드 개발자가 오래된 문서로 개발하면 실제 API와 맞지 않아 디버깅 시간이 늘어납니다.
2. RPC가 제시하는 해결 방향
"함수 호출하듯 서버와 통신하기"의 의미
RPC(Remote Procedure Call)는 원격 서버의 함수를 로컬 함수처럼 호출할 수 있게 합니다.
HTTP 요청/응답 처리를 추상화하여 개발자가 함수 호출처럼 코드를 작성할 수 있습니다.
// REST API 방식 const response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'John', email: 'john@example.com' }) }) const user = await response.json() // RPC 방식 - 함수 호출처럼 간단 const user = await createUser({ name: 'John', email: 'john@example.com' })
네트워크 경계를 숨기는 추상화 개념
RPC는 네트워크 통신의 복잡성을 개발자로부터 숨깁니다. 에러 처리, 재시도 로직, 직렬화/역직렬화 등이 프레임워크 레벨에서 자동으로 처리되어, 개발자는 비즈니스 로직에만 집중할 수 있습니다.
풀스택 환경에서 RPC가 갖는 특별한 장점
같은 TypeScript 코드베이스에서 클라이언트와 서버를 함께 개발할 때, RPC는 더욱 강력해집니다. 타입 정보가 자동으로 공유되고, 함수 시그니처 변경 시 컴파일 타임에 오류를 잡을 수 있습니다.
3. 주요 프레임워크별 RPC 구현 방식
Tanstack Start의 Server Functions
Tanstack Start는
createServerFn
을 통해 서버 함수를 정의합니다.import { createServerFn } from '@tanstack/react-start' import { z } from 'zod' // 서버 함수 정의 const getUserById = createServerFn({ method: 'GET' }) .validator(z.string()) // Zod로 입력 검증 .handler(async ({ data }) => { return db.query.users.findFirst({ where: eq(users.id, data) }) }) // 클라이언트에서 사용 const user = await getUserById({ data: '1' })
강력한 타입 추론: TypeScript가 서버 함수의 입력과 출력 타입을 자동으로 추론합니다. 함수 시그니처가 변경되면 클라이언트 코드에서 즉시 타입 에러가 발생합니다.
통합된 검증: Zod 스키마를 사용해 런타임 검증과 타입 추론을 동시에 처리합니다.
라우터 통합: TanStack Router와 자연스럽게 연동되어 route loader에서 서버 함수를 직접 호출할 수 있습니다.
export const Route = createFileRoute('/users/$id')({ loader: async ({ params }) => { const user = await getUserById({ data: params.id }) return { user } }, })
Next.js의 Server Actions
Next.js는 React의 Server Actions를 활용해 RPC 스타일의 개발을 지원합니다.
'use server' export async function createUser(formData: FormData) { const name = formData.get('name') as string const email = formData.get('email') as string // 서버에서 직접 데이터베이스 작업 const user = await db.user.create({ data: { name, email } }) revalidatePath('/users') // 캐시 무효화 return user } // 클라이언트 컴포넌트에서 사용 'use client' export function CreateUserForm() { return ( <form action={createUser}> <input name="name" /> <input name="email" /> <button type="submit">Create User</button> </form> ) }
React 통합: form의 action prop에 서버 함수를 직접 전달할 수 있어, React의 선언적 특성을 유지합니다.
Progressive Enhancement: JavaScript가 비활성화되어도 form 제출이 동작합니다.
자동 직렬화: Next.js가 서버 함수를 클라이언트에서 호출할 수 있도록 자동으로 직렬화 처리합니다.
4. End-to-End 타입 안전성 구현
Zod + TypeScript 조합의 실제 활용
현대 RPC 구현에서 Zod는 핵심적인 역할을 합니다. 런타임 검증과 타입 추론을 동시에 제공하기 때문입니다.
import { z } from 'zod' // 스키마 정의 const CreateUserSchema = z.object({ name: z.string().min(1), email: z.string().email(), age: z.number().min(18) }) // Tanstack Start에서 사용 const createUser = createServerFn({ method: 'POST' }) .validator(CreateUserSchema) .handler(async ({ data }) => { // data는 자동으로 올바른 타입을 가짐 // { name: string, email: string, age: number } return await db.user.create({ data }) }) // 클라이언트에서 호출 시 타입 검증 const user = await createUser({ data: { name: 'John', email: 'john@example.com', age: 25 } }) // 잘못된 타입으로 호출하면 컴파일 에러 // createUser({ data: { name: 123 } }) // 타입 에러!
REST API 대비 개발 경험 개선 효과
즉시 피드백: 함수 시그니처 변경 시 IDE에서 즉시 오류를 표시합니다.
자동완성: 서버 함수의 매개변수와 반환값에 대한 정확한 자동완성을 제공합니다.
리팩토링 안전성: 서버 함수 이름이나 타입을 변경하면 모든 사용처에서 자동으로 업데이트됩니다.
// 서버 함수 시그니처 변경 const getUser = createServerFn({ method: 'GET' }) .validator(z.object({ id: z.string(), includeProfile: z.boolean() })) // 새 필드 추가 .handler(async ({ data }) => { /* ... */ }) // 모든 클라이언트 호출부에서 컴파일 에러 발생 const user = await getUser({ data: { id: '1' } }) // 에러: includeProfile이 없음
5. RPC 도입 판단 기준
REST API와 RPC의 장단점 비교
RPC의 장점
- 개발 속도 향상: boilerplate 코드 제거
- 강력한 타입 안전성: 컴파일 타임 검증
- 일관된 에러 처리: 프레임워크 레벨에서 표준화
- 자동 문서화: 타입 정보가 곧 문서
RPC의 단점
- 프레임워크 종속성: 특정 프레임워크에 강하게 결합
- 캐싱 복잡성: HTTP 캐싱 전략 적용이 어려움
- 디버깅 어려움: 네트워크 레이어가 추상화되어 있음
REST API의 장점
- 표준화: HTTP 표준을 따르는 범용적 접근
- 캐싱 친화적: HTTP 캐싱 헤더 활용 가능
- 디버깅 용이: 명확한 HTTP 요청/응답 구조
- 클라이언트 독립성: 다양한 클라이언트에서 사용 가능
각 방식이 적합한 프로젝트 상황
RPC를 선택해야 하는 경우
- 풀스택 TypeScript 프로젝트
- 빠른 개발 속도가 중요한 MVP나 내부 도구
- 팀 전체가 같은 프레임워크를 사용하는 환경
- 복잡한 비즈니스 로직이 많은 애플리케이션
REST API를 선택해야 하는 경우
- 다양한 클라이언트(모바일 앱, 써드파티)를 지원해야 하는 경우
- 마이크로서비스 아키텍처
- 팀이 서로 다른 기술 스택을 사용하는 경우
- 퍼블릭 API 제공이 필요한 경우
기존 시스템 마이그레이션 고려사항
점진적 마이그레이션이 가능합니다. 새로운 기능부터 RPC로 개발하고, 기존 REST API는 그대로 유지하는 하이브리드 접근법을 취할 수 있습니다.
// 기존 REST API와 새로운 RPC 함수 공존 const legacyGetUser = async (id: string) => { const response = await fetch(`/api/legacy/users/${id}`) return response.json() } const newGetUser = createServerFn({ method: 'GET' }) .validator(z.string()) .handler(async ({ data: id }) => { return getUserById(id) })
6. 마무리
RPC는 웹 개발의 복잡성을 줄이고 개발자 경험을 향상시키는 해결책입니다. 모든 프로젝트에 적합하지는 않지만, 풀스택 TypeScript 환경에서는 개발 생산성과 코드 품질을 동시에 높일 수 있습니다.
적절한 상황에서 RPC를 도입한다면 반복적인 boilerplate 코드 작성에서 벗어나 비즈니스 로직에 집중할 수 있을 것입니다.
RPC는 웹 개발의 복잡성을 줄이고 개발자 경험을 향상시키는 근본적인 해결책입니다. 모든 프로젝트에 적합하지는 않지만, 적절한 상황에서 사용한다면 개발 생산성과 코드 품질을 동시에 높일 수 있는 강력한 도구가 될 것입니다.