본문으로 건너뛰기
Previous
Next
API 설계 가이드 | REST vs GraphQL vs gRPC 완벽 비교

API 설계 가이드 | REST vs GraphQL vs gRPC 완벽 비교

API 설계 가이드 | REST vs GraphQL vs gRPC 완벽 비교

이 글의 핵심

REST·GraphQL·gRPC를 비교하고, HATEOAS 구현·GraphQL 실행 엔진·HTTP/2 멀티플렉싱·Protobuf 와이어 인코딩·프로덕션 운영 패턴까지 실무 관점에서 심화 정리합니다.

들어가며: API 설계의 중요성

”어떤 API 스타일을 선택해야 할까?”

백엔드 개발에서 API 설계는 가장 중요한 결정 중 하나입니다. REST, GraphQL, gRPC 중 무엇을 선택하느냐에 따라 개발 경험, 성능, 유지보수성이 크게 달라집니다. 이 글에서 다루는 것:

  • REST, GraphQL, gRPC의 핵심 개념
  • 심화: HATEOAS 구현(HAL·Curie·상태 전이), GraphQL 실행 파이프라인·병렬성·연합, HTTP/2 프레임·흐름 제어·gRPC 매핑, Protobuf 태그·Varint·packed
  • 각 API 스타일의 장단점
  • 성능 비교
  • 프로덕션 운영 패턴(아웃박스·사가·계약 테스트 등)과 프로젝트별 선택 기준

실전 경험에서 배운 교훈

이 기술을 실무 프로젝트에 처음 도입했을 때, 공식 문서만으로는 알 수 없었던 많은 함정들이 있었습니다. 특히 프로덕션 환경에서 발생하는 엣지 케이스들은 로컬 개발 환경에서는 재현조차 되지 않았죠.

가장 어려웠던 점은 성능 최적화였습니다. 처음엔 “동작만 하면 되겠지”라고 생각했지만, 실제 사용자 트래픽이 몰리면서 병목 지점들이 하나씩 드러났습니다. 특히 데이터베이스 쿼리 최적화, 캐싱 전략, 에러 핸들링 구조 등은 여러 번의 장애를 겪으면서 개선해 나갔습니다.

이 글에서는 그런 시행착오를 통해 얻은 실전 노하우와, “이렇게 하면 안 된다”는 교훈들을 함께 정리했습니다. 특히 트러블슈팅 섹션은 실제 장애 대응 경험을 바탕으로 작성했으니, 비슷한 문제를 마주했을 때 참고하시면 도움이 될 것입니다.

1. REST API

REST란?

REST(Representational State Transfer)는 HTTP를 기반으로 한 아키텍처 스타일입니다. REST의 핵심 원칙:

graph TB
    A[REST 원칙] --> B[자원 Resource]
    A --> C[HTTP 메서드]
    A --> D[무상태 Stateless]
    A --> E[캐시 가능]
    
    B --> B1[URI로 식별]
    C --> C1[GET POST PUT DELETE]
    D --> D1[서버가 상태 저장 안 함]
    E --> E1[HTTP 캐시 활용]

REST API 예제

엔드포인트 설계:

GET    /api/users          # 사용자 목록 조회
GET    /api/users/123      # 특정 사용자 조회
POST   /api/users          # 사용자 생성
PUT    /api/users/123      # 사용자 수정
DELETE /api/users/123      # 사용자 삭제
GET    /api/users/123/posts  # 사용자의 게시글 목록

Node.js Express 구현:

const express = require('express');
const app = express();
app.use(express.json());
// 사용자 목록 조회
app.get('/api/users', (req, res) => {
  const users = [
    { id: 1, name: 'Alice', email: '[email protected]' },
    { id: 2, name: 'Bob', email: '[email protected]' }
  ];
  res.json(users);
});
// 특정 사용자 조회
app.get('/api/users/:id', (req, res) => {
  const user = { id: req.params.id, name: 'Alice' };
  res.json(user);
});
// 사용자 생성
app.post('/api/users', (req, res) => {
  const newUser = req.body;
  res.status(201).json(newUser);
});
app.listen(3000);

REST의 장단점

장점:

  • 단순함: HTTP 표준 사용
  • 캐싱: HTTP 캐시 활용 가능
  • 도구 지원: Postman, curl 등
  • 학습 곡선 낮음: 직관적 단점:
  • Over-fetching: 불필요한 데이터도 받음
  • Under-fetching: 여러 요청 필요 (N+1 문제)
  • 버전 관리: /api/v1/, /api/v2/
  • 유연성 부족: 클라이언트 요구사항 변경 시 엔드포인트 추가

HATEOAS와 하이퍼미디어

HATEOAS(Hypermedia as the Engine of Application State)는 REST가 말하는 “하이퍼미디어를 통한 상태 전이”를 구체화한 아이디어입니다. 응답 본문 안에 다음에 취할 수 있는 행동을 링크 형태로 넣어 두면, 클라이언트는 하드코딩된 URL 목록 없이도 서버가 허용하는 흐름을 따라갈 수 있습니다. 예를 들어 주문 리소스에 결제하기, 취소하기 링크가 조건부로 나타나면, 클라이언트는 비즈니스 규칙이 바뀌어도 같은 응답 구조만 해석하면 됩니다.

