왜 gRPC인가? — REST의 한계를 넘는 서비스 간 통신
마이크로서비스 간 통신에서 REST + JSON은 직관적이지만, 직렬화/역직렬화 비용이 크고, API 스키마가 느슨하며, 양방향 스트리밍을 지원하지 않는다. gRPC는 Google이 만든 고성능 RPC 프레임워크로, Protocol Buffers(protobuf) 기반의 바이너리 직렬화, HTTP/2 멀티플렉싱, 양방향 스트리밍, 코드 자동 생성을 제공한다.
Spring Boot에서 gRPC를 사용하면, Resilience4j 심화에서 다룬 Circuit Breaker와 결합하여 장애에 강한 서비스 간 통신을 구축할 수 있다. 이 글에서는 grpc-spring-boot-starter(net.devh)를 사용한 서버·클라이언트 구현, Interceptor 체이닝, 에러 처리, 스트리밍, Kubernetes 헬스체크 연동까지 실무 운영 패턴을 총정리한다.
1. Proto 파일 정의 — API 계약의 단일 진실 공급원
gRPC의 출발점은 .proto 파일이다. REST의 OpenAPI 스펙과 달리, proto 파일에서 서버·클라이언트 코드가 자동 생성되므로 스펙과 구현이 괴리될 수 없다.
// src/main/proto/order_service.proto
syntax = "proto3";
package com.example.order;
option java_multiple_files = true;
option java_package = "com.example.order.grpc";
// 서비스 정의 — REST의 Controller에 대응
service OrderService {
// Unary RPC — 일반적인 요청-응답
rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);
rpc GetOrder (GetOrderRequest) returns (OrderResponse);
// Server Streaming — 서버가 여러 응답을 스트리밍
rpc ListOrders (ListOrdersRequest) returns (stream OrderResponse);
// Client Streaming — 클라이언트가 여러 요청을 스트리밍
rpc BatchCreateOrders (stream CreateOrderRequest) returns (BatchCreateResponse);
// Bidirectional Streaming — 양방향
rpc OrderUpdates (stream OrderUpdateRequest) returns (stream OrderUpdateResponse);
}
// 메시지 정의 — REST의 DTO에 대응
message CreateOrderRequest {
string customer_id = 1;
repeated OrderItem items = 2;
string currency = 3;
ShippingAddress shipping_address = 4;
}
message OrderItem {
string product_id = 1;
int32 quantity = 2;
int64 price_cents = 3; // 금액은 정수(cents)로 — 부동소수점 함정 방지
}
message ShippingAddress {
string street = 1;
string city = 2;
string zip_code = 3;
string country = 4;
}
message CreateOrderResponse {
string order_id = 1;
OrderStatus status = 2;
int64 total_cents = 3;
google.protobuf.Timestamp created_at = 4;
}
message OrderResponse {
string order_id = 1;
string customer_id = 2;
OrderStatus status = 3;
repeated OrderItem items = 4;
int64 total_cents = 5;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0; // proto3에서 0은 기본값
ORDER_STATUS_CREATED = 1;
ORDER_STATUS_CONFIRMED = 2;
ORDER_STATUS_SHIPPED = 3;
ORDER_STATUS_DELIVERED = 4;
ORDER_STATUS_CANCELLED = 5;
}
message GetOrderRequest {
string order_id = 1;
}
message ListOrdersRequest {
string customer_id = 1;
int32 page_size = 2;
string page_token = 3;
}
message BatchCreateResponse {
int32 created_count = 1;
repeated string order_ids = 2;
}
message OrderUpdateRequest {
string order_id = 1;
OrderStatus new_status = 2;
}
message OrderUpdateResponse {
string order_id = 1;
OrderStatus status = 2;
string message = 3;
}
2. 의존성과 빌드 설정
<!-- pom.xml -->
<properties>
<grpc.version>1.63.0</grpc.version>
<protobuf.version>3.25.3</protobuf.version>
<grpc-spring-boot.version>3.1.0.RELEASE</grpc-spring-boot.version>
</properties>
<dependencies>
<!-- gRPC Server -->
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-server-spring-boot-starter</artifactId>
<version>${grpc-spring-boot.version}</version>
</dependency>
<!-- gRPC Client (다른 서비스 호출 시) -->
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-client-spring-boot-starter</artifactId>
<version>${grpc-spring-boot.version}</version>
</dependency>
</dependencies>
<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.7.1</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
mvn compile을 실행하면 target/generated-sources에 Java 코드가 자동 생성된다. OrderServiceGrpc.OrderServiceImplBase 클래스를 상속하여 서버를 구현한다.
3. gRPC 서버 구현 — @GrpcService
@GrpcService // Spring Bean + gRPC 서비스 등록
@RequiredArgsConstructor
public class OrderGrpcService extends OrderServiceGrpc.OrderServiceImplBase {
private final OrderService orderService;
private final OrderMapper orderMapper;
// Unary RPC
@Override
public void createOrder(CreateOrderRequest request,
StreamObserver<CreateOrderResponse> responseObserver) {
try {
// protobuf → 도메인 객체 변환
CreateOrderCommand command = orderMapper.toCommand(request);
Order order = orderService.create(command);
// 도메인 객체 → protobuf 변환
CreateOrderResponse response = CreateOrderResponse.newBuilder()
.setOrderId(order.getId())
.setStatus(orderMapper.toProtoStatus(order.getStatus()))
.setTotalCents(order.getTotalCents())
.setCreatedAt(Timestamps.fromMillis(order.getCreatedAt().toEpochMilli()))
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (InvalidOrderException e) {
responseObserver.onError(
Status.INVALID_ARGUMENT
.withDescription(e.getMessage())
.asRuntimeException()
);
} catch (Exception e) {
responseObserver.onError(
Status.INTERNAL
.withDescription("Internal server error")
.withCause(e)
.asRuntimeException()
);
}
}
// Server Streaming RPC
@Override
public void listOrders(ListOrdersRequest request,
StreamObserver<OrderResponse> responseObserver) {
List<Order> orders = orderService.findByCustomer(
request.getCustomerId(),
request.getPageSize(),
request.getPageToken()
);
// 각 주문을 개별적으로 스트리밍
for (Order order : orders) {
responseObserver.onNext(orderMapper.toProto(order));
}
responseObserver.onCompleted();
}
@Override
public void getOrder(GetOrderRequest request,
StreamObserver<OrderResponse> responseObserver) {
orderService.findById(request.getOrderId())
.ifPresentOrElse(
order -> {
responseObserver.onNext(orderMapper.toProto(order));
responseObserver.onCompleted();
},
() -> responseObserver.onError(
Status.NOT_FOUND
.withDescription("Order not found: " + request.getOrderId())
.asRuntimeException()
)
);
}
}
# application.yml
grpc:
server:
port: 9090 # gRPC 서버 포트 (HTTP와 분리)
security:
enabled: false # TLS는 Ingress/Service Mesh에서 처리
client:
inventory-service: # 클라이언트 채널 이름
address: dns:///inventory-service:9090 # K8s DNS
negotiationType: plaintext
enableKeepAlive: true
keepAliveTime: 30s
keepAliveTimeout: 5s
4. gRPC 클라이언트 — @GrpcClient로 다른 서비스 호출
@Service
public class InventoryGrpcClient {
// @GrpcClient가 채널 + stub을 자동 생성·주입
@GrpcClient("inventory-service")
private InventoryServiceGrpc.InventoryServiceBlockingStub inventoryStub;
public StockInfo checkStock(String sku) {
CheckStockRequest request = CheckStockRequest.newBuilder()
.setSku(sku)
.build();
try {
CheckStockResponse response = inventoryStub.checkStock(request);
return new StockInfo(response.getSku(), response.getQuantity());
} catch (StatusRuntimeException e) {
switch (e.getStatus().getCode()) {
case NOT_FOUND:
throw new ProductNotFoundException(sku);
case UNAVAILABLE:
throw new ServiceUnavailableException("Inventory service unavailable");
default:
throw new InternalException("gRPC call failed: " + e.getMessage());
}
}
}
// 비동기 호출 (ListenableFuture)
@GrpcClient("inventory-service")
private InventoryServiceGrpc.InventoryServiceFutureStub inventoryFutureStub;
public CompletableFuture<StockInfo> checkStockAsync(String sku) {
CheckStockRequest request = CheckStockRequest.newBuilder()
.setSku(sku)
.build();
ListenableFuture<CheckStockResponse> future =
inventoryFutureStub.checkStock(request);
return CompletableFuture.supplyAsync(() -> {
try {
CheckStockResponse response = future.get(5, TimeUnit.SECONDS);
return new StockInfo(response.getSku(), response.getQuantity());
} catch (Exception e) {
throw new CompletionException(e);
}
});
}
}
Stub 3가지 타입
| Stub 타입 | 호출 방식 | 스트리밍 지원 | 사용 시점 |
|---|---|---|---|
| BlockingStub | 동기 (블로킹) | Server streaming만 | 단순 Unary 호출 |
| FutureStub | 비동기 (Future) | Unary만 | 비동기 Unary 호출 |
| Stub (async) | 비동기 (StreamObserver) | 모든 타입 | 양방향 스트리밍 |
5. gRPC 에러 처리 — Status Code 매핑
gRPC는 HTTP 상태 코드 대신 gRPC Status Code를 사용한다. 올바른 매핑이 클라이언트 에러 처리의 핵심이다.
| gRPC Status | HTTP 대응 | 사용 시점 |
|---|---|---|
| OK (0) | 200 | 성공 |
| INVALID_ARGUMENT (3) | 400 | 잘못된 요청 파라미터 |
| NOT_FOUND (5) | 404 | 리소스 없음 |
| ALREADY_EXISTS (6) | 409 | 중복 생성 |
| PERMISSION_DENIED (7) | 403 | 권한 없음 |
| UNAUTHENTICATED (16) | 401 | 인증 실패 |
| RESOURCE_EXHAUSTED (8) | 429 | Rate limit 초과 |
| UNAVAILABLE (14) | 503 | 서비스 불가 (재시도 가능) |
| DEADLINE_EXCEEDED (4) | 504 | 타임아웃 |
| INTERNAL (13) | 500 | 서버 내부 오류 |
// 풍부한 에러 정보 전달 — ErrorDetails 사용
import com.google.rpc.BadRequest;
import io.grpc.protobuf.StatusProto;
// 서버 측: 상세 에러 전달
BadRequest badRequest = BadRequest.newBuilder()
.addFieldViolations(BadRequest.FieldViolation.newBuilder()
.setField("items")
.setDescription("최소 1개 이상의 상품이 필요합니다")
.build())
.build();
com.google.rpc.Status status = com.google.rpc.Status.newBuilder()
.setCode(Code.INVALID_ARGUMENT_VALUE)
.setMessage("주문 생성 요청이 유효하지 않습니다")
.addDetails(Any.pack(badRequest))
.build();
responseObserver.onError(StatusProto.toStatusRuntimeException(status));
6. Server Interceptor — 인증·로깅·메트릭 횡단 관심사
gRPC Interceptor는 Spring의 Filter/Interceptor에 대응한다. 모든 RPC 호출 전후에 공통 로직을 실행한다.
// 인증 Interceptor
@GrpcGlobalServerInterceptor // 전역 적용
@RequiredArgsConstructor
public class AuthInterceptor implements ServerInterceptor {
private final JwtService jwtService;
private static final Metadata.Key<String> AUTH_KEY =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
// 인증 정보를 다운스트림에 전달하기 위한 Context Key
public static final Context.Key<String> USER_ID_KEY = Context.key("userId");
public static final Context.Key<List<String>> ROLES_KEY = Context.key("roles");
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
String methodName = call.getMethodDescriptor().getFullMethodName();
// 헬스체크는 인증 제외
if (methodName.contains("grpc.health")) {
return next.startCall(call, headers);
}
String authHeader = headers.get(AUTH_KEY);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
call.close(Status.UNAUTHENTICATED.withDescription("Missing token"), new Metadata());
return new ServerCall.Listener<>() {};
}
try {
String token = authHeader.substring(7);
TokenPayload payload = jwtService.verify(token);
// Context에 인증 정보 저장 → 서비스에서 꺼내 쓸 수 있음
Context ctx = Context.current()
.withValue(USER_ID_KEY, payload.getSub())
.withValue(ROLES_KEY, payload.getRoles());
return Contexts.interceptCall(ctx, call, headers, next);
} catch (Exception e) {
call.close(Status.UNAUTHENTICATED.withDescription("Invalid token"), new Metadata());
return new ServerCall.Listener<>() {};
}
}
}
// 서비스에서 인증 정보 사용
@GrpcService
public class OrderGrpcService extends OrderServiceGrpc.OrderServiceImplBase {
@Override
public void createOrder(CreateOrderRequest request,
StreamObserver<CreateOrderResponse> responseObserver) {
// Interceptor에서 저장한 userId 꺼내기
String userId = AuthInterceptor.USER_ID_KEY.get();
List<String> roles = AuthInterceptor.ROLES_KEY.get();
// ...
}
}
// 로깅 + 메트릭 Interceptor
@GrpcGlobalServerInterceptor
@RequiredArgsConstructor
public class LoggingInterceptor implements ServerInterceptor {
private final MeterRegistry meterRegistry;
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
String method = call.getMethodDescriptor().getBareMethodName();
String service = call.getMethodDescriptor().getServiceName();
Timer.Sample sample = Timer.start(meterRegistry);
// 응답 상태를 캡처하기 위해 ServerCall을 래핑
ServerCall<ReqT, RespT> wrappedCall = new ForwardingServerCall
.SimpleForwardingServerCall<>(call) {
@Override
public void close(Status status, Metadata trailers) {
sample.stop(Timer.builder("grpc.server.calls")
.tag("service", service)
.tag("method", method)
.tag("status", status.getCode().name())
.register(meterRegistry));
if (!status.isOk()) {
log.warn("gRPC call failed: {}/{} → {}", service, method, status);
}
super.close(status, trailers);
}
};
return next.startCall(wrappedCall, headers);
}
}
7. Deadline(타임아웃) — 연쇄 타임아웃 전파
gRPC의 Deadline은 REST의 타임아웃과 달리 호출 체인 전체에 전파된다. 서비스 A → B → C로 호출할 때, A가 5초 deadline을 설정하면 B가 3초를 소비한 뒤 C를 호출할 때 남은 2초가 자동으로 전파된다.
// 클라이언트에서 Deadline 설정
OrderResponse response = orderStub
.withDeadlineAfter(5, TimeUnit.SECONDS) // 5초 타임아웃
.getOrder(request);
// application.yml에서 기본 Deadline 설정
grpc:
client:
inventory-service:
address: dns:///inventory-service:9090
negotiationType: plaintext
deadline: 5s # 모든 호출에 5초 기본 적용
서버 측에서 Deadline 확인:
@Override
public void createOrder(CreateOrderRequest request,
StreamObserver<CreateOrderResponse> responseObserver) {
// 남은 시간이 충분한지 확인
if (Context.current().getDeadline() != null
&& Context.current().getDeadline().isExpired()) {
responseObserver.onError(
Status.DEADLINE_EXCEEDED.withDescription("Deadline already expired")
.asRuntimeException());
return;
}
// ...
}
8. Kubernetes 운영 — 헬스체크와 Service 설정
gRPC는 자체 헬스체크 프로토콜을 정의하고 있으며, Kubernetes 1.24+에서 네이티브 gRPC probe를 지원한다.
# application.yml
grpc:
server:
port: 9090
health-service-enabled: true # grpc.health.v1.Health 서비스 자동 등록
# Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
template:
spec:
containers:
- name: order
image: order-service:v2.0
ports:
- containerPort: 8080
name: http
- containerPort: 9090
name: grpc
# gRPC 네이티브 Probe (K8s 1.24+)
readinessProbe:
grpc:
port: 9090
service: "" # 빈 문자열 = 전체 서버 상태
periodSeconds: 5
livenessProbe:
grpc:
port: 9090
initialDelaySeconds: 15
periodSeconds: 10
---
# Service — gRPC와 HTTP 포트 분리
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector:
app: order-service
ports:
- name: http
port: 8080
targetPort: http
- name: grpc
port: 9090
targetPort: grpc
appProtocol: grpc # Istio/Linkerd가 gRPC 인식
9. REST + gRPC 공존 — 하이브리드 아키텍처
모든 API를 gRPC로 전환할 필요는 없다. 외부(프론트엔드, 서드파티)는 REST, 내부(서비스 간)는 gRPC를 쓰는 하이브리드가 일반적이다.
// REST Controller → 내부 gRPC 호출
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderRestController {
private final OrderGrpcClient orderGrpcClient; // 내부 gRPC 클라이언트
@PostMapping
public ResponseEntity<OrderDto> createOrder(@RequestBody CreateOrderDto dto) {
// REST 요청을 받아서 내부적으로 gRPC 호출
OrderDto result = orderGrpcClient.createOrder(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
}
// 또는 grpc-gateway로 자동 REST ↔ gRPC 변환
// (envoy proxy 또는 grpc-gateway를 앞단에 배치)
10. 운영 체크리스트
| 항목 | 권장 설정 | 위반 시 증상 |
|---|---|---|
| Deadline | 모든 클라이언트 호출에 deadline 설정 | 느린 서비스가 호출자 스레드 고갈 |
| Status Code | 비즈니스 에러에 정확한 gRPC Status 매핑 | 클라이언트가 재시도 여부 판단 불가 |
| KeepAlive | keepAliveTime 30~60초 설정 | 유휴 연결 끊김 → 첫 호출 실패 |
| 헬스체크 | K8s gRPC probe 또는 grpc-health-probe | 장애 Pod에 트래픽 유입 |
| Proto 관리 | 별도 Git 저장소 + 버전 태그 | 서버-클라이언트 스키마 불일치 |
| Interceptor | 인증·로깅·메트릭을 Interceptor로 분리 | 서비스 코드에 횡단 관심사 침투 |
마무리 — gRPC는 내부 통신의 정답에 가깝다
마이크로서비스 간 통신에서 gRPC는 REST 대비 명확한 장점을 제공한다. Proto 파일로 API 계약을 강제하고, 바이너리 직렬화로 성능을 확보하며, Deadline 전파로 연쇄 타임아웃을 방지하고, 스트리밍으로 실시간 데이터를 전송한다.
Spring Boot에서는 grpc-spring-boot-starter의 @GrpcService와 @GrpcClient로 REST Controller만큼 자연스럽게 구현할 수 있다. 핵심은 세 가지다: Deadline을 항상 설정하고, Status Code를 정확히 매핑하며, Interceptor로 횡단 관심사를 분리하라. 이 세 가지를 갖추면 프로덕션 수준의 gRPC 서비스를 운영할 수 있다.