서비스 간 통신: REST, gRPC 또는 메시지 큐?

1. 개요

모놀리식 시스템을 여러 개의 작은 서비스로 분리할 때, 가장 먼저 마주치는 질문은 항상 같습니다: 서비스들이 서로 어떻게 통신할 것인가? 이 글에서는 세 가지 가장 일반적인 선택지 — REST, gRPC, 메시지 큐(Kafka/RabbitMQ) — 를 구체적인 코드 예제를 통해 비교합니다. 이 글을 읽고 나면, 언제 어떤 방식을 선택해야 하는지 알 수 있고, 서비스 간 통신을 설계할 때 자주 하는 실수들을 피할 수 있을 것입니다.

2. 문제 상황

OrderService, PaymentService, InventoryService 3개의 서비스를 가진 e-commerce 시스템이 있다고 가정해봅시다. OrderService가 주문을 받으면:

  • PaymentService를 호출해 돈을 차감합니다(사용자에게 결과를 즉시 보고하기 위해 응답이 필요합니다).
  • InventoryService를 호출해 재고를 차감합니다(비동기로 처리해도 됩니다).

이 두 가지 다른 요구사항은 두 가지 다른 통신 방식으로 이어집니다: 동기 (REST, gRPC)와 비동기 (메시지 큐).

3. REST — 간단하고, 일반적이고, 디버깅하기 쉬움

HTTP/JSON을 이용한 REST는 거의 모든 사람이 사용 방법을 알고 있고, 디버깅 도구(Postman, curl)가 어디에나 있기 때문에 기본적인 선택입니다.

3.1. RestClient를 이용한 REST 호출 예제

public record PaymentRequest(String orderId, BigDecimal amount) {}
public record PaymentResponse(String transactionId, String status) {}

@Service
public class PaymentClient {

    private final RestClient restClient;

    public PaymentClient(RestClient.Builder builder) {
        this.restClient = builder.baseUrl("http://payment-service:8081").build();
    }

    public PaymentResponse charge(PaymentRequest request) {
        return restClient.post()
                .uri("/api/payments")
                .body(request)
                .retrieve()
                .body(PaymentResponse.class);
    }
}

RestClient(Spring 6.1+)는 JSON을 payment-service로 보내고 미리 역직렬화된 PaymentResponse를 받습니다. 만약 payment-service가 4xx/5xx를 반환하면 retrieve()는 RestClientException을 던집니다 — 이 오류는 호출한 쪽에서 처리해야 합니다.

3.2. 장점과 단점

  • 장점: 배우기 쉽고, curl/Postman으로 테스트하기 쉬우며, 모든 언어에서 지원하고, HTTP 캐싱, 로드 밸런서, API 게이트웨이를 활용할 수 있습니다.
  • 단점: JSON 페이로드가 바이너리보다 무겁고, contract이 느슨해서 클라이언트/서버 간 필드가 벗어나기 쉬우며, gRPC보다 latency가 높습니다.

주의: REST는 요청 수가 많지 않고 curl/Postman으로 빠르게 디버깅해야 할 때 적합합니다. 초당 수만 개의 요청을 처리하는 내부 시스템에서는 REST JSON이 병목이 될 수 있습니다.

4. gRPC — 높은 성능, 엄격한 contract

gRPC는 Protocol Buffers(바이너리)와 HTTP/2를 사용하며, 속도와 명확한 contract이 필요한 서비스 간 내부 통신에 적합합니다.

4.1. proto 선언

syntax = "proto3";

package payment;

option java_package = "dev.nonla.payment.grpc";
option java_multiple_files = true;

service PaymentGrpcService {
  rpc Charge (ChargeRequest) returns (ChargeResponse);
}

message ChargeRequest {
  string order_id = 1;
  double amount = 2;
}

message ChargeResponse {
  string transaction_id = 1;
  string status = 2;
}

이 .proto 파일은 클라이언트와 서버 모두를 위한 Java stub을 생성합니다 — 이것이 REST에는 없는 “엄격한” contract입니다.

4.2. Maven 의존성

<dependency>
    <groupId>net.devh</groupId>
    <artifactId>grpc-server-spring-boot-starter</artifactId>
    <version>3.1.0.RELEASE</version>
</dependency>
<dependency>
    <groupId>net.devh</groupId>
    <artifactId>grpc-client-spring-boot-starter</artifactId>
    <version>3.1.0.RELEASE</version>
</dependency>

grpc-spring-boot-starter 라이브러리는 gRPC server/client를 Spring Boot에 통합하므로 Server를 직접 관리할 필요가 없습니다.

4.3. Server 구현

@GrpcService
public class PaymentGrpcServer extends PaymentGrpcServiceGrpc.PaymentGrpcServiceImplBase {

    @Override
    public void charge(ChargeRequest request, StreamObserver<ChargeResponse> responseObserver) {
        ChargeResponse response = ChargeResponse.newBuilder()
                .setTransactionId(UUID.randomUUID().toString())
                .setStatus("SUCCESS")
                .build();
        responseObserver.onNext(response);
        responseObserver.onCompleted();
    }
}

