
gRPC vs REST vs GraphQL: API 프로토콜 선택 기준
REST는 왜 지금도 지배적인가, GraphQL은 어떤 문제를 해결하는가, gRPC는 언제 진짜 빛나는가. 세 프로토콜의 차이와 선택 기준을 실전 코드와 함께 정리했다.

REST는 왜 지금도 지배적인가, GraphQL은 어떤 문제를 해결하는가, gRPC는 언제 진짜 빛나는가. 세 프로토콜의 차이와 선택 기준을 실전 코드와 함께 정리했다.
API는 한번 공개하면 마음대로 바꾸지 못한다. 클라이언트를 깨트리지 않으면서 API를 진화시키는 버저닝 전략 4가지를 비교하고, GitHub·Stripe·Twilio의 실제 선택을 분석한다.

페이스북은 왜 REST API를 버렸을까? 원하는 데이터만 쏙쏙 골라 담는 GraphQL의 매력과 치명적인 단점 (캐싱, N+1 문제) 분석.

백엔드: 'API 다 만들었어요.' 프론트엔드: '어떻게 써요?' 이 지겨운 대화를 끝내주는 Swagger(OpenAPI)의 마법.

개발자가 직접 하드디스크를 제어할 수 없습니다. 대신 API를 통해 커널에게 '부탁'해야 합니다. 그 부탁의 정체가 바로 시스템 콜입니다.

