REST vs GraphQL vs tRPC vs gRPC 2026: API 아키텍처 선택 완벽 가이드
새 프로젝트를 시작하려고 빈 파일을 열면, 바로 이 논쟁이 시작돼요.
"REST 쓰면 되지 않나? 다들 아는 거잖아." 그러면 팀원 한 명이 "전 직장에서 GraphQL 썼는데 진짜 좋았어요"라고 말하고, 또 다른 엔지니어가 "tRPC가 미래"라고 끼어들고, 시니어 백엔드 개발자는 팔짱 끼고 "마이크로서비스엔 gRPC가 답"이라고 주장하죠.
이 논쟁, 모든 팀에서 모든 새 프로젝트마다 벌어져요. 2026년에도 정답은 여전히 "그때그때 달라요" — 다만 이제는 그 판단을 내릴 수 있는 데이터가 훨씬 많아졌어요.
이 가이드는 REST, GraphQL, tRPC, gRPC 각각이 오늘날 프로덕션에서 실제로 어떻게 동작하는지 비교해요. 2020년 튜토리얼이 아니라 지금 기준이에요. 아키텍처, 성능, 개발자 경험, 그리고 아무도 말 안 하는 진짜 비용까지 다 다루고, 마지막에는 더 이상 싸우지 않고 바로 골라서 쓸 수 있는 결정 프레임워크를 드릴게요.
판이 바뀌었어요
이 기술들에 대한 인식이 2022년에 멈춰 있다면, 지금 현실이랑 꽤 달라요:
2022년 이후로 바뀐 것들:
REST:
→ OpenAPI 3.1이 사실상 표준 (JSON Schema 정렬 완료)
→ Fetch API가 어디서나 (Node, Deno, Bun, 브라우저)
→ HTMX가 REST를 프론트엔드 담론으로 복귀시킴
GraphQL:
→ Federation v2 성숙 (Apollo, Grafbase, WunderGraph)
→ Relay Compiler가 React Server Components와 연동
→ Subscriptions은 여전히 어색; 대부분 SSE 사용
tRPC:
→ v11 출시: React Server Components 네이티브 지원
→ TanStack Start + tRPC가 새로운 풀스택 정석
→ 여전히 TypeScript 전용 (그게 포인트)
gRPC:
→ gRPC-Web 안정화; Connect 프로토콜 채택 증가
→ Buf.build + ConnectRPC가 DX를 극적으로 개선
→ Protocol Buffers → TypeScript 코드젠이 이제 고통 없음
핵심은 이거예요: 만능인 건 하나도 없어요. 다 최적화된 방향이 달라요. 진짜 실수는 우리 상황을 안 보고 유행 따라 고르는 거예요.
30초 복습: 각각 뭐하는 놈인가
비교 들어가기 전에 기본기부터 빠르게 짚고 가요:
REST
Client: GET /api/users/123
Server: { "id": 123, "name": "Alice", "email": "[email protected]" }
Client: GET /api/users/123/orders?limit=5
Server: [{ "id": 1, "product": "Widget", "total": 29.99 }, ...]
리소스 중심이에요. URL 하나당 리소스 하나. HTTP 동사(GET, POST, PUT, DELETE)로 연산을 정의하고, 서버가 어떤 데이터를 줄지 결정해요.
GraphQL
query { user(id: 123) { name email orders(limit: 5) { product total } } }
HTTP 위의 쿼리 언어예요. 엔드포인트 하나(/graphql). 클라이언트가 필요한 데이터를 직접 골라서 요청하고, 서버가 타입 시스템으로 필드를 resolve해요.
tRPC
// Server (라우터 정의) export const appRouter = router({ user: router({ getById: publicProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { return db.users.findUnique({ where: { id: input.id } }); }), }), }); // Client (직접 함수 호출 — 코드젠 없음, fetch 없음) const user = await trpc.user.getById.query({ id: 123 }); // ^? { id: number, name: string, email: string }
TypeScript 추론으로 양쪽 타입이 자동으로 맞아요. 별도 스키마 언어 없고, 코드젠도 없어요. 라우터 자체가 API 계약이에요.
gRPC
// user.proto service UserService { rpc GetUser (GetUserRequest) returns (User); rpc ListOrders (ListOrdersRequest) returns (stream Order); } message User { int32 id = 1; string name = 2; string email = 3; }
HTTP/2 위에서 돌아가는 바이너리 프로토콜(Protocol Buffers)이에요. 스키마 먼저 정의하고 코드를 생성하는 방식이고, 스트리밍이 기본 지원돼요. 서버끼리 통신하라고 만든 거예요.
진짜 비교: 실제로 뭐가 중요한가
성능
다들 숨기는 것 — 같은 작업(유저 + 주문 5건 조회)으로 실제 측정한 레이턴시랑 페이로드 크기:
프로토콜 페이로드(bytes) 직렬화 레이턴시(p50) 레이턴시(p99)
────────────── ────────────── ────────── ──────────── ────────────
REST (JSON) 1,247 ~0.3ms 12ms 45ms
GraphQL 834 ~0.5ms 15ms 55ms
tRPC (JSON) 1,180 ~0.2ms 11ms 40ms
gRPC (proto) 312 ~0.1ms 4ms 12ms
참고:
- REST는 ~30% 불필요한 필드까지 가져옴 (over-fetching)
- GraphQL은 리졸버 오버헤드 있음 (필드 단위 해석)
- tRPC는 raw REST 대비 오버헤드가 거의 없음
- gRPC는 와이어 크기에서 압승이지만 HTTP/2 필요
- 모두 Node.js 22, 같은 머신, 같은 DB에서 측정
핵심: 브라우저→서버 호출에서 REST, GraphQL, tRPC 성능 차이는 별로 안 나요. 어차피 네트워크 레이턴시가 지배적이거든요. gRPC가 빛나는 건 양쪽 다 내가 컨트롤하고, 초당 수천 번 호출하는 서버 간 통신뿐이에요.
타입 안전성
진짜 차이가 나는 건 여기예요:
프로토콜 스키마 소스 클라이언트 타입 런타임 검증
─────────── ────────────────── ──────────────── ─────────────────
REST OpenAPI (선택 사항) 코드젠 필요 수동
GraphQL SDL (필수) 코드젠 필요 스키마 검증
tRPC TypeScript 그 자체 자동 (추론) Zod 내장
gRPC Protobuf (필수) 코드젠 필요 Proto 검증
// REST: 타입을 직접 작성 (맞기를 기도) const res = await fetch('/api/users/123'); const user = await res.json() as User; // 🤷 날 믿어줘 // GraphQL: 스키마에서 코드젠 (빌드 스텝 하나 더) const { data } = useQuery(GET_USER); // 코드젠 돌리면 타입 있음 // tRPC: 타입이 자동으로 흐름 (추가 작업 제로) const user = await trpc.user.getById.query({ id: 123 }); // ^? 서버의 Zod 스키마 + 리턴 타입에서 추론 // gRPC: .proto에서 코드젠 (빌드 스텝 하나 더) const user = await client.getUser({ id: 123 }); // proto에서 타입 생성
tRPC의 킬러 장점: 서버에서 필드명 하나 바꾸면 → 클라이언트 코드에 빨간 밑줄이 즉시 뜨죠. 빌드 스텝 없이. 코드젠 없이. "타입 다시 생성했나?" 불안 없이.
tRPC의 킬러 단점: 클라이언트와 서버가 둘 다 TypeScript이고, 같은 저장소(또는 공유 패키지) 안에 있어야만 동작해요.
개발자 경험
매일 이걸로 코드 짜면 어떤 느낌인지 솔직히 말해볼게요:
REST:
✅ 다들 아는 거 (학습 곡선 제로)
✅ Curl 친화적 (디버깅 쉬움)
✅ 어마어마한 툴 생태계
❌ 자동 타입 안전성 없음
❌ Over-fetching / under-fetching이 기본
❌ 버전 관리가 엉망 (v1, v2, v3...)
❌ 복잡한 UI에서 N+1 엔드포인트 문제
GraphQL:
✅ 클라이언트 주도 쿼리 (UI에 필요한 것만 조회)
✅ 스키마가 곧 문서
✅ 복잡하고 중첩된 데이터에 강함
❌ 캐싱이 어려움 (HTTP 캐싱 안녕)
❌ 리졸버 레벨에서 N+1 쿼리 문제
❌ Mutation이 덧붙인 느낌
❌ 전체 스택 학습 곡선 가파름
❌ 파일 업로드가 고통
tRPC:
✅ 제로 오버헤드 타입 안전성
✅ 배울 스키마 언어 없음
✅ 모노레포 DX 최고
✅ Mutation이 자연스러움
❌ TypeScript 전용 (양쪽 다)
❌ 퍼블릭 API에 부적합
❌ 클라이언트-서버 간 강한 결합
❌ REST/GraphQL보다 생태계 작음
gRPC:
✅ 날것의 성능 최강
✅ 네이티브 스트리밍 (양방향)
✅ 뛰어난 하위 호환성
✅ 다중 언어 코드젠
❌ 브라우저 네이티브 아님 (프록시 / Connect 필요)
❌ Protobuf이라는 언어 하나 더 배워야 함
❌ 디버깅 고통 (바이너리 프로토콜)
❌ 학습 곡선 가파름
캐싱
이건 REST가 넘사벽인 영역이에요:
REST:
HTTP 캐싱이 그냥 됨™
- CDN 캐싱 (Cache-Control 헤더)
- 브라우저 캐싱 (ETag, 조건부 요청)
- 프록시 캐싱 (Varnish, Nginx)
- URL 하나 = 고유한 캐시 키
GraphQL:
HTTP 캐싱이 사실상 깨져 있음
- 단일 엔드포인트에 POST = URL 기반 캐싱 불가
- GET 기반 캐싱을 위해 Persisted Queries 필요
- 전용 캐싱 레이어 필요 (Apollo, Stellate)
- 캐시 무효화가 복잡 (정규화된 캐시)
tRPC:
HTTP 캐싱 동작함 (쿼리는 GET)
- TanStack Query가 클라이언트 캐싱 처리
- 적절한 헤더로 CDN 캐싱 가능
- 캐시 키 = procedure 경로 + input
gRPC:
HTTP 캐싱 없음 (바이너리 프로토콜)
- 커스텀 캐싱 인프라 필요
- 보통 서비스 메시 레벨에서 해결 (Envoy, Istio)
- 요청 메시지 해시로 캐싱
공개 데이터나 잘 안 바뀌는 리소스를 서빙하는 API라면, 캐싱에서 REST를 이길 수가 없어요.
N+1 문제: 다들 겪는데, 해결법이 다 달라요
N+1은 어떤 API를 쓰든 한 번은 발등에 불 떨어지는 문제예요. 각각 어떻게 풀어가는지 볼게요:
REST N+1
클라이언트가 필요한 것:
- 유저 프로필
- 유저의 최신 주문 10건
- 각 주문의 배송 상태
REST 방식 (순진하게):
GET /api/users/123 → 1 요청
GET /api/users/123/orders → 1 요청
GET /api/orders/1/shipping → 1 요청
GET /api/orders/2/shipping → 1 요청
... (10개 더) → 10 요청
총: 12 HTTP 요청 😱
REST 방식 (똑똑하게):
GET /api/users/123?include=orders.shipping → 1 요청
(또는 데이터를 모아주는 BFF 엔드포인트)
GraphQL N+1
# 클라이언트는 요청 하나로 끝! (좋아요!) query { user(id: 123) { name orders(last: 10) { id shipping { status, eta } # ← 여기서 리졸버 레벨 N+1 발생 } } }
// 서버 쪽 문제: const resolvers = { Order: { shipping: (order) => db.shipping.findByOrderId(order.id) // 10번 호출됨! 주문당 한 번! } } // 해법: DataLoader const shippingLoader = new DataLoader( (orderIds) => db.shipping.findByOrderIds(orderIds) ); const resolvers = { Order: { shipping: (order) => shippingLoader.load(order.id) // 쿼리 하나로 배치 처리 } }
tRPC N+1
// tRPC는 기본적으로 이 문제가 없어요 // 하나의 procedure에서 전체 쿼리를 컨트롤하니까: const userWithOrders = await trpc.user.getWithOrders.query({ id: 123 }); // 서버 쪽: JOIN이나 배치 로딩으로 한 방 쿼리 // 데이터 페칭 로직을 직접 작성하니까 쿼리를 직접 컨트롤
gRPC N+1
// gRPC는 서비스 경계에서 해결: rpc GetUserWithOrders(GetUserRequest) returns (UserWithOrders); // 또는 스트리밍 사용: rpc StreamOrderUpdates(OrderRequest) returns (stream OrderUpdate);
정리하면: GraphQL은 N+1을 클라이언트에서 서버로 옮기는 거예요. REST는 클라이언트가 알아서 하라는 거고요. tRPC랑 gRPC는 애초에 한 방에 다 가져오는 procedure/RPC를 만들 수 있어서 문제 자체를 피해요.
실전 아키텍처 패턴
패턴 1: 풀스택 TypeScript 앱 (tRPC)
적합: SaaS 앱, 대시보드, 내부 툴
┌──────────────────────────────────────┐
│ Next.js / TanStack Start 프론트엔드 │
│ (React + TanStack Query) │
│ │ │
│ tRPC Client │
│ │ (타입 추론) │
│ ▼ │
│ tRPC Server (Zod 검증) │
│ │ │
│ Database (Prisma / Drizzle) │
└──────────────────────────────────────┘
왜 잘 동작하나:
- DB 컬럼 하나 바꾸면 → UI 레이어에서 즉시 타입 에러
- API 문서 작성 제로 (TypeScript가 곧 문서)
- Zod가 인풋을 검증하고, Prisma가 아웃풋을 검증
- 하나의 레포, 하나의 언어, 하나의 타입 시스템
패턴 2: 퍼블릭 API 플랫폼 (REST + OpenAPI)
적합: 개발자 플랫폼, 공개 API, 멀티 클라이언트 앱
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 웹 클라이언트│ │ 모바일 앱 │ │ 서드파티 │
└─────┬──────┘ └──────┬─────┘ └──────┬─────┘
│ │ │
└────────────┬────┘─────────────────┘
▼
┌──────────────┐
│ REST API │
│ (OpenAPI 3.1)│
│ + Swagger │
└──────┬───────┘
│
┌──────▼───────┐
│ Services │
└──────────────┘
왜 잘 동작하나:
- 어떤 언어/플랫폼에서든 소비 가능
- OpenAPI가 모든 언어용 SDK 생성
- HTTP 캐싱 + CDN = 공짜 스케일링
- REST는 누구나 이해
패턴 3: 데이터가 많은 대시보드 (GraphQL)
적합: 분석 대시보드, CMS, 멀티 엔티티 어드민 패널
┌────────────────────────────────────────┐
│ 어드민 대시보드 (React) │
│ │
│ ┌─────────┐ ┌──────────┐ ┌────────┐│
│ │ 유저 │ │ 분석 │ │ 콘텐츠 ││
│ │ 패널 │ │ 차트 │ │ 에디터 ││
│ └────┬────┘ └────┬─────┘ └───┬────┘│
│ │ │ │ │
│ └─────── GraphQL ─────────┘ │
│ (뷰당 쿼리 하나) │
└───────────────────┬────────────────────┘
▼
┌───────────────┐
│ GraphQL 서버 │
│ (Federation) │
├───────────────┤
│ Users 서비스 │
│ Analytics DB │
│ CMS 서비스 │
└───────────────┘
왜 잘 동작하나:
- 각 패널이 필요한 데이터만 정확히 가져감
- 뷰당 요청 하나 (워터폴 없음)
- Federation으로 팀별 스키마 소유
- 스키마 = 자동 문서
패턴 4: 마이크로서비스 백엔드 (gRPC)
적합: 고처리량 백엔드, 폴리글랏 서비스, 실시간 시스템
┌──────────────┐
│ API Gateway │ (외부엔 REST/GraphQL)
└──────┬───────┘
│ gRPC (내부)
▼
┌──────────────┐ ┌──────────────┐
│ User Service │◄───►│ Order Service│
│ (Go) │ │ (Rust) │
└──────┬───────┘ └──────┬───────┘
│ │
│ gRPC │ gRPC
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Auth Service │ │ Payment Svc │
│ (Python) │ │ (Java) │
└──────────────┘ └──────────────┘
왜 잘 동작하나:
- 바이너리 프로토콜 = 대역폭 5~10배 절감
- 실시간 업데이트용 스트리밍
- Proto 스키마 = 언어 간 계약
- 서비스 메시가 디스커버리 + 로드밸런싱 처리
하이브리드가 현실이에요
아무도 "REST vs GraphQL" 글에서 안 말하는 진실: 대부분의 프로덕션 시스템은 여러 개를 같이 써요.
2026년 전형적인 SaaS 아키텍처:
외부:
┌─────────────────┐
│ 퍼블릭 REST API │ (인테그레이션, 웹훅, SDK용)
└────────┬────────┘
│
내부:
┌────────▼────────┐
│ tRPC / GraphQL │ (자사 프론트엔드용)
└────────┬────────┘
│
백엔드:
┌────────▼────────┐
│ gRPC / REST │ (서비스 간 통신)
└─────────────────┘
이건 오버엔지니어링이 아니에요 — 쓰는 사람이 다르니까 맞는 도구도 다른 거예요:
- 외부 개발자한테는 안정적이고 문서 잘 된 API가 필요 → REST + OpenAPI
- 우리 프론트엔드에선 DX랑 타입 안전성이 최우선 → tRPC (클라이언트 여럿이면 GraphQL)
- 서버끼리는 성능이랑 스키마 진화가 중요 → gRPC (간단하면 REST도 OK)
어떻게 고르냐고요?
논쟁 그만하고 이 플로우차트 따라가세요:
START: 누가 이 API를 소비하나?
├── 외부 개발자 / 퍼블릭 API
│ └── REST + OpenAPI 3.1
│ (범용적, 캐시 가능, 다들 이해)
│
├── 자사 프론트엔드 (TypeScript 모노레포)
│ ├── 데이터 구조 단순?
│ │ └── tRPC
│ │ (제로 오버헤드, 최대 타입 안전성)
│ └── 복잡한 중첩 데이터 / 클라이언트 여러 개?
│ └── GraphQL
│ (유연한 쿼리, 클라이언트 주도)
│
├── 서비스 간 통신 (내부 마이크로서비스)
│ ├── 스트리밍 / 고처리량 필요?
│ │ └── gRPC
│ │ (바이너리 프로토콜, 네이티브 스트리밍)
│ └── 서비스 몇 개 간 단순 CRUD?
│ └── REST
│ (심플하게 가자)
│
└── 잘 모르겠어 / 프로토타이핑 단계?
└── REST로 시작
(나중에 언제든 전환 가능)
"이걸 고르면 안 되는" 시나리오
때로는 뭘 안 고를지 아는 게 최고의 조언이에요:
❌ GraphQL 쓰면 안 되는 경우:
- 데이터가 단순하고 평면적 (CRUD 앱)
- HTTP 캐싱을 적극적으로 써야 함
- 팀에 GraphQL 경험이 전혀 없음
- 프론트엔드 하나에 예측 가능한 데이터 요구사항
❌ tRPC를 쓰면 안 되는 경우:
- 클라이언트가 TypeScript가 아님
- 퍼블릭 API가 필요함
- C/S가 다른 저장소에 있고 배포 주기도 다름
- 모바일 앱도 같은 API를 소비
❌ gRPC를 쓰면 안 되는 경우:
- 브라우저 클라이언트만 있음 (되긴 하는데 고통)
- 서비스 5개 미만 (오버킬)
- 팀이 Protocol Buffers 배우기 싫어함
- 디버깅할 때 와이어 포맷을 사람이 읽어야 함
❌ REST를 쓰면 안 되는 경우:
- 프론트엔드가 깊게 중첩된 가변 데이터 필요
- TypeScript 모노레포 앱 (tRPC가 엄격히 더 나음)
- 양방향 실시간 스트리밍 필요
전환 경로: 한 번 고르면 끝? 아니에요
제일 무서운 게 잘못 골라서 못 빠져나오는 거잖아요. 다행히 전환 경로는 잘 닦여 있어요:
REST → GraphQL
// 기존 REST 엔드포인트를 GraphQL 리졸버로 래핑 const resolvers = { Query: { user: async (_, { id }) => { const res = await fetch(`${REST_BASE}/users/${id}`); return res.json(); }, orders: async (_, { userId }) => { const res = await fetch(`${REST_BASE}/users/${userId}/orders`); return res.json(); }, }, }; // 점진적으로 리졸버를 직접 DB 접근으로 전환 // 클라이언트 마이그레이션: 쿼리 하나씩
REST → tRPC
// tRPC는 같은 서버에서 REST와 공존 가능 import { createExpressMiddleware } from '@trpc/server/adapters/express'; const app = express(); // 기존 REST 라우트는 계속 동작 app.get('/api/v1/users/:id', existingHandler); // 새 tRPC 라우터를 나란히 마운트 app.use('/trpc', createExpressMiddleware({ router: appRouter })); // 엔드포인트를 하나씩 전환
GraphQL → tRPC
// TypeScript 모노레포라면 전환이 간단해요: // 1. GraphQL 쿼리에 대응하는 tRPC procedure 정의 // 2. 컴포넌트 하나씩 마이그레이션 // 3. 안 쓰이는 GraphQL 리졸버 제거 // Before (GraphQL): const { data } = useQuery(gql` query GetUser($id: ID!) { user(id: $id) { name, email } } `); // After (tRPC): const { data } = trpc.user.getById.useQuery({ id }); // 같은 결과, 코드젠 없이, 즉각적인 타입 피드백
비용 분석: 아무도 말 안 하는 숨은 비용
개발 시간 말고도, 각 프로토콜에는 인프라 비용 차이가 있어요:
인프라 비용 비교 (대규모: 1,000만 요청/일 기준):
REST GraphQL tRPC gRPC
────────────────── ────────── ────────── ────────── ──────────
CDN 캐싱 매우 좋음 나쁨 좋음 N/A
대역폭 기준 -20~30% ~기준 -60~80%
서버 CPU 기준 +20~40% ~기준 -10~20%
툴링 비용 무료 $$ 무료 $
모니터링 표준 전문 도구 표준 전문 도구
게이트웨이/프록시 표준 GraphQL GW 표준 gRPC 프록시
숨은 비용:
REST: API 버전 관리 유지보수
GraphQL: 쿼리 복잡도 분석, 쿼리 비용 기반 레이트 리미팅
tRPC: TypeScript 의존성 외에 없음
gRPC: Proto 관리, 서비스 메시
GraphQL의 숨은 비용: 규모가 커지면 쿼리 복잡도 분석, persisted queries, depth limiting, 전용 APM 도구가 필요해요. 이 인프라 세금은 실제로 꽤 크고, 많은 팀이 뒤늦게 깨달아요.
gRPC의 숨은 대역폭 절감: 서비스 간 트래픽이 가장 큰 비용이라면 (마이크로서비스에서 흔함), gRPC의 바이너리 인코딩이 대역폭을 60~80% 줄여요.
2026년 결론
바쁘신 분들을 위한 요약:
| 시나리오 | 최선의 선택 | 차선 |
|---|---|---|
| 퍼블릭 API | REST + OpenAPI | GraphQL |
| TypeScript 모노레포 SaaS | tRPC | REST |
| 멀티 플랫폼 (웹 + 모바일 + 서드파티) | GraphQL | REST |
| 마이크로서비스 (내부) | gRPC | REST |
| 단순 CRUD 앱 | REST | tRPC |
| 실시간 양방향 데이터 | gRPC | GraphQL (subscriptions) |
| 데이터 볼륨 큰 어드민 | GraphQL | tRPC |
| 프로토타이핑 / MVP | REST | tRPC |
제일 중요한 건: 이건 종교가 아니에요. 잘하는 팀일수록 레이어마다 다른 프로토콜을 섞어 써요. 외부는 REST, 프론트엔드는 tRPC, 백엔드끼리는 gRPC. 이건 도구지, 내 정체성이 아니에요.
"뭐가 객관적으로 더 좋냐"는 논쟁은 그만하고, 이렇게 물어보세요: "이 API를 누가 쓸 거고, 그 사람들 상황이 뭐고, 우리 팀이 뭘 잘 하지?"
비교표 말고 — 그 질문이 답을 줄 거예요.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요