@GrpcService는 이 서버를 gRPC runtime에 자동으로 등록합니다. onNext는 응답을 보내고, onCompleted는 스트림을 닫습니다 — 반드시 호출해야 하지 않으면 클라이언트가 계속 대기합니다.

4.4. gRPC 호출하는 클라이언트

@Service
public class PaymentGrpcClient {

    @GrpcClient("payment-service")
    private PaymentGrpcServiceGrpc.PaymentGrpcServiceBlockingStub stub;

    public ChargeResponse charge(String orderId, double amount) {
        ChargeRequest request = ChargeRequest.newBuilder()
                .setOrderId(orderId)
                .setAmount(amount)
                .build();
        return stub.charge(request);
    }
}

BlockingStub은 동기적으로 호출해 결과를 즉시 반환합니다 — JSON을 자동 역직렬화할 필요가 없어서 REST보다 코드가 깔끔합니다.

4.5. 장점과 단점

  • 장점: 바이너리가 작고 JSON보다 2–5배 빠르며, HTTP/2 멀티플렉싱을 지원하고, .proto contract이 클라이언트/서버 schema를 동기화하며, 양방향 스트리밍을 지원합니다.
  • 단점: 수동으로 디버깅하기 어렵고(페이로드를 직접 읽을 수 없음), 특수 도구(grpcurl, BloomRPC)가 필요하며, 브라우저에서 직접 호출하기 어렵고(grpc-web 필요), 팀이 proto를 배워야 합니다.

주의: 브라우저 기반 frontend와 통신해야 한다면 grpc-web이 필요할 때까지 gRPC를 피하세요. gRPC는 서비스 내부 통신에 가장 적합합니다.

5. 메시지 큐 — 비동기, 우수한 오류 허용

OrderService가 InventoryService의 즉각적인 응답을 기다릴 필요가 없다면, 메시지 큐(Kafka, RabbitMQ)가 합리적인 선택입니다: 메시지를 보낸 후 계속 처리하고, consumer는 준비되면 처리합니다.

5.1. Maven 의존성

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

5.2. Producer — OrderService가 이벤트를 발행

public record StockReservedEvent(String orderId, String sku, int quantity) {}

@Service
public class InventoryEventPublisher {

    private final KafkaTemplate<String, StockReservedEvent> kafkaTemplate;

    public InventoryEventPublisher(KafkaTemplate<String, StockReservedEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void publishStockReserved(StockReservedEvent event) {
        kafkaTemplate.send("stock-reserved-topic", event.orderId(), event);
    }
}

kafkaTemplate.send는 이벤트를 stock-reserved-topic topic으로 보냅니다. orderId를 key로 사용해 같은 주문의 이벤트가 같은 partition에 있도록 해 처리 순서를 유지합니다.

5.3. Consumer — InventoryService가 리스닝

@Component
public class StockReservedListener {

    @KafkaListener(topics = "stock-reserved-topic", groupId = "inventory-service")
    public void onStockReserved(StockReservedEvent event) {
        log.info("Reserving {} units of {} for order {}",
                event.quantity(), event.sku(), event.orderId());
        // 재고 차감, orderId별로 멱등성 처리
    }
}

@KafkaListener는 자동으로 topic에서 새 메시지를 poll합니다. 만약 InventoryService가 잠시 중단되면 Kafka가 메시지를 보관합니다 — 데이터가 손실되지 않고 consumer가 복구된 후 처리합니다.

5.4. 장점과 단점

  • 장점: 강한 decoupling(producer가 consumer가 살아있는지 알 필요가 없음), 우수한 오류 허용(메시지가 유지됨), partition별 확장성, 이벤트 기반 아키텍처 지원.
  • 단점: 즉시 응답이 없음(결과가 필요하면 callback/polling을 추가로 설계해야 함), 데이터 흐름 디버깅이 복잡함(여러 topic을 추적해야 함), Kafka/RabbitMQ 같은 broker 인프라가 필요합니다(운영 및 모니터링).

주의: 사용자에게 즉시 피드백이 필요한 경우(예: 결제 성공/실패 확인)에는 메시지 큐를 사용하지 마세요 — 비동기 지연이 UX를 악화시킵니다.

6. 세 가지 방법 빠른 비교

항목 REST gRPC 메시지 큐
통신 방식 동기 동기 비동기
형식 JSON(텍스트) Protobuf(바이너리) 주로 JSON/Avro
속도 중간 높음 직접 적용 불가능(처리량 높음)
Contract 느슨함(OpenAPI 선택) 엄격함(.proto) 느슨함(schema registry 옵션)
디버깅 쉬움(curl, Postman) 더 어려움 가장 어려움(여러 consumer 추적)
Service 다운 시 오류 허용 즉시 실패 즉시 실패 메시지 대기, 손실 없음
적합한 경우 공개 API, 간단한 CRUD 내부 통신, 높은 성능 이벤트 기반, 백그라운드 처리

7. 상황별 통신 방식 선택

OrderService 예제로 돌아가봅시다:

@Service
public class OrderProcessingService {

