サービス間通信: REST、gRPC、それともメッセージキュー?

1. 概要

monolith なシステムを複数の小さなサービスに分割するとき、最初の質問は必ずこうです: サービス間はどのように通信するのか? このブログでは、最も一般的な 3 つの選択肢 — REST、gRPC、メッセージキュー (Kafka/RabbitMQ) — を具体的なコード例を通じて比較します。最後まで読めば、どのシーンでどの方法を選ぶべきかがわかり、サービス間通信を設計する際の典型的なミスを回避できるようになります。

2. 問題のシナリオ

OrderService、PaymentService、InventoryService の 3 つのサービスを持つ e-commerce システムを想定しましょう。OrderService が注文を受け取ったとき、以下の処理が必要です:

  • PaymentService を呼び出して決済を処理する(ユーザーに結果を即座に通知するため、結果を待つ必要があります)。
  • InventoryService を呼び出して在庫を減らす(非同期で処理可能です)。

これら 2 つの異なるニーズにより、異なる 2 つのタイプの通信が必要になります: 同期 (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 ペイロードはバイナリより重い、厳密なコントラクトがない(クライアント/サーバー間でフィールドがズレやすい)、HTTP/1.1 のオーバーヘッドとテキスト解析のため gRPC より遅延が大きい。

注意: REST はリクエスト数が多すぎず、curl/Postman で即座にデバッグする必要がある場合に適しています。システムが毎秒数万件のリクエストを処理する場合、REST JSON はボトルネックになる可能性があります。

4. gRPC — 高パフォーマンス、厳密なコントラクト

gRPC は Protocol Buffers(バイナリ)と HTTP/2 を使用しており、サービス間の内部通信で速度と明確なコントラクトが必要な場合に最適です。

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 スタブを生成します — これが REST にはない「厳密なコントラクト」です。

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 サーバー/クライアントを Spring Boot に統合するのに役立ち、Server を手動で管理する必要がなくなります。

4.3. サーバー実装

@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 ランタイムに自動登録します。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 コントラクトがクライアント/サーバーのスキーマ同期を強制; 双方向ストリーミングをサポート。
  • デメリット: 手動デバッグが困難(ペイロードを直接読むことができない)、専用ツール (grpcurl、BloomRPC) が必要、ブラウザから直接呼び出せない(grpc-web が必要)、チームが proto を学習する必要がある。

注意: グローバルな Web フロントエンドに向けた公開 API に gRPC を使用しないでください(grpc-web インフラが整備されていない場合)。gRPC はサービス間の 内部通信 に最適です。

5. メッセージキュー — 非同期、エラー耐性が高い

OrderService が InventoryService の応答をすぐに待つ必要がない場合、メッセージキュー (Kafka、RabbitMQ) は合理的な選択肢です: メッセージを送信してから処理を継続し、コンシューマーは準備ができたときに処理します。

5.1. Maven 依存関係

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

5.2. プロデューサー — 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 トピックにプッシュし、orderId をキーとして使用して、同じ注文のイベントが同じパーティションに配置され、処理順序が保証されます。

5.3. コンシューマー — 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 はトピックから新しいメッセージを自動的にポーリングします。InventoryService が一時的にダウンした場合、Kafka はメッセージを保持します — データが失われることなく、コンシューマーが復旧したら処理を再開します。

5.4. メリットとデメリット

  • メリット: 強いデカップリング(プロデューサーはコンシューマーの稼働状況を知らない)、エラー耐性が高い(メッセージは保持される)、パーティション単位でスケール可能、イベント駆動アーキテクチャをサポート。
  • デメリット: 即座のレスポンスがない(結果が必要な場合、コールバック/ポーリングを別途設計する必要がある)、データフローのデバッグが複雑(複数のトピックをトレースする必要がある)、ブローカー(Kafka/RabbitMQ)を別途構築・運用・監視する必要がある。

注意: ユーザーに即座にフィードバックを返す必要がある場合(例: 決済の成功/失敗確認)にメッセージキューを使用しないでください — 非同期の遅延により UX が低下します。

6. 3つの方法の簡単な比較

項目 REST gRPC メッセージキュー
通信タイプ 同期 同期 非同期
フォーマット JSON(テキスト) Protobuf(バイナリ) 通常 JSON/Avro
速度 中程度 高い 直接には適用不可(スループットは高い)
コントラクト ルーズ(OpenAPI はオプション) 厳密(.proto) ルーズ(schema registry あれば可)
デバッグ 簡単(curl、Postman) より難しい 最も難しい(複数のコンシューマーをトレース)
サービスダウン時の耐性 即座に失敗 即座に失敗 メッセージは待機、損失なし
適用シーン 公開 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.
  • 結果が不要, エラー耐性 と デカップリング が必要 → メッセージキュー.

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 が正しいパラメータで呼び出されることを検証します。2 番目のテストは、決済失敗時にシステムが在庫減額イベントを 発行しない ことを検証します — verifyNoInteractions により、意図しない副作用がないことを保証します。

9. 3つの方法を組み合わせるときの一般的なミス

  • すべてに REST 同期を乱用する: 「分散 monolith」につながる — サービス A が B を呼び出し、B が C を呼び出し、1 つのサービスが遅くなると全体がタイムアウトします。
  • proto をバージョン管理しない: .proto のフィールドを変更しながら下位互換性を保たない(古いフィールドを削除する代わりに非推奨化する)— 古いクライアントがクラッシュします。
  • メッセージを発行してもべき等性を処理しない: Kafka はコンシューマーのリトライ時にメッセージを再送信できます — InventoryService が同じ orderId に対して 2 回在庫を減らすのはバグです。
  • 同期/非同期を混在させて明確にしない: コードを保守しやすくするため、メソッド/クラス名は通信タイプを明確に表現すべきです。例えば、publishXxxEvent(非同期)は fetchXxx/chargeXxx(同期)と異なります。

10. 結論

すべてのケースに「最適な」方法はありません — REST はシンプルさとデバッグの容易さが必要な場合に適しており、gRPC は内部通信で速度と厳密なコントラクトが必要な場合に適しており、メッセージキューは即座のレスポンスが不要なフローのデカップリングとエラー耐性が必要な場合に適しています。実際には、成熟した microservices システムは通常 3つ全て を同時に使用し、各方法が最も適したタイプの通信に対して使用されます — 上記の OrderService の例に示されているとおりです。

microservicesRESTgRPCmessage-queuespring-boot