karawaci.kode

2026-07-17 · 8 min

gRPC vs REST vs GraphQL: Enterprise Pilih Mana 2026

Setahun lalu, klien enterprise (logistik B2B Indonesia + integrasi banyak partner) decide arsitektur API untuk platform baru. Ada 3 pilihan di meja: gRPC, REST, GraphQL. Tim split opinion. Saya jalankan PoC paralel 4 minggu + observasi 6 bulan post-deploy mixed approach. Share angka, trade-off, dan rekomendasi konkret per use case.

Setup PoC

API yang sama (warehouse management — list inventory, create shipment, update tracking) diimplementasi 3 cara:

  • REST: Spring Boot 3.4 + Spring Web, JSON, OpenAPI 3 spec.
  • gRPC: Spring Boot 3.4 + grpc-spring-boot-starter, Protobuf 3.
  • GraphQL: Spring Boot 3.4 + spring-graphql, schema-first.

Backend: Postgres 17, Redis 7. Same DB schema, same business logic. Bench dari client di same VPC.

Benchmark: 60 menit sustained load

MetricRESTgRPCGraphQL
Throughput14.200 RPS28.400 RPS9.800 RPS
Latency p5012ms4ms22ms
Latency p9538ms11ms78ms
Latency p9995ms24ms180ms
Bandwidth (payload)18 KB4 KB22 KB
CPU server (avg)38%28%52%

gRPC menang besar di throughput + latency + bandwidth. GraphQL paling slow karena N+1 resolver issue (akan dibahas di bawah).

Write scenario (create shipment dengan 5 line items)

MetricRESTgRPCGraphQL
Throughput8.400 RPS11.200 RPS6.800 RPS
Latency p5024ms14ms32ms
Latency p9580ms38ms110ms
Latency p99180ms78ms220ms

Write scenario gap lebih kecil karena DB write dominate latency budget, network/serialization less impactful.

Mobile client scenario (slow 3G simulation, 500ms latency)

MetricRESTgRPCGraphQL
Time to first byte612ms580ms645ms
Total round trips for 3 related queries33 (or 1 streaming)1
Total data fetched54 KB12 KB26 KB

GraphQL menang di round trip count + flexibility. gRPC menang di payload size. Untuk mobile dengan latency tinggi (Indonesia 3G/4G rural): GraphQL lebih responsif kalau query kompleks.

Dev experience comparison

REST

@RestController
@RequestMapping("/api/inventory")
public class InventoryController {
    
    @GetMapping("/{id}")
    public InventoryResponse getInventory(@PathVariable String id) {
        return inventoryService.findById(id)
            .map(InventoryResponse::from)
            .orElseThrow(() -> new NotFoundException(id));
    }
}

OpenAPI 3 spec auto-generated via SpringDoc. Familiar untuk junior dev. Client SDK generate via openapi-generator (Kotlin, TypeScript, Python).

Onboarding: junior dev produktif < 1 minggu.

gRPC

inventory.proto:

syntax = "proto3";
package warehouse.v1;

service InventoryService {
  rpc GetInventory(GetInventoryRequest) returns (Inventory);
  rpc ListInventory(ListInventoryRequest) returns (stream Inventory);
  rpc UpdateStock(UpdateStockRequest) returns (UpdateStockResponse);
}

message GetInventoryRequest {
  string id = 1;
}

message Inventory {
  string id = 1;
  string sku = 2;
  int32 quantity = 3;
  Location location = 4;
  google.protobuf.Timestamp last_updated = 5;
}

Generated stub via protoc:

val inventory = inventoryStub.getInventory(
    GetInventoryRequest.newBuilder().setId(id).build()
)

Type-safe, fast (binary protocol), bidirectional streaming native.

Onboarding: junior dev butuh 2-3 minggu (protoc tooling, schema evolution, stream concept).

GraphQL

Schema:

type Inventory {
  id: ID!
  sku: String!
  quantity: Int!
  location: Location!
  lastUpdated: DateTime!
}

type Query {
  inventory(id: ID!): Inventory
  inventories(filter: InventoryFilter, limit: Int = 20): [Inventory!]!
}

type Mutation {
  updateStock(id: ID!, delta: Int!): Inventory!
}

Resolver:

@Controller
public class InventoryResolver {
    
    @QueryMapping
    public Inventory inventory(@Argument String id) {
        return inventoryService.findById(id);
    }
    
    @SchemaMapping(typeName = "Inventory")
    public Location location(Inventory inventory) {
        return locationService.findById(inventory.getLocationId()); // N+1 trap!
    }
}

N+1 fix via DataLoader:

@BatchMapping(typeName = "Inventory")
public Map<Inventory, Location> location(List<Inventory> inventories) {
    Set<String> locationIds = inventories.stream()
        .map(Inventory::getLocationId).collect(Collectors.toSet());
    Map<String, Location> locationMap = locationService.findAllById(locationIds);
    return inventories.stream()
        .collect(Collectors.toMap(i -> i, i -> locationMap.get(i.getLocationId())));
}

Onboarding: junior dev butuh 4-6 minggu (resolver pattern, DataLoader, N+1 awareness, schema federation).

Schema evolution

REST

Backward-compatible change (add optional field): no client break. Breaking change: version in URL (/v2/inventory). Lifecycle: documented, sunset notification, dual-version support 6-12 bulan.

Mature ecosystem, simple semantics.

gRPC

Protobuf rules:

  • Add field: must be optional (Proto3 default), reserved field number, new field higher number. No break.
  • Rename field: don’t, jangan pernah. Pakai reserved keyword untuk old name.
  • Change field type: no, breaking. Add new field instead.