    private final PaymentGrpcClient paymentClient;      // 즉시 결과 필요, 내부, 높은 성능
    private final InventoryEventPublisher inventoryPublisher; // 대기 불필요, 백그라운드 처리

    public OrderProcessingService(PaymentGrpcClient paymentClient,
                                   InventoryEventPublisher inventoryPublisher) {
        this.paymentClient = paymentClient;
        this.inventoryPublisher = inventoryPublisher;
    }

    public OrderResult processOrder(Order order) {
        ChargeResponse charge = paymentClient.charge(order.id(), order.amount());
        if (!"SUCCESS".equals(charge.getStatus())) {
            return OrderResult.failed(order.id());
        }
        inventoryPublisher.publishStockReserved(
                new StockReservedEvent(order.id(), order.sku(), order.quantity()));
        return OrderResult.accepted(order.id(), charge.getTransactionId());
    }
}

processOrder는 결제 성공/실패 여부를 즉시 알기 위해 gRPC를 호출한 후, 재고를 차감하기 위해 이벤트만 발행합니다 — 사용자가 이 단계를 기다릴 필요가 없고, InventoryService가 잠시 중단되어도 Kafka가 메시지를 안전하게 보관합니다.

일반적인 선택 규칙:

  • 즉시 결과가 필요하고, 내부 통신이며, 빈도가 높음 → gRPC.
  • 즉시 결과가 필요하지만, 외부와 통신하거나 팀이 아직 gRPC에 익숙하지 않음 → REST.
  • 즉시 결과가 필요 없고, 오류 허용과 decoupling이 필요 → 메시지 큐.

8. 처리 로직 선택 테스트 작성

@ExtendWith(MockitoExtension.class)
class OrderProcessingServiceTest {

    @Mock PaymentGrpcClient paymentClient;
    @Mock InventoryEventPublisher inventoryPublisher;
    @InjectMocks OrderProcessingService orderProcessingService;

    @Test
    void shouldPublishStockEvent_whenPaymentSucceeds() {
        Order order = new Order("order-1", "SKU-01", 2, new BigDecimal("100.00"));
        ChargeResponse success = ChargeResponse.newBuilder()
                .setTransactionId("tx-1")
                .setStatus("SUCCESS")
                .build();
        given(paymentClient.charge(order.id(), order.amount())).willReturn(success);

        OrderResult result = orderProcessingService.processOrder(order);

        assertThat(result.status()).isEqualTo("ACCEPTED");
        verify(inventoryPublisher).publishStockReserved(
                new StockReservedEvent("order-1", "SKU-01", 2));
    }

    @Test
    void shouldNotPublishStockEvent_whenPaymentFails() {
        Order order = new Order("order-2", "SKU-02", 1, new BigDecimal("50.00"));
        ChargeResponse failed = ChargeResponse.newBuilder().setStatus("FAILED").build();
        given(paymentClient.charge(order.id(), order.amount())).willReturn(failed);

        OrderResult result = orderProcessingService.processOrder(order);

        assertThat(result.status()).isEqualTo("FAILED");
        verifyNoInteractions(inventoryPublisher);
    }
}

첫 번째 테스트는 결제 성공 시 InventoryEventPublisher.publishStockReserved가 올바른 파라미터로 호출되는지 확인합니다. 두 번째 테스트는 결제 실패 시 시스템이 재고 차감 이벤트를 발행하지 않는지 확인합니다 — verifyNoInteractions는 의도하지 않은 side-effect가 없음을 보장합니다.

9. 세 가지를 함께 사용할 때 자주 하는 실수

  • 모든 곳에 REST 동기 호출 남용: “분산 모놀리식”을 만들어집니다 — A가 B를 호출하고, B가 C를 호출하면, 한 서비스가 느려지면 전체 체인이 timeout입니다.
  • proto version 관리 없이 gRPC 사용: .proto의 필드를 deprecate 없이 제거하면 오래된 클라이언트가 crash됩니다.
  • 멱등성 처리 없이 메시지 발행: Kafka는 consumer retry 시 메시지를 다시 보낼 수 있습니다 — InventoryService가 같은 orderId에 대해 재고를 두 번 차감하면 버그입니다.
  • 동기/비동기 혼용을 명확하게 구분하지 않음: 코드 유지보수를 쉽게 하려면 메서드/클래스 이름이 통신 방식을 명확히 드러내야 합니다. 예: publishXxxEvent(비동기) vs fetchXxx/chargeXxx(동기).

10. 결론

모든 상황에 “최고의” 방법은 없습니다 — 단순성과 디버깅이 중요할 때는 REST, 내부 통신에서 성능과 엄격한 contract이 필요할 때는 gRPC, decoupling과 오류 허용이 중요할 때는 메시지 큐를 선택하세요. 실제로는 성숙한 마이크로서비스 시스템이 세 가지를 모두 함께 사용합니다. 위의 OrderService 예제처럼, 각 방식이 가장 적합한 통신 유형에 대해서입니다.

microservicesRESTgRPCmessage-queuespring-boot