실전 마이크로서비스 설계 패턴
gRPC로 마이크로서비스를 묶을 때의 진짜 도전
gRPC는 단일 RPC 호출에서 탁월한 성능을 발휘하지만, 마이크로서비스 아키텍처에서는 단일 호출 성능보다 서비스 간 조합과 장애 격리가 훨씬 중요하다. 서비스 수가 늘어나면 네트워크 파티션, 느린 의존 서비스, 분산 트랜잭션이라는 단일 프로세스에서는 없던 문제들이 나타난다.
이 챕터에서는 gRPC 기반 마이크로서비스에서 반복적으로 등장하는 핵심 설계 패턴을 정리한다.
(Web/Mobile) → API Gateway
(JSON→gRPC) → Service A → Service B → Service C
서비스 경계 설계: 잘못 나누면 10배 느려진다
서비스 분해가 잘못되면 단일 사용자 요청이 수십 번의 동기 RPC를 유발한다. 이를 chatty service 안티패턴이라 한다. 예를 들어 쇼핑몰의 주문 요약 화면이 상품 서비스 10번, 사용자 서비스 1번, 리뷰 서비스 10번을 호출한다면, 각 RPC가 5ms여도 총 지연은 직렬 합산 = ~100ms가 된다.
해결 방향:
- 도메인 기반 집계(Aggregator): 클라이언트와 내부 서비스 사이에 조합 계층을 두고, 내부 RPC를 병렬로 팬아웃한다.
- Backend-for-Frontend(BFF): 클라이언트 유형(모바일, 웹, CLI)별로 전용 집계 서비스를 만들어 각각 최적화된 페이로드를 반환한다.
- gRPC 서버 스트리밍: 여러 아이템을 하나의 스트림으로 묶어 왕복 횟수를 줄인다.
좋은 서비스 경계는 하나의 서비스 변경이 다른 서비스 코드를 바꾸지 않아도 되는 경계다. Protobuf 계약 변경이 잦다면 경계가 잘못 그어진 신호다.
API Gateway: gRPC를 외부에 노출하는 방법
브라우저와 모바일 클라이언트는 HTTP/2 gRPC를 직접 사용하기 어렵다. API Gateway는 외부 HTTP/REST 요청을 내부 gRPC 호출로 변환한다.
gRPC 트랜스코딩
Google의 google.api.http 어노테이션을 .proto 파일에 추가하면 Envoy나 grpc-gateway가 HTTP JSON ↔ Protobuf 변환을 자동으로 처리한다.
import "google/api/annotations.proto";
service OrderService {
rpc GetOrder (GetOrderRequest) returns (Order) {
option (google.api.http) = {
get: "/v1/orders/{order_id}"
};
}
}이렇게 하면 GET /v1/orders/123이 GetOrder({order_id: "123"}) gRPC 호출로 자동 변환된다.
gRPC-Web
브라우저가 HTTP/2 트레일러를 지원하지 않는 문제를 해결하기 위해 gRPC-Web 프로토콜이 존재한다. Envoy 사이드카가 gRPC-Web 요청을 일반 gRPC로 변환한다.
(gRPC-Web) → Envoy
gRPC-Web 프록시 → gRPC
서비스
(Native gRPC) → Load Balancer
(L4/L7) → gRPC
서비스
(트랜스코딩) → gRPC
서비스
Deadline 전파: 마이크로서비스 필수 패턴
gRPC의 Deadline은 "이 RPC가 완료되어야 하는 절대 시각"이다. Timeout과 달리, 호출 체인을 따라 전파될 수 있다.
서비스 A가 1000ms deadline을 받아서 B를 호출할 때, B에게 원래 deadline을 그대로 넘긴다. B가 이미 900ms를 소비했다면 B→C 호출에는 100ms만 남은 deadline이 전달된다. 이 전파가 없으면 이미 타임아웃된 요청을 위해 하위 서비스가 계속 작업을 이어가는 낭비가 발생한다.
// 상위 서비스에서 받은 ctx의 deadline을 그대로 하위 RPC에 전달
func (s *OrderService) PlaceOrder(ctx context.Context, req *pb.PlaceOrderRequest) (*pb.Order, error) {
// ctx에 이미 deadline이 있다면 그대로 사용; 없다면 새로 설정
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 3*time.Second)
defer cancel()
}
// 하위 서비스 호출에 ctx 그대로 전달
inventory, err := s.inventoryClient.Reserve(ctx, &pb.ReserveRequest{...})
if err != nil {
return nil, err
}
return s.paymentClient.Charge(ctx, &pb.ChargeRequest{...})
}Deadline을 받은 서버가 이미 지났다면 즉시 DEADLINE_EXCEEDED를 반환한다. 이 동작은 gRPC 런타임이 자동으로 처리한다.
Circuit Breaker: 장애 격리
의존 서비스가 느려지면 내 서비스의 스레드·고루틴·커넥션 풀이 그 서비스를 기다리며 고갈된다. Circuit Breaker는 이 연쇄 장애를 막는다.
(정상 통과) 에러율 임계 초과 →
(즉시 실패) 대기 시간 경과 →
(탐침 요청) 성공 → CLOSED / 실패 → OPEN
gRPC에는 기본 Circuit Breaker가 내장되어 있지 않다. go-resilience 나 sony/gobreaker 같은 라이브러리, 또는 Istio/Envoy의 서비스 메시 레이어에서 구성한다.
// gobreaker 예시
cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "inventory-service",
MaxRequests: 3, // HALF-OPEN 상태에서 허용할 최대 요청 수
Interval: 10 * time.Second, // CLOSED 상태에서 카운터 초기화 주기
Timeout: 30 * time.Second, // OPEN → HALF-OPEN 대기 시간
ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.ConsecutiveFailures > 5
},
})
result, err := cb.Execute(func() (interface{}, error) {
return inventoryClient.Reserve(ctx, req)
})Bulkhead 패턴은 Circuit Breaker의 보완재다. 의존 서비스별로 별도의 고루틴 풀이나 커넥션 풀을 두면, 하나의 느린 서비스가 다른 서비스 호출까지 블록하지 않는다.
Retry 정책과 멱등성(Idempotency)
gRPC는 서비스 설정(Service Config)을 통해 클라이언트 측 자동 재시도를 지원한다.
{
"methodConfig": [{
"name": [{"service": "order.v1.OrderService", "method": "GetOrder"}],
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE", "DEADLINE_EXCEEDED"]
}
}]
}재시도가 안전하려면 RPC가 멱등(idempotent) 해야 한다. 조회(GET 계열)는 자연스럽게 멱등이지만, 주문 생성 같은 변경 RPC는 중복 실행이 부작용을 낳는다.
Idempotency Key 패턴: 클라이언트가 요청에 고유 키(UUID)를 포함해 보내고, 서버가 이 키로 중복 요청을 감지한다. 이미 처리한 키라면 저장된 결과를 바로 반환한다.
message CreateOrderRequest {
string idempotency_key = 1; // 클라이언트가 생성한 UUID
OrderDetails details = 2;
}서버는 Redis나 DB에 idempotency_key → result를 저장하고, 같은 키가 들어오면 저장된 결과를 바로 반환한다. 이렇게 하면 네트워크 오류로 재시도가 발생해도 주문이 두 번 생성되지 않는다.
Saga 패턴: 분산 트랜잭션 없이 일관성 유지
여러 서비스에 걸친 트랜잭션을 분산 2PC(Two-Phase Commit) 으로 구현하면 코디네이터 장애 시 전체가 블록된다. Saga는 로컬 트랜잭션의 연속과 실패 시 보상 트랜잭션으로 이 문제를 해결한다.
Order Service → 이벤트 2. 재고 예약
Inventory → 이벤트 3. 결제 처리
Payment → 이벤트 4. 배송 예약
Shipping
Inventory → 보상 주문 취소
Order Service
Saga에는 두 가지 구현 방식이 있다.
| 방식 | 설명 | 장점 | 단점 |
|---|---|---|---|
| Choreography | 각 서비스가 이벤트를 발행하고 구독하며 다음 단계 실행 | 결합도 낮음, 단순한 흐름 | 흐름 파악이 어려움, 사이클 위험 |
| Orchestration | 중앙 Saga 오케스트레이터가 각 서비스에 명령 발행 | 흐름 가시성 높음 | 오케스트레이터 중심화, 복잡도 증가 |
gRPC 서비스 간 Saga에서는 각 단계의 RPC가 실패할 경우 보상 RPC를 역순으로 호출한다. 보상 RPC 역시 멱등해야 한다.
서비스 버전 관리 전략
Protobuf의 강점 중 하나는 하위 호환 진화(backward-compatible evolution)다. 그러나 gRPC 서비스 수준에서는 별도의 버전 관리 전략이 필요하다.
URL 기반 버전 관리
Protobuf 패키지 이름에 버전을 포함한다.
package order.v1; // 현재 안정 버전
package order.v2; // 신규 비호환 버전 (별도 서비스로 배포)서비스 이름이 order.v1.OrderService와 order.v2.OrderService로 구분되어 완전히 독립적으로 운영된다. 클라이언트를 점진적으로 마이그레이션하는 동안 두 버전이 동시에 실행된다.
Protobuf 하위 호환 규칙 요약
| 안전한 변경 | 위험한 변경 |
|---|---|
| 새 필드 추가 (새 번호 사용) | 기존 필드 번호 변경 |
| 새 메시지 타입 추가 | 필드 타입 변경 (int32 → string) |
optional 필드 제거 | required 필드 제거 |
oneof 에 새 케이스 추가 | 필드 이름만 변경 (직렬화엔 번호가 쓰임) |
필드 이름은 직렬화에 영향이 없지만 코드 생성에 영향을 준다. JSON 트랜스코딩을 쓴다면 필드 이름 변경도 클라이언트에 영향을 미친다.
실전 체크리스트
- 서비스 경계가 너무 잘게 나뉘어 chatty RPC가 많다면 Aggregator 또는 BFF 계층을 도입한다.
- 모든 gRPC 호출에 Deadline을 설정하고, 수신한 ctx의 Deadline을 하위 RPC에 그대로 전파한다.
- 재시도 가능한 메서드는 서비스 Config에 RetryPolicy를 명시하고, 변경 RPC에는 Idempotency Key를 추가한다.
- 의존 서비스별로 Circuit Breaker를 적용한다. OPEN 상태에서는 빠른 실패(fail-fast)로 요청을 즉시 거절한다.
- 여러 서비스에 걸친 상태 변경이 필요하면 Saga 패턴을 쓴다. 2PC는 운영 부담 대비 이득이 거의 없다.
- .proto 파일을 변경할 때 하위 호환 규칙을 지킨다. 비호환 변경이 필요하면 패키지 버전을 올린다.
- 외부 클라이언트를 위해 Envoy 또는 grpc-gateway로 gRPC를 REST/JSON으로 노출한다.
References
- gRPC Service Config — Retry Policy
- gRPC — Deadlines
- Advanced gRPC in Microservices — DZone
- gRPC API Gateway Guide — Zuplo
- Microservices Pattern: Circuit Breaker
- Mastering Microservices Patterns: Circuit Breaker, Fallback, Bulkhead, Saga, and CQRS
- Resilient Microservices: A Systematic Review of Recovery Patterns
- 19 Essential Microservices Patterns for System Design Interviews