Strict discipline, but predictable. Tooling (buf, protolint) catch most violations CI-time.

GraphQL

Add field: backward-compatible (client opt-in via query). Remove field: deprecate first via @deprecated, sunset after grace period. Breaking change: schema federation kompleks, careful coordination cross-team.

Schema flexible but coordination overhead di organisasi besar.

Yang dipilih untuk klien

Setelah PoC + 6 minggu analisis tim:

Use casePilihanAlasan
Service-to-service internalgRPCThroughput tertinggi, type-safe, streaming
Public API for partners (15 partner)RESTUniversal, OpenAPI familiar, partner support
Mobile app (iOS + Android)gRPCNative SDK, bandwidth efisien, streaming
Web SPA (admin dashboard)GraphQLMultiple data shape per page, flexible
Webhook outgoing (notify partner)RESTStandar, partner accept
Integration with legacy SAPRESTSAP integration native REST

Mixed approach. Bukan “satu pilihan untuk semua”. Pertimbangan organizational: tim mobile berbeda dari tim web, kebutuhan beda.

Implementation di production

gRPC service-to-service

Setup Envoy sidecar untuk:

  • TLS termination internal
  • Authentication via mTLS
  • Observability (gRPC-aware metrics + tracing via OTel)
  • Retry + circuit breaker
  • Load balancing client-side (gRPC native LB)
# envoy sidecar config snippet
clusters:
  - name: inventory-service
    type: STRICT_DNS
    connect_timeout: 0.5s
    http2_protocol_options: {}
    load_assignment:
      cluster_name: inventory-service
      endpoints:
        - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: inventory-service.internal
                    port_value: 9090
    health_checks:
      - timeout: 1s
        interval: 10s
        unhealthy_threshold: 3
        healthy_threshold: 2
        grpc_health_check: {}

REST public API gateway

Spring Cloud Gateway + OpenAPI:

  • Rate limit per API key (1.000 req/min default, tier-based)
  • API key + JWT validation (OAuth 2.1 dari OAuth PKCE Spring)
  • Request logging untuk audit
  • Versioning via URL path

GraphQL gateway (admin dashboard)

Spring GraphQL + DataLoader pattern. Schema federation tidak dipakai (overhead tidak worth untuk single backend).

Setup:

  • N+1 prevention via DataLoader (mandatory di code review).
  • Query complexity analysis (reject query depth > 7).
  • Persisted queries (production: only allowlisted queries, no arbitrary).
  • Caching via Apollo Server-side (planned, belum implement).

Hasil 6 bulan post-deploy

MetricBefore (monolith REST)After (mixed)
Service-to-service p99180ms38ms (gRPC)
Mobile API bandwidth100%28% (gRPC)
Admin dashboard page load2.4s0.9s (GraphQL)
Partner integration time6-8 weeks4-5 weeks (REST + OpenAPI)
Developer onboarding2-3 weeks3-5 weeks (mixed)
Operational complexitylowmedium-high

Latency win untuk gRPC service-to-service signifikan. Admin dashboard page load win karena GraphQL single round trip vs REST multiple call.

Yang break

1. gRPC client tidak compatible saat schema field deprecated

Field legacy_id di-deprecate di v2. Mobile app versi lama tetap pakai. Saat field di-remove dari protobuf, mobile app crash deserialize.

Fix: jangan pernah remove field, hanya reserved. Plus client version tracking, sunset old client version > 6 bulan.

2. GraphQL N+1 issue di production

DataLoader implementation di-lupakan di beberapa resolver. Single GraphQL query trigger 80+ DB query.

Detection: alert db.query.count > 30 per request. Investigate, fix DataLoader.

Pasca insiden: pre-commit hook yang check pattern resolver tanpa DataLoader.

3. REST API versioning kompleks

V1 + V2 coexist. Dokumentasi 2 versi, bug fix harus apply ke 2. Bandwidth tim hilang untuk maintenance dual-version.

Fix: hardcap V1 deprecation 12 bulan post-V2 launch. Communicate explicit ke partner.

4. gRPC-Web limitation di browser

Saya pernah pertimbangkan gRPC-Web untuk admin dashboard (single backend untuk web + mobile + internal). gRPC-Web ada limitation (no server streaming yang full, no bidirectional streaming).

Fix: stick dengan GraphQL untuk web. Mixed protocol OK.

5. Schema evolution coordination

3 protocol = 3 schema/spec. Sometimes change di domain model harus propagate ke 3 tempat (proto + OpenAPI + GraphQL schema).

Fix: single source of truth di domain model Java, dengan generator yang produce 3 protocol spec. Maintain 1 file, generate 3.

# Maven plugin generate proto + OpenAPI + GraphQL schema dari Java annotations
./mvnw schema:generate-all

Verdict

Tidak ada protocol “terbaik” untuk semua. Per use case:

  • gRPC: service-to-service backend (terutama high-throughput), mobile native client, streaming workload.
  • REST: public API for partners, integration legacy/3rd party, simple CRUD.
  • GraphQL: dashboard SPA dengan complex data shape per page, multiple client dengan kebutuhan data berbeda.

Untuk enterprise Indonesia: mixed approach itu mature engineering decision, bukan indecisiveness. Yang penting: clear ownership per protocol, dokumentasi standardize, dan tim siap maintain multi-protocol stack.

Bukan magic. Mixed approach butuh tim yang familiar 3 protocol, tooling investment, dan disiplin schema evolution. Untuk SMB: pilih 1 protocol dominan, kompromi performance untuk simplicity.

Lihat juga Spring Boot modular monolith untuk konteks bagaimana protocol choice intersect dengan service boundary architecture.

Ditulis oleh Reza Pradipta