Spring Boot gRPC: Proto 정의

왜 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 서비스를 운영할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux