이 글에서 얻는 것
- gRPC를 “빠른 REST” 정도로 오해하지 않고, IDL/코드 생성/스트리밍/데드라인 중심의 설계 감각을 잡습니다.
- proto 스키마를 호환성 있게 진화시키는 규칙(필드 번호/예약/reserved, deprecated)을 이해합니다.
- 데드라인/리트라이/스트리밍 백프레셔 같은 운영 포인트를 포함해 서비스 계약을 설계할 수 있습니다.
0) gRPC는 “계약(Contract) 기반”이다
gRPC의 강점은 **Protobuf(Protocol Buffers)**를 사용한 고효율 바이너리 통신입니다.
0.1 Protobuf vs JSON
JSON: 사람이 읽을 수 있지만, 필드 이름이 반복되어 용량이 큽니다.
{ "id": 1, "username": "alice" } // 30 bytes
Protobuf: 바이너리로 직렬화되며, 필드 번호(Tag)로 데이터를 식별해 매우 작습니다.
block-beta
columns 4
block:proto
Tag1["Tag: 1 (id)"]
Val1["Value: 1"]
Tag2["Tag: 2 (username)"]
Val2["Value: 'alice'"]
end
style proto fill:#e1f5fe,stroke:#0277bd
(실제로는 [Tag|Type] + [Length] + [Value] 구조의 TLV 패킹으로 약 9~10 bytes)
0.2 gRPC Interface Definition (IDL)
gRPC는 **계약(Proto)**을 먼저 정의하고, 코드를 자동 생성합니다.
flowchart LR
Proto[order.proto] -->|protoc| Compiler[Protobuf Compiler]
Compiler -->|Generate| Java[OrderServiceGrpc.java<br/>OrderOuterClass.java]
Compiler -->|Generate| Go[order.pb.go<br/>order_grpc.pb.go]
Compiler -->|Generate| Python[order_pb2.py<br/>order_pb2_grpc.py]
style Proto fill:#ffebee,stroke:#c62828
style Compiler fill:#e3f2fd,stroke:#1565c0
style Java fill:#fff3e0,stroke:#e65100
- Service Stub: 클라이언트/서버가 통신하기 위한 기본 코드.
- Message Class: 데이터를 담는 DTO (Builder 패턴 등 제공).
좋은 gRPC 설계의 핵심 질문:
- 클라이언트가 어떤 타입/에러/타임아웃을 기대할 수 있는가?
- 호출이 멱등(idempotent)한가? 재시도해도 안전한가?
- 스트리밍이 필요한가, 단순 Unary가 충분한가?
1) Unary vs Streaming (feat. HTTP/2)
gRPC는 HTTP/2 위에서 동작하며, 하나의 커넥션으로 여러 요청을 동시에 처리(Multiplexing)합니다.
sequenceDiagram
participant Client
participant Server
Note over Client,Server: HTTP/1.1 (Blocking / Head-of-Line Blocking)
Client->>Server: Request 1
Server-->>Client: Response 1
Client->>Server: Request 2
Server-->>Client: Response 2
Note over Client,Server: HTTP/2 (Multiplexing)
par Parallel Requests
Client->>Server: Request 1 (Stream 1)
Client->>Server: Request 2 (Stream 3)
and
Server-->>Client: Response 2 (Stream 3)
Server-->>Client: Response 1 (Stream 1)
end
1.1 Communication Patterns
- Unary: 단순 Req/Res (대부분의 API).
- Server Streaming:
returns (stream response)(알림, 피드, 로그). - Client Streaming:
(stream request)(대용량 업로드). - Bidirectional:
(stream request) returns (stream response)(실시간 채팅, 게임).
필요한 경우에만 스트리밍을 사용하세요. (운영 복잡도 증가)
2) proto 설계: 호환성 규칙이 ‘운영 안전성’이다
proto는 시간이 지나며 바뀝니다. 그래서 호환성 규칙을 지키는 게 중요합니다.
- 필드 번호는 절대 재사용하지 않는다
- 삭제는
deprecated로 표시하고, 실제 삭제는 충분히 오래 뒤에 - 제거한 번호/이름은
reserved로 막아 사고를 방지 - 타입 변경은 사실상 breaking change일 수 있음(특히 string ↔ int)
예시(개념):
message Order {
string id = 1;
string user_id = 2;
// deprecated: use created_at instead
int64 createdAtMillis = 3 [deprecated = true];
reserved 4, 5;
reserved "old_field_name";
}
3) 데드라인/타임아웃: “서버가 아니라 클라이언트가 정한다”
gRPC는 데드라인(deadline)이 문화입니다.
- 클라이언트는 “얼마까지 기다릴지”를 정하고,
- 서버는 데드라인을 넘기면 불필요한 작업을 중단해야 합니다.
서버에서 타임아웃을 무시하면:
- 이미 클라이언트는 포기했는데 서버는 계속 일한다(낭비)
- 부하가 누적돼 장애로 이어질 수 있습니다
3.1 호출 예산을 코드로 보존하기
timeout=3초를 모든 계층에 각각 설정하면 전체 요청은 3초보다 훨씬 오래 살아날 수 있습니다. 진입 요청의 남은 시간을 예산으로 보고, 각 하위 RPC에는 그보다 짧은 deadline을 전달해야 합니다. 또한 DEADLINE_EXCEEDED가 난 뒤에도 DB 조회나 외부 호출을 계속하지 않도록 cancellation을 확인해야 합니다.
// OrderFacade가 받은 전체 예산이 800ms라면,
// inventory 호출에 800ms를 새로 주지 말고 남은 시간 안에서 250ms만 배정합니다.
InventoryReply reply = inventoryStub
.withDeadlineAfter(250, TimeUnit.MILLISECONDS)
.getInventory(request);
운영 계약에는 RPC별 기본값만이 아니라 다음을 함께 적습니다.
| 항목 | 예시 | 이유 |
|---|---|---|
| 진입 deadline | 800ms | 사용자 응답의 상한 |
| 하위 inventory 예산 | 250ms | fan-out이 전체 시간을 소진하지 않게 함 |
| 재시도 가능 시간 | 120ms 이상 남을 때만 | 이미 늦은 재시도가 꼬리를 늘리는 것을 방지 |
| 취소 후 작업 | DB/HTTP 호출 중단, span 종료 | 포기한 요청의 자원 점유 방지 |
deadline은 오류를 숨기는 값이 아닙니다. DEADLINE_EXCEEDED 비율, 남은 예산 분포, 하위 호출별 timeout을 함께 보면 어느 경계가 사용자 시간을 소비했는지 찾을 수 있습니다. 자세한 전파 규칙은 End-to-End Deadline과 Cancellation에서 이어서 확인하세요.
4) 재시도/멱등성: 자동 재시도는 항상 위험하다
gRPC/클라이언트 SDK는 재시도 기능이 있지만, 무턱대고 켜면 사고가 납니다.
- 멱등한 요청만 재시도(조회/상태 확인 등)
- 쓰기 요청은 idempotency key를 도입하거나, 재시도 정책을 더 보수적으로
- 백오프 + jitter, retry budget 같은 “증폭 방지”가 필요
4.1 재시도 허용표를 먼저 만든다
클라이언트가 UNAVAILABLE만 보고 모든 RPC를 재시도하면, 장애 중인 쓰기 요청을 중복 실행할 수 있습니다. 메서드마다 멱등성을 문서화하고, retryable status와 최대 횟수를 제한하세요.
| RPC 유형 | 예시 | 기본 정책 |
|---|---|---|
| 읽기 | GetOrder | UNAVAILABLE에 한해 짧은 backoff로 1회, 남은 deadline이 있을 때만 |
| 멱등 쓰기 | CreateOrder(idempotency_key) | 키 저장 기간 안에서 제한 재시도 가능 |
| 비멱등 쓰기 | CapturePayment | 자동 재시도 금지, 결과 조회/보상 흐름으로 확인 |
| streaming | WatchOrder | 새 stream을 열기 전 마지막 event offset과 구독 권한을 재검증 |
재시도 횟수보다 중요한 것은 retry budget입니다. 예를 들어 1분 동안 정상 요청 1,000건이면 재시도는 50건까지만 허용하는 식으로 제한합니다. 예산이 소진되면 빠르게 실패시키고 원래 원인을 관측해야 복구 중인 의존성을 두 번째 장애로 몰아넣지 않습니다.
5) 인증/메타데이터/관측성
- 인증 토큰은 메타데이터로 전달(Authorization)
- traceId/correlationId를 메타데이터로 전파
- 서버/클라이언트 인터셉터로 로깅/메트릭/트레이싱을 표준화
6) gRPC-Gateway(외부 공개가 필요할 때)
브라우저/외부 파트너는 HTTP/JSON이 필요할 수 있습니다. 이때 gRPC-Gateway로 변환하면 “내부는 gRPC, 외부는 REST” 같은 구조가 가능합니다.
포인트:
- 외부에서 들어오는 rate limit/인증은 게이트웨이에서 1차로 처리하는 편이 안전합니다.
- HTTP 상태 코드와 gRPC status를 기계적으로 1:1 대응시키기보다, 외부 소비자에게 노출할 오류 코드·재시도 가능 여부·사용자 메시지를 별도 계약으로 둡니다.
7) 배포 전 계약 검증 체크리스트
새 RPC 또는 proto 변경을 배포하기 전에는 아래 항목을 PR에서 확인합니다.
- 새 필드는 새 번호를 사용했고, 삭제한 번호·이름은
reserved로 남겼는가? - 메서드별 deadline, 멱등성, 허용 재시도 status를 문서와 클라이언트 설정에 동시에 반영했는가?
- 요청·응답 최대 크기와 streaming의 느린 소비자/최대 연결 시간을 정했는가?
-
grpc.status,grpc.method, deadline 초과, retry 횟수, stream 활성 수를 대시보드에서 분리했는가? - Gateway 또는 외부 API의 오류 응답이 내부 구현·민감한 metadata를 그대로 노출하지 않는가?
이 체크리스트는 proto 문법 검증을 통과한 뒤에야 의미가 있습니다. 호환성 검사는 CI에 넣고, 실제 배포에서는 신규 서버 → 신규 클라이언트와 구버전 클라이언트 → 신규 서버 조합을 모두 작은 트래픽에서 확인하세요.
연습(추천)
- 간단한
OrderServiceproto를 설계해보고, “필드 추가/이름 변경/삭제” 시나리오에서 호환성을 어떻게 지킬지reserved/deprecated로 표현해보기 - Unary API에 데드라인을 적용하고, 데드라인 초과 시 서버가 작업을 중단하도록 구현해보기
- 서버 스트리밍 API에서 느린 소비자(클라이언트)를 시뮬레이션하고, 백프레셔/버퍼 정책을 어떻게 둘지 실험해보기
💬 댓글