실무에서는 HAL(JSON에 _links), JSON:APIlinks, Siren 등 규격을 쓰기도 하고, 더 가볍게는 관계 이름과 URL만 담은 객체를 두기도 합니다. 순수 HATEOAS는 클라이언트·서버 모두 구현 비용이 있어, 공개 API·장기 호환이 중요한 도메인(결제, 정부·금융 연동)에서 가치가 큽니다. 반면 모바일 앱 전용 API처럼 배포 주기가 짧고 팀이 하나면, OpenAPI 명세 + 버전 정책으로 충분한 경우가 많습니다.

하이퍼미디어 응답 예시(개념):

{
  "id": "ord_42",
  "status": "PENDING_PAYMENT",
  "_links": {
    "self": { "href": "/api/orders/ord_42" },
    "pay": { "href": "/api/orders/ord_42/payments", "method": "POST" },
    "cancel": { "href": "/api/orders/ord_42/cancel", "method": "POST" }
  }
}

위와 같이 표현하면 클라이언트는 status_links만 보고 다음 요청을 구성할 수 있습니다. 캐싱과 함께 쓸 때는 같은 URL이 같은 의미를 갖도록 하고, 링크가 사라지거나 메서드가 바뀌면 버전 또는 프로필(Profile) 문서로 계약을 명시하는 편이 안전합니다.

HATEOAS 구현 심화

1) “링크”가 담는 정보
실제 구현에서는 URL 문자열만으로 부족할 때가 많습니다. 링크 객체에 다음을 함께 두면 클라이언트가 안전하게 다음 요청을 만들 수 있습니다.

  • rel(관계 이름): self, next, collection, item 같은 표준 관계 또는 도메인 전용 이름(acme:pay)을 부여합니다. IANA 링크 관계 레지스트리에 등록된 값을 쓰면 상호운용성이 좋아집니다.
  • href / 템플릿 URI: 고정 URL뿐 아니라 RFC 6570 URI 템플릿({/orderId}/shipments{?carrier})을 쓰면 클라이언트가 변수를 채워 넣을 수 있습니다.
  • HTTP 메서드·콘텐츠 타입: HAL은 method, type(예상 Content-Type)을 확장 프로퍼티로 넣기도 하고, JSON:API는 links와 메타데이터로 관계를 표현합니다. 서버가 허용하는 전이를 “어떤 메서드로, 어떤 바디 스키마로” 보내야 하는지와 함께 노출하는 것이 핵심입니다.
  • title·국제화: 사람이 읽는 설명은 UI에 쓰이고, 기계가 읽는 계약은 프로필 문서JSON Schema 링크로 연결하는 편이 일반적입니다.

2) HAL·JSON:API에서의 패턴
HAL_links 아래에 링크를 모으고, 컬렉션·단일 리소스 모두 동일한 패턴을 유지합니다. Curie(Compact URI)는 긴 rel을 짧은 접두어로 줄이기 위한 네임스페이스 선언("curies": [{"name": "acme", "href": "https://docs.example.com/rels/{rel}", "templated": true}])으로, 도메인별 관계 이름을 안정적으로 관리할 때 유용합니다. JSON:APIdata.relationships.*.links.related처럼 관계 그래프를 명시하고, include로 연관 리소스를 한 응답에 실어 올리는 방식이 REST의 “표현 전이”와 잘 맞습니다.

3) 서버 측 구현: 상태 기계와의 연결
HATEOAS는 “REST가 CRUD 래퍼가 아니다”를 보여 주는 지점입니다. 서버는 도메인 객체의 현재 상태허용 전이 규칙(예: PAID 뒤에는 환불만 가능)을 평가해 그 순간에만 해당 rel을 응답에 포함합니다. 구현 패턴은 다음과 같습니다.

  • 도메인 레이어: 주문·결제 엔티티가 List<Transition> 또는 Affordance 목록을 반환하도록 모델링합니다.
  • 프레젠테이션 레이어: 위 목록을 HAL/JSON:API DTO로 변환합니다. 이때 URL 생성은 라우터 역전(reverse routing)으로 하드코딩을 피합니다.
  • 권한: 같은 상태라도 사용자 역할에 따라 cancel 링크를 숨기는 것은 흔합니다. 이는 “하이퍼미디어가 허용 행동의 진실 공급원”이라는 점에서 정책을 응답에 반영하는 형태입니다.

4) 클라이언트 측: 제네릭 vs. 특화
완전 제네릭 클라이언트(링크 rel만 보고 동적으로 UI를 구성)는 이론적으로 이상적이지만, 실제 제품 UI는 화면 설계와 밀접합니다. 절충안은 핵심 흐름은 하드코딩, 엣지 케이스·버전 차이는 링크 유무로 분기하거나, OpenAPI + 코드 생성으로 타입 안전을 확보하고 링크는 “서버가 허용하는지” 검증용으로 쓰는 방식입니다.

5) 캐싱·조건부 요청과의 결합
GET 응답에 ETag를 붙이고, 다음 전이 요청 전에 If-Match낙관적 동시성 제어를 하면 HATEOAS 흐름이 안전해집니다. 링크가 가리키는 리소스가 캐시되면, 캐시 키 설계(URL 정규화, Vary 헤더)와 충돌하지 않게 문서화해야 합니다.