실제로 봤던 회의다. 새 서비스 API를 설계하는 자리였는데, 백엔드 개발자 셋이 각각 REST, GraphQL, gRPC를 주장하며 45분을 날렸다. 결론은 "일단 REST로 가자"였다.
근데 2년 뒤에 그 서비스가 성장하면서 모바일 클라이언트가 붙었다. 과다 페칭(over-fetching) 문제가 터졌다. GraphQL을 도입했다. 그리고 내부 서비스 간 통신도 많아지면서 gRPC를 추가했다. 결국 세 개를 다 쓰게 됐다.
그 회의에서 "어떤 게 제일 좋아?" 대신 "각각 어디에 쓰는 거야?"를 물었다면 45분을 아꼈을 텐데.
REST(Representational State Transfer)는 2000년에 Roy Fielding이 박사 논문에서 정의했다. 지금도 API의 80% 이상이 REST다. 왜일까?
REST는 HTTP 메서드(GET, POST, PUT, DELETE, PATCH)와 상태 코드(200, 404, 500)를 그대로 쓴다. 추가 학습 없이 HTTP를 아는 사람이면 바로 이해할 수 있다.
GET /users → 사용자 목록 조회
GET /users/123 → 특정 사용자 조회
POST /users → 사용자 생성
PUT /users/123 → 사용자 전체 수정
PATCH /users/123 → 사용자 부분 수정
DELETE /users/123 → 사용자 삭제
# 누구나 바로 테스트 가능
curl -X GET https://api.example.com/users/123 \
-H "Authorization: Bearer token123"
# 응답
{
"id": 123,
"name": "김개발",
"email": "dev@example.com",
"role": "admin",
"createdAt": "2025-01-01T00:00:00Z"
}
HTTP 캐시 헤더를 그대로 활용한다. CDN, 프록시, 브라우저 캐시가 모두 동작한다.
HTTP/1.1 200 OK
Cache-Control: max-age=3600
ETag: "abc123"
Last-Modified: Mon, 01 Jan 2025 00:00:00 GMT
Over-fetching: 필요한 것보다 많은 데이터를 받는다.
// 화면에 이름과 아바타만 필요한데
GET /users/123
→ {
"id": 123,
"name": "김개발",
"email": "...",
"phone": "...",
"address": "...",
"preferences": { ... }, // 필요 없는 데이터 잔뜩
"createdAt": "..."
}
Under-fetching: 하나의 화면을 그리기 위해 여러 번 요청해야 한다.
사용자 프로필 페이지를 그리려면:
GET /users/123 → 사용자 정보
GET /users/123/posts → 사용자 게시글
GET /users/123/followers → 팔로워 수
GET /users/123/following → 팔로잉 수
= 4번 요청
API 버전 관리: /v1/, /v2/ 방식이 URL을 지저분하게 만든다.
/api/v1/users/123
/api/v2/users/123 ← v2에서 응답 구조 바뀜
/api/v3/users/123 ← 이거 유지하는 팀은 고통
GraphQL은 2015년 Facebook이 공개했다. 모바일 앱에서 데이터 페칭 문제를 풀기 위해 만들었다. 핵심 아이디어: 클라이언트가 필요한 데이터를 정확히 명시한다.
# 클라이언트가 이렇게 요청하면
query GetUserProfile($id: ID!) {
user(id: $id) {
name # 이것만
avatar # 이것만
posts(last: 5) {
title
createdAt
}
followerCount # 이것만
}
}
// 응답도 딱 이것만 온다
{
"data": {
"user": {
"name": "김개발",
"avatar": "https://...",
"posts": [
{ "title": "...", "createdAt": "..." }
],
"followerCount": 1024
}
}
}
단 한 번의 요청. 필요한 데이터만. Over/Under-fetching 해결.
GraphQL은 강한 타입 시스템을 가진다. 스키마가 API 문서이자 계약이다.
# schema.graphql
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
followerCount: Int!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
createdAt: String!
}
type Query {
user(id: ID!): User
posts(limit: Int, offset: Int): [Post!]!
}
type Mutation {
createPost(title: String!, content: String!): Post!
updatePost(id: ID!, title: String, content: String): Post!
}
type Subscription {
postCreated: Post!
}
// 서버 측 리졸버 (Node.js + Apollo)
const resolvers = {
Query: {
user: async (_, { id }, context) => {
return await context.db.users.findById(id);
},
posts: async (_, { limit = 10, offset = 0 }, context) => {
return await context.db.posts.findAll({ limit, offset });
},
},
User: {
// N+1 문제를 DataLoader로 해결
posts: async (user, _, context) => {
return await context.loaders.postsByUser.load(user.id);
},
followerCount: async (user, _, context) => {
return await context.db.follows.count({ followeeId: user.id });
},
},
};
N+1 문제: 리졸버가 중첩되면 DB 쿼리가 폭발적으로 늘어난다. DataLoader로 해결하지만, 추가 설정이 필요하다.
캐싱이 어렵다: GET이 아닌 POST로 쿼리를 보내기 때문에 HTTP 캐시가 기본으로 동작하지 않는다.
학습 곡선: 스키마 설계, 리졸버 구현, DataLoader 패턴 - 처음엔 꽤 가파르다.
파일 업로드: 기본 스펙에 없다. 멀티파트 스펙이 별도로 있지만 표준이 아니다.
# 복잡한 쿼리를 클라이언트가 날리면 서버가 힘들다
query Evil {
users {
posts {
comments {
author {
posts {
comments { ... } # 깊이 제한 없으면 위험
}
}
}
}
}
}
깊이 제한(depth limiting), 쿼리 복잡도 제한(complexity limiting) 같은 방어 로직이 필요하다.
gRPC는 2016년 Google이 공개했다. Protocol Buffers(protobuf)를 사용하고, HTTP/2 기반이다. 외부 API보다 내부 서비스 간 통신에 강점이 있다.
JSON 대신 이진 포맷을 쓴다. 정의부터 본다.
// user.proto
syntax = "proto3";
package user;
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc CreateUser(CreateUserRequest) returns (User);
rpc StreamUsers(StreamUsersRequest) returns (stream User);
}
message User {
string id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
}
message GetUserRequest {
string id = 1;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
message StreamUsersRequest {
int32 limit = 1;
}
이 .proto 파일로 코드를 자동 생성한다.
# 코드 생성
protoc --go_out=. --go-grpc_out=. user.proto
protoc --ts_out=. --grpc-web_out=. user.proto
// server.go
package main
import (
"context"
"net"
"google.golang.org/grpc"
pb "myapp/proto/user"
)
type UserServer struct {
pb.UnimplementedUserServiceServer
db *Database
}
func (s *UserServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
user, err := s.db.FindUser(req.Id)
if err != nil {
return nil, status.Errorf(codes.NotFound, "user not found: %v", err)
}
return &pb.User{
Id: user.ID,
Name: user.Name,
Email: user.Email,
CreatedAt: user.CreatedAt.Unix(),
}, nil
}
// 서버 스트리밍
func (s *UserServer) StreamUsers(req *pb.StreamUsersRequest, stream pb.UserService_StreamUsersServer) error {
users, err := s.db.FindUsers(int(req.Limit))
if err != nil {
return err
}
for _, user := range users {
if err := stream.Send(&pb.User{
Id: user.ID,
Name: user.Name,
}); err != nil {
return err
}
}
return nil
}
func main() {
lis, _ := net.Listen("tcp", ":50051")
s := grpc.NewServer()
pb.RegisterUserServiceServer(s, &UserServer{})
s.Serve(lis)
}
// client.ts
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
const packageDef = protoLoader.loadSync("user.proto");
const proto = grpc.loadPackageDefinition(packageDef) as any;
const client = new proto.user.UserService(
"user-service:50051",
grpc.credentials.createInsecure()
);
// 단순 RPC
client.GetUser({ id: "123" }, (err: Error, response: any) => {
console.log(response.name); // "김개발"
});
// 서버 스트리밍
const stream = client.StreamUsers({ limit: 100 });
stream.on("data", (user: any) => {
console.log(user.name);
});
stream.on("end", () => {
console.log("스트리밍 완료");
});
성능: JSON보다 protobuf가 3-10배 작다. HTTP/2 멀티플렉싱으로 연결 효율이 높다.
JSON: {"id":"123","name":"김개발","email":"dev@example.com","createdAt":1735689600}
→ 약 80 bytes
Protobuf: 이진 인코딩
→ 약 20-30 bytes (3x 압축)
스트리밍: 4가지 통신 패턴을 지원한다.
Unary: 클라이언트 1 요청 → 서버 1 응답
Server Streaming: 클라이언트 1 요청 → 서버 n 응답 (스트림)
Client Streaming: 클라이언트 n 요청 → 서버 1 응답
Bidirectional: 클라이언트 n ↔ 서버 n (양방향 스트림)
강한 타입 계약: proto 파일이 서버-클라이언트 계약이다. 코드 생성으로 타입 불일치 버그가 컴파일 타임에 잡힌다.
브라우저 직접 호출 불가: HTTP/2의 트레일러(trailer) 헤더를 브라우저가 지원하지 않는다. grpc-web이나 Envoy 프록시 필요.
디버깅 어려움: 이진 포맷이라 curl로 확인 못 한다. grpcurl, Postman gRPC 지원 등 전용 도구가 필요.
# grpcurl로 테스트
grpcurl -plaintext -d '{"id":"123"}' \
localhost:50051 user.UserService/GetUser
스키마 변경 조심: 필드 번호를 잘못 변경하면 데이터 손상이 일어난다.
// 위험한 변경
message User {
string id = 1;
// string name = 2; ← 삭제하면 기존 데이터의 필드 2가 오염됨
string email = 2; // ← 절대 이렇게 하면 안 됨
}
// 안전한 변경
message User {
string id = 1;
reserved 2; // 이전에 name이었던 필드 번호 예약
string email = 3; // 새 번호 부여
}
| 항목 | REST | GraphQL | gRPC |
|---|---|---|---|
| 전송 형식 | JSON/XML | JSON | Protobuf (이진) |
| 프로토콜 | HTTP/1.1 | HTTP/1.1 | HTTP/2 |
| 브라우저 지원 | 네이티브 | 네이티브 | grpc-web 필요 |
| 캐싱 | HTTP 캐시 기본 지원 | 별도 구현 필요 | 없음 |
| 타입 안전성 | 없음 (OpenAPI로 보완) | 스키마 타입 | proto 타입 |
| 학습 곡선 | 낮음 | 중간 | 높음 |
| Over-fetching | 있음 | 없음 | 없음 |
| 스트리밍 | 제한적 (SSE) | 구독(Subscription) | 네이티브 |
| 성능 | 보통 | 보통 | 높음 |
| 생태계 | 매우 성숙 | 성숙 | 성장 중 |
| 주요 사용처 | 퍼블릭 API | 복잡한 데이터 조회 | 내부 서비스 통신 |
Stripe API, GitHub API, Twitter API → 모두 REST
이유: 외부 개발자 친화성, 문서화 용이, 캐싱
GitHub GraphQL API, Shopify → GraphQL
이유: 파트너마다 다른 데이터 요구사항
Google 내부 서비스들, Netflix 내부 통신 → gRPC
이유: 성능, 타입 안전성, 다언어 지원
실무에서는 셋 중 하나만 쓰는 경우가 드물다.
아키텍처 예시:
외부 클라이언트 (모바일/웹)
↓ REST or GraphQL
API Gateway / BFF (Backend For Frontend)
↓ gRPC
┌─────────────────────────────────────┐
│ User Service Payment Service ... │
│ (gRPC) (gRPC) ... │
└─────────────────────────────────────┘
BFF(Backend For Frontend) 패턴: 클라이언트에겐 REST나 GraphQL, 내부 서비스 간엔 gRPC.
// BFF 레이어: GraphQL → gRPC 변환
const resolvers = {
Query: {
user: async (_, { id }) => {
// 내부적으로 gRPC 호출
return await grpcUserClient.getUser({ id });
},
},
};
"어떤 게 제일 좋아?"는 잘못된 질문이다. 올바른 질문은 "이 상황에서 뭐가 맞아?"다.
그리고 셋을 조합하는 것도 완전히 유효한 선택이다. API Gateway에서 REST/GraphQL, 내부는 gRPC. 이게 많은 성숙한 서비스들이 하는 방식이다.