6) 한계와 대안
HATEOAS는 클라이언트가 읽을 수 있는 문서화(프로필, 스키마)와 함께 가야 합니다. 팀이 작고 배포가 빠르면 OpenAPI + 안정적인 URL + 명시적 버전이 비용 대비 효과가 큽니다. 반면 다수의 외부 소비자·규제·10년 단위 호환이 요구되면, HATEOAS·프로필·표준 rel 조합이 설계 여력을 회수합니다.

2. GraphQL

GraphQL이란?

GraphQL은 Facebook이 개발한 쿼리 언어로, 클라이언트가 필요한 데이터를 정확히 요청할 수 있습니다. GraphQL 구조:

graph LR
    A[클라이언트] -->|Query| B[GraphQL 서버]
    B -->|정확한 데이터만| A
    
    C[REST] -->|고정된 응답| D[클라이언트]
    D -->|필요 없는 데이터도 받음| C

GraphQL 예제

스키마 정의:

# schema.graphql
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}
type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
}
type Query {
  user(id: ID!): User
  users: [User!]!
  post(id: ID!): Post
}
type Mutation {
  createUser(name: String!, email: String!): User!
  updateUser(id: ID!, name: String): User!
  deleteUser(id: ID!): Boolean!
}

쿼리 예제:

# 사용자와 게시글 한 번에 조회
query {
  user(id: "123") {
    name
    email
    posts {
      title
      content
    }
  }
}
# 응답 (필요한 필드만)
{
  "data": {
    "user": {
      "name": "Alice",
      "email": "[email protected]",
      "posts": [
        {
          "title": "첫 번째 글",
          "content": "내용..."
        }
      ]
    }
  }
}

Node.js Apollo Server 구현:

const { ApolloServer, gql } = require('apollo-server');
// 타입 정의
const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    email: String!
  }
  
  type Query {
    users: [User!]!
    user(id: ID!): User
  }
`;
// 리졸버
const resolvers = {
  Query: {
    users: () => [
      { id: '1', name: 'Alice', email: '[email protected]' },
      { id: '2', name: 'Bob', email: '[email protected]' }
    ],
    user: (_, { id }) => {
      return { id, name: 'Alice', email: '[email protected]' };
    }
  }
};
const server = new ApolloServer({ typeDefs, resolvers });
server.listen().then(({ url }) => {
  console.log(`🚀 Server ready at ${url}`);
});

GraphQL의 장단점

장점:

  • 정확한 데이터: 필요한 필드만 요청
  • 단일 엔드포인트: /graphql 하나로 모든 요청
  • 타입 시스템: 스키마로 타입 보장
  • 개발 경험: GraphQL Playground, 자동 문서화 단점:
  • 복잡도: 학습 곡선 높음
  • 캐싱 어려움: HTTP 캐시 활용 제한적
  • N+1 문제: DataLoader 등 추가 도구 필요
  • 오버헤드: 단순 CRUD에는 과함

실행 엔진: 파싱부터 리졸버까지

GraphQL 요청이 들어오면 구현체는 보통 다음 순서로 처리합니다. 먼저 렉싱·파싱으로 문서를 AST(추상 구문 트리)로 만든 뒤, 스키마와 대조해 정적 검증을 합니다(필드 존재 여부, 인자 타입, 프래그먼트 조건 등). 그다음 실행(execution) 단계에서 루트 타입(Query/Mutation)의 선택 집합(selection set)을 따라 필드 단위로 리졸버를 호출합니다.

동시성 측면에서, 같은 레벨의 여러 필드는 스펙상 병렬 실행이 허용되므로 서버는 이를 활용해 지연을 줄입니다. 반면 중첩된 필드는 부모가 준비된 뒤 자식 리졸버가 호출되는 트리 순회에 가깝습니다. 이때 한 레코드마다 N번 DB를 두드리는 N+1이 생기기 쉬워, DataLoader처럼 요청 범위 내에서 키를 모아 배치 조회하는 패턴이 사실상 표준에 가깝습니다.

운영 관점에서는 쿼리 깊이·복잡도 제한, 지속 쿼리(persisted query), 인트로스펙션 비활성화(프로덕션) 같은 방어 기법이 실행 엔진 바깥에서 함께 설계됩니다. 즉 GraphQL은 “단일 엔드포인트”라서 단순해 보이지만, 실행 계획·부하 제어까지 포함해야 프로덕션에 적합합니다.

GraphQL 실행 엔진 심화

1) 파이프라인: Document → Operation → Execution
클라이언트가 보내는 문자열은 GraphQL Document입니다. 여기에는 query/mutation/subscription프래그먼트가 포함될 수 있습니다. 파서는 AST를 만들고, 이후 단계에서 Operation 정의가 하나로 선택됩니다(이름 있는 연산이 여러 개면 어떤 것을 실행할지 지정해야 함). 검증 단계는 스키마 타입 시스템과 문서를 대조해, 존재하지 않는 필드·잘못된 리터럴·잘못된 인자 타입을 걸러 냅니다.

2) 실행 시 “필드 집합”과 병합
같은 타입에 대해 프래그먼트가 여러 개 깔리면, 실행기는 필드 선택을 병합단일 실행 계획으로 만듭니다. 중복 필드는 이름이 같을 때 병합 규칙(같은 타입의 하위 선택이 충돌하면 오류)이 적용됩니다. 이 때문에 “클라이언트가 보낸 텍스트”와 “실제로 호출되는 리졸버 집합”이 1:1로 대응하지 않을 수 있으며, 디버깅 시 AST 덤프가 유용합니다.

3) Coalescing / 그룹화와 병렬성
스펙은 같은 레벨의 필드는 병렬 실행될 수 있음을 허용합니다. 구현체는 이를 프로미스·그린 스레드·스레드 풀 등으로 스케줄링합니다. 반면 Mutation의 필드는 보통 순차 실행이 요구됩니다(비즈니스 부작용의 순서 보장). 따라서 “한 번의 mutation 블록에 여러 루트 필드를 넣는 것”은 트랜잭션 묶음이 아니라 순차 부작용으로 취급된다는 점을 설계에 반영해야 합니다.

4) 리졸버 시그니처와 info 객체
리졸버는 (parent, args, context, info) 형태가 일반적입니다. context는 요청 단위 DI(현재 사용자, DB 커넥션, DataLoader 인스턴스)를 실어 나르고, info필드 AST, 경로(path), 반환 타입 등 실행기 메타데이터를 담습니다. 필드 레벨 권한info를 보고 “이 요청이 실제로 email까지 요청했는지” 판별해 불필요한 DB 컬럼 조회를 피하는 필드 마스킹에도 쓰입니다.

5) N+1과 DataLoader의 위치
리졸버는 트리를 따라 호출되므로, User.posts가 리스트라면 posts 리졸버가 사용자 수만큼 불릴 수 있습니다. DataLoader는 “요청 단위 배치 윈도”에서 키를 모아 한 번의 SQL로 가져오게 합니다. 여기에 더해 SQL 조인 기반으로 상위에서 미리 JOIN해 내려 보내는 lookahead 패턴(Prisma·GraphQL Java 등의 dataloader/프로젝션 기능)도 함께 고려합니다.

6) 지연·스트리밍(Deferred / Stream)
일부 구현체는 @defer, @stream으로 응답을 여러 페이로드로 분할합니다. 이는 HTTP/1.1 기반 전송에서도 “느린 필드가 전체 TTFB를 막지 않게” 하려는 목적이 있으나, 캐시·CDN·프록시와의 상호작용이 복잡해집니다. 도입 시 프로토콜(멀티파트 chunked, SSE 등)클라이언트 라이브러리 지원을 함께 검증해야 합니다.

7) 연합(Federation)과의 경계
여러 서비스가 스키마를 나눠 갖는 Federation에서는 쿼리 계획(query plan) 단계가 추가됩니다. 게이트웨이가 하위 서비스로 하위 쿼리를 나눠 보내고, _entities 배치로 참조 해석을 합니다. 이는 “단일 엔드포인트” 뒤에 분산 실행이 숨는 셈이라, 지연 추적·부분 실패·타임아웃이 REST·gRPC보다 관측 설계가 더 중요해집니다.

8) 운영 가드레일(요약)
복잡도·깊이 제한, 알리어스 남용 방지, 지속 쿼리(알려진 해시만 허용), Mutation 비율 모니터링, 인트로스펙션 제한은 실행 엔진과 별도로 미들웨어/게이트웨이에서 걸어야 합니다. GraphQL의 유연성은 공격 표면이 되기 쉬우므로, “엔진이 할 수 있다”와 “프로덕션에서 허용할 것”을 분리하는 것이 핵심입니다.

3. gRPC

gRPC란?

gRPC는 Google이 개발한 고성능 RPC(Remote Procedure Call) 프레임워크입니다. Protocol Buffers를 사용하여 데이터를 직렬화합니다. gRPC 구조:

graph LR
    A[클라이언트] -->|Binary Protobuf| B[gRPC 서버]
    B -->|Binary Protobuf| A
    
    C[REST] -->|JSON Text| D[서버]
    D -->|JSON Text| C

HTTP/2와 멀티플렉싱

gRPC는 전송에 HTTP/2를 사용합니다. HTTP/1.1의 요청당 연결·헤더 중복 문제와 달리, HTTP/2는 단일 TCP 연결 위에 여러 개의 스트림을 겹쳐 올립니다. 각 RPC 호출은 스트림으로 매핑되므로, 클라이언트는 동시에 많은 RPC를 보내도 연결 수를 폭증시키지 않습니다. HPACK 헤더 압축으로 반복되는 메타데이터 부담도 줄어듭니다.

Unary 호출뿐 아니라 서버·클라이언트·양방향 스트리밍도 같은 연결 위에서 스트림으로 처리됩니다. 운영 시에는 로드밸런서·프록시가 HTTP/2를 end-to-end로 올바르게 종료·재개하는지, 연결 수명(keepalive, max streams)과 GOAWAY 처리 등을 점검해야 합니다. gRPC가 “빠르다”고 할 때 그 배경에는 이런 전송 계층의 효율이 포함됩니다.

HTTP/2·gRPC 전송 심화

1) 연결·스트림·프레임
HTTP/2에서 연결(Connection) 은 TCP 하나에 대응하고, 그 위에 스트림(Stream) 이라는 논리 채널이 여러 개 열립니다. 스트림은 양방향이며 정수 ID로 식별됩니다(클라이언트가 시작한 스트림과 서버가 시작한 스트림의 ID 패턴이 달라, 중간자가 스트림 출처를 구분할 수 있습니다). 전송 단위는 프레임(Frame) 입니다. HEADERS, DATA, RST_STREAM, SETTINGS, WINDOW_UPDATE, GOAWAY, PING 등이 대표적입니다. gRPC Unary는 보통 헤더 프레임 + 0개 이상 DATA + 트레일러(헤더) 로 완료됩니다.

2) 멀티플렉싱과 HOL 차단 완화
HTTP/1.1은 동일 연결에서 응답이 순서대로 막히는 헤드 오브 라인(HOL) 블로킹 문제가 있었고, 브라우저는 보통 연결 수를 늘려 우회했습니다. HTTP/2는 한 연결에서 여러 스트림이 프레임 단위로 인터리브되므로, 작은 RPC가 큰 스트림 뒤에 묶이지 않습니다(전송 계층에서의 HOL을 완화).

3) 흐름 제어(Flow Control)
HTTP/2는 연결 수준스트림 수준 각각에 윈도 크기가 있습니다. 수신자는 WINDOW_UPDATE로 “지금 이 만큼 더 받을 수 있다”고 알립니다. TCP 혼잡 제어와 별개로, 애플리케이션이 읽지 않는 스트림이 상대의 송신을 막지 않도록 설계된 장치입니다. gRPC 스트리밍에서 소비 속도가 느린 클라이언트가 있으면 이 윈도가 백프레셔 신호가 될 수 있습니다.

4) HPACK 헤더 압축
반복되는 헤더(:method, :path, content-type, gRPC 메타데이터)는 동적 테이블에 심볼화되어 이후 프레임에서 바이트 수가 크게 줄어듭니다. 프록시가 헤더를 변조하거나 테이블을 무효화하면 성능·호환 문제가 생길 수 있어, 엔드투엔드 TLS와 함께 프록시 설정을 검증해야 합니다.

5) 우선순위·가중치
HTTP/2는 스트림 우선순위 트리를 정의하지만, gRPC 클라이언트/서버가 이를 얼마나 적극 활용하는지는 구현에 따라 다릅니다. 마이크로서비스 내부에서는 데드라인·큐 길이·서킷 브레이커가 체감 SLA에 더 큰 영향을 주는 경우가 많습니다.

6) GOAWAY·연결 드레이닝
서버가 GOAWAY를 보내면 “이 연결에서 새 스트림을 더 받지 않겠다”는 뜻입니다. 로드밸런서가 노드를 내릴 때, 진행 중 RPC는 끝까지 보내고 새 연결은 다른 노드로 보내는 드레이닝과 맞물립니다. gRPC keepaliveLB의 idle timeout이 충돌하면 RST·재연결 폭주가 나기도 하므로, 인프라 값을 한 장표로 맞춥니다.

7) gRPC와 HTTP/2 매핑(실무 체크)

  • :path 에 서비스·메서드 이름이 들어가고, 콘텐츠 타입application/grpc 계열입니다.
  • 압축(gzip 등)은 메시지 단위 옵션과 함께 동작합니다.
  • 트레일러grpc-status, grpc-message가 실려 오므로, 로드밸런서가 트레일러까지 전달하는지 확인합니다(일부 프록시는 이를 떨어뜨립니다).

Protocol Buffers 인코딩

.proto 파일은 스키마이고, 네트워크에 실리는 것은 바이너리 와이어 포맷입니다. 각 필드는 필드 번호와이어 타입(Varint, 32비트, 64비트, Length-delimited 등)으로 식별되며, 가변 길이 정수 Varint 덕분에 작은 숫자는 적은 바이트로 표현됩니다. JSON과 달리 필드 이름 문자열이 반복되지 않아 페이로드가 작아지는 것이 일반적입니다.

호환 진화는 필드 번호를 기준으로 합니다. 새 필드를 추가할 때는 번호를 바꾸지 말고, 의미가 바뀌면 reserved로 번호를 묶어 두는 식으로 깨진 디코딩을 방지합니다. optional/repeated/oneof 등은 와이어 상에서 어떤 형태로 묶이는지 이해하고 있으면, 디버깅(헥스 덤프)과 버전 불일치 문제를 추적하기 쉽습니다.

와이어 포맷 개념 예시(설명용 의사 코드):

필드: message User { string name = 2; int32 id = 1; }
인코딩 시: 필드 번호·와이어 타입이 앞에 오고, 값은 Varint/길이 접두 문자열 등 규칙에 따라 뒤따름.
디코더는 번호로 스키마의 필드에 매핑하며, 알 수 없는 번호는 알려진 경우 무시(호환)할 수 있음.

Protobuf 와이어 인코딩 심화

1) 태그·와이어 타입
각 필드의 첫 바이트열은 tag = (field_number << 3) | wire_type 형태로 인코딩되는 경우가 많습니다(필드 번호가 매우 클 때 Varint로 확장). 와이어 타입은 아래와 같이 몇 가지로 나뉩니다.

와이어 타입의미주로 쓰는 Protobuf 타입
0Varintint32, int64, uint32, uint64, sint32, sint64, bool, enum
164비트 고정fixed64, sfixed64, double
2길이 접두(length-delimited)string, bytes, 중첩 message, packed 반복
532비트 고정fixed32, sfixed32, float

2) Varint
7비트씩 끊어 상위 비트가 연속 여부를 표시합니다. 작은 정수는 1바이트로 끝나기 쉬워 로그 ID·플래그가 많은 메시지에서 JSON보다 유리합니다.

3) ZigZag (sint32/sint64)
부호 있는 정수를 그대로 인코딩하면 음수가 큰 Varint가 될 수 있어, ZigZag로 부호 없는 작은 값에 매핑한 뒤 Varint합니다. 음수가 많다int32 대신 sint32를 검토합니다.

4) Length-delimited와 중첩 메시지
문자열·바이트·하위 메시지는 길이 프리픽스 + 페이로드입니다. JSON의 중첩 객체가 문자열 키 반복 없이 표현되는 이유가 여기에 있습니다.

5) packed repeated
스칼라 repeatedpacked일 때 하나의 length-delimited 블록 안에 Varint들이 연속으로 들어갑니다. 반복 요소가 많을수록 태그 오버헤드가 줄어듭니다. 호환성 규칙(구버전 디코더)을 확인해 활성화합니다.

6) oneof와 필드 존재 여부
proto3 초기에는 많은 스칼라가 “기본값과 구분 불가” 이슈가 있었고, 이후 optional, oneof, proto3 optional 등으로 존재 의미를 복원했습니다. 와이어 상으로는 oneof의 각 케이스가 서로 다른 필드 번호를 갖기 때문에, 한 번에 하나만 올 수 있습니다.

7) 알 수 없는 필드와 호환
디코더는 모르는 필드 번호그대로 보존(언어 구현에 따라 unknown fields)할 수 있습니다. 이는 순방향·역방향 혼합 배포에서 스키마 진화를 돕습니다. 반대로 필드 번호 재사용은 과거 바이트열이 새 의미로 해석될 수 있어 가장 위험합니다.

8) JSON·google.protobuf.Any와의 경계
디버깅 편의를 위해 JSON 변환을 쓰면 필드 이름이 다시 등장합니다. Any는 타입 URL + 직렬화된 메시지를 담아 확장 포인트를 만들지만, 버전 관리가 어려워지므로 내부 경계 API에서 신중히 씁니다.

gRPC 예제

Protobuf 정의:

// user.proto
syntax = "proto3";
package user;
service UserService {
  rpc GetUser (GetUserRequest) returns (User);
  rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
  rpc CreateUser (CreateUserRequest) returns (User);
}
message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
}
message GetUserRequest {
  int32 id = 1;
}
message ListUsersRequest {
  int32 page = 1;
  int32 page_size = 2;
}
message ListUsersResponse {
  repeated User users = 1;
}
message CreateUserRequest {
  string name = 1;
  string email = 2;
}

C++ 서버 구현:

#include <grpcpp/grpcpp.h>
#include "user.grpc.pb.h"
class UserServiceImpl final : public user::UserService::Service {
  grpc::Status GetUser(
      grpc::ServerContext* context,
      const user::GetUserRequest* request,
      user::User* response) override {
    
    response->set_id(request->id());
    response->set_name("Alice");
    response->set_email("[email protected]");
    
    return grpc::Status::OK;
  }
};
int main() {
  std::string server_address("0.0.0.0:50051");
  UserServiceImpl service;
  
  grpc::ServerBuilder builder;
  builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
  builder.RegisterService(&service);
  
  std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
  std::cout << "Server listening on " << server_address << std::endl;
  server->Wait();
  
  return 0;
}

gRPC의 장단점

장점:

  • 고성능: Binary 프로토콜, HTTP/2
  • 타입 안전: Protobuf 스키마
  • 스트리밍: 양방향 스트리밍 지원
  • 다국어 지원: 여러 언어로 클라이언트 생성 단점:
  • 브라우저 지원 제한: gRPC-Web 필요
  • 디버깅 어려움: Binary 프로토콜
  • 학습 곡선: Protobuf 문법
  • 도구 부족: Postman 등 REST 도구 사용 불가

4. 비교 분석

종합 비교표

특징RESTGraphQLgRPC
프로토콜HTTP/1.1HTTP/1.1HTTP/2
데이터 형식JSONJSONProtobuf (Binary)
타입 시스템없음있음 (Schema)있음 (Protobuf)
캐싱우수 (HTTP)제한적제한적
성능보통보통빠름
학습 곡선낮음중간높음
브라우저 지원우수우수제한적
스트리밍없음Subscription양방향

성능 벤치마크

테스트 환경: 10만 개 요청, 평균 응답 크기 1KB

처리량 (requests/sec):
1. gRPC:     50,000  ⭐
2. REST:     20,000
3. GraphQL:  15,000
응답 시간 (ms):
1. gRPC:     2ms   ⭐
2. REST:     5ms
3. GraphQL:  7ms
데이터 크기 (1000개 객체):
1. Protobuf: 82KB  ⭐
2. JSON:     120KB

사용 사례 비교

REST 적합한 경우:

  • 간단한 CRUD API
  • 공개 API (외부 개발자 사용)
  • 캐싱이 중요한 경우
  • 레거시 시스템 통합 GraphQL 적합한 경우:
  • 복잡한 데이터 요구사항
  • 모바일 앱 (데이터 절약)
  • 빠른 프론트엔드 개발
  • 다양한 클라이언트 (웹, 모바일, 데스크톱) gRPC 적합한 경우:
  • 마이크로서비스 간 통신
  • 실시간 스트리밍
  • 고성능 요구
  • 내부 API (브라우저 불필요)

5. 프로덕션 API 패턴

스타일(REST, GraphQL, gRPC)을 고른 뒤에도, 운영 환경에서 같은 계약을 안전하게 유지하려면 공통 과제가 있습니다. 아래는 팀에서 자주 마주치는 패턴입니다.

API 게이트웨이와 BFF

API 게이트웨이는 인증·레이트 리밋·라우팅·TLS 종료를 한곳에 모읍니다. BFF(Backend for Frontend)는 웹·모바일별로 필요한 집계·응답 형태를 맞추는 계층으로, GraphQL 서버가 BFF 역할을 하기도 하고, REST 뒤에 여러 gRPC를 호출하는 합성(composition) 역할을 하기도 합니다. 핵심은 외부에 노출되는 계약내부 마이크로서비스 프로토콜을 분리해, 내부는 gRPC로 고성능을 유지하면서 외부는 REST/GraphQL로 접근성을 확보하는 것입니다.

멱등성·재시도·타임아웃

네트워크는 실패합니다. POST처럼 본래 멱등이 아닌 연산은 Idempotency-Key 헤더나 서버 측 중복 키 저장으로 “한 번만 적용”을 보장하는 설계가 필요합니다. 재시도는 지수 백오프와 지터를 넣고, gRPC에서는 상태 코드·데드라인(deadline)과 함께 재시도 정책을 명시적으로 둡니다. 클라이언트와 서버가 합의한 타임아웃 없이 무한 대기하면 연결·스레드가 고갈됩니다.

레이트 리밋·서킷 브레이커·백프레셔

공개 API는 사용자·토큰·IP 단위로 레이트 리밋을 두어 남용과 연쇄 장애를 막습니다. 내부 호출은 서킷 브레이커로 하류 장애 시 빠르게 실패하고, 스트리밍(gRPC)에서는 흐름 제어와 애플리케이션 수준의 백프레셔로 소비 속도에 맞춥니다.

관측성과 버전 정책

구조화 로그, 메트릭(지연, 오류율, 포화도), 분산 추적(trace id)은 스타일과 무관하게 필수에 가깝습니다. REST는 URL 또는 헤더 기반 버전(Accept, Api-Version)을 문서화하고, GraphQL은 스키마 변경을 호환 가능한 추가 위주로 가져가며, gRPC는 proto 패키지·서비스 버전과 생성 코드 배포를 맞춥니다.

보안·배포

공개 REST/GraphQL은 OAuth2/OIDC, 내부 gRPC는 mTLS·네트워크 정책을 함께 고려합니다. gRPC-Web을 쓰면 브라우저 제약과 프록시 설정을 별도로 검증해야 합니다.

프로덕션 패턴 보강

1) 트랜잭션 아웃박스(Transactional Outbox)
API가 DB에 커밋한 뒤 메시지 브로커로 이벤트를 보내야 할 때, “DB 성공 후 발행 실패”나 그 반대로 이중 쓰기 문제가 생깁니다. 아웃박스 테이블에 이벤트를 같은 트랜잭션으로 기록하고, 별도 워커가 폴링·CDC로 발행하면 최소 한 번(at-least-once) 전달을 깔끔히 맞출 수 있습니다. REST·GraphQL·gRPC 모두 도메인 이벤트 경계에서 동일하게 쓰입니다.

2) 사가(Saga)·보상 트랜잭션
여러 서비스에 걸친 연산은 글로벌 2PC 대신 사가로 쪼갭니다. 각 단계는 로컬 트랜잭션이고, 실패 시 보상(compensating) API를 호출해 롤백에 가까운 상태를 만듭니다. API 설계 시 “취소·환불·역방향 연산”을 대칭적으로 노출해 두는 것이 실무적으로 중요합니다.

3) 벌크헤드(Bulkhead)
한 하류 서비스 장애가 스레드 풀·커넥션 풀 전체를 고갈시키지 않도록, 클라이언트별·의존성별로 풀을 분리합니다. gRPC 채널·HTTP 클라이언트 풀·GraphQL DataLoader 배치 큐도 같은 원리로 격리합니다.

4) 계약 테스트(Consumer-driven)
스키마(OpenAPI, GraphQL, proto)만으로는 런타임 동작(에러 포맷, 관계 필드, 지연)이 보장되지 않습니다. Pact 등으로 소비자 기대를 검증하면, 게이트웨이·BFF가 여러 백엔드를 조합할 때 회귀를 줄일 수 있습니다.

5) REST 조건부 요청·캐시 키
ETag/Last-ModifiedIf-None-Match/If-Modified-Since대역폭과 응답 처리를 줄입니다. CDN·API 게이트웨이 앞에 둘 때는 Vary, Cache-Control, 정규화된 URL 규칙을 문서화합니다. GraphQL은 지속 쿼리 해시 + CDN으로 GET 캐시에 가깝게 가져가는 경우가 있습니다.

6) 레이트 리밋 알고리즘·할당량
토큰 버킷은 순간 버스트를 허용하고, 누수 버킷은 더 부드럽게 제한합니다. 사용자·클라이언트 ID·IP·API 키 단위로 다단계(글로벌·지역) 쿼터를 두면, 공정성과 남용 방지를 동시에 잡을 수 있습니다.

7) 우아한 저하(graceful degradation)·기능 플래그
하류가 느려질 때 타임아웃 후 기본값·캐시된 스냅샷을 돌려주는 저하 모드는 공개 REST·BFF에서 특히 유용합니다. 스키마 필드를 nullable로 두고, 플래그로 필드 노출을 제어하면 GraphQL에서도 부분 장애를 흡수하기 쉽습니다.

8) 카나리·섀도 트래픽
새 버전 API는 트래픽 일부만 라우팅해 오류율·지연을 비교합니다. 섀도(shadow) 는 실제 응답은 옛 경로로 쓰고, 새 경로에 복제 요청만 보내 검증합니다. gRPC는 서비스 메시LB 가중치와 잘 맞습니다.

9) 웹훅·이벤트 API의 멱등·재전송
Idempotency-Key뿐 아니라 이벤트 ID중복 저장소(짧은 TTL)로 소비자가 같은 알림을 여러 번 받아도 안전하게 합니다. 서명 헤더(HMAC)로 출처를 검증합니다.

10) 데이터 주권·지역 라우팅
규제·지연 때문에 테넌트·지역별로 엔드포인트를 나누는 경우, 게이트웨이에서 Geo·테넌트 헤더로 라우팅합니다. gRPC 메타데이터에 동일 정보를 실어 스티키 세션데이터 레지던시를 맞춥니다.

이 패턴들은 “어떤 스타일이냐”보다 팀이 운영 가능한지를 가늠하는 체크리스트로 쓰면 좋습니다.

6. 선택 가이드

선택 플로우차트

flowchart TD
    A[API 설계 시작] --> B{브라우저 클라이언트?}
    B -->|아니오| C{성능 최우선?}
    C -->|예| D[gRPC]
    C -->|아니오| E[REST]
    
    B -->|예| F{복잡한 데이터 요구?}
    F -->|예| G[GraphQL]
    F -->|아니오| H{공개 API?}
    H -->|예| I[REST]
    H -->|아니오| J[GraphQL 또는 REST]

프로젝트별 권장

1. 스타트업 MVP

  • REST: 빠른 개발, 단순함
  • 예: Express + MongoDB 2. 모바일 앱 백엔드
  • GraphQL: 데이터 절약, 유연성
  • 예: Apollo Server + PostgreSQL 3. 마이크로서비스
  • gRPC: 고성능, 타입 안전
  • 예: gRPC + Kubernetes 4. 공개 API
  • REST: 표준, 도구 지원
  • 예: Stripe API, GitHub API 5. 실시간 앱
  • GraphQL Subscription 또는 gRPC Streaming
  • 예: 채팅, 알림, 대시보드

하이브리드 접근

많은 프로젝트에서 여러 API 스타일을 혼합합니다.

프론트엔드 (브라우저)
    ↓ GraphQL
API Gateway
    ↓ gRPC
마이크로서비스들

:

  • 외부 클라이언트: REST 또는 GraphQL
  • 내부 서비스: gRPC
  • 실시간 기능: WebSocket 또는 GraphQL Subscription

7. 정리

핵심 요약

REST:

  • HTTP 기반, 자원 중심
  • 단순하고 캐싱 우수
  • 필요 시 HATEOAS로 rel·프로필·상태 전이를 응답에 녹여 진화 가능한 공개 API를 만들 수 있음
  • 공개 API에 적합 GraphQL:
  • 쿼리 언어, 클라이언트 중심
  • 정확한 데이터 요청
  • 문서→검증→실행·필드 병합·Mutation 순차성·연합 계획까지 포함해 부하·보안을 설계해야 함
  • 복잡한 데이터 요구사항에 적합 gRPC:
  • Binary 프로토콜, HTTP/2 스트림·프레임·흐름 제어·HPACK 위에서 동작
  • Protobuf 태그·Varint·length-delimited로 작은 페이로드·스키마 진화
  • 타입 안전, 스트리밍
  • 마이크로서비스에 적합 공통:
  • 게이트웨이·BFF, 멱등성·재시도, 관측성·버전 정책에 더해 아웃박스·사가·계약 테스트·캐시·레이트 리밋·카나리 등으로 프로덕션 품질 확보

선택 기준

우선순위선택
단순함REST
유연성GraphQL
성능gRPC
공개 APIREST
모바일GraphQL
마이크로서비스gRPC

다음 단계

각 API 스타일의 자세한 구현 방법은 아래 글을 참고하세요:

추가 학습 자료

공식 문서:

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「API 설계 가이드 | REST vs GraphQL vs gRPC 완벽 비교」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「API 설계 가이드 | REST vs GraphQL vs gRPC 완벽 비교」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. REST·GraphQL·gRPC 비교, HATEOAS 구현, GraphQL 실행 엔진, HTTP/2·Protobuf 와이어 포맷, 프로덕션 API 패턴까지 심화 정리합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

API, REST, GraphQL, gRPC, 백엔드, API설계, 마이크로서비스 등으로 검색하시면 이 글이 도움이 됩니다.