REST, gRPC mi GraphQL mi? Aynı Bookstore Senaryosunu Üç Stilde Ölçerek Karar Vermek
Hangi API stilini seçeceğini client türü ve ekranın ihtiyacı belirler: ölçümde küçük service-to-service çağrılarında REST JSON en az server CPU'su harcadı, gRPC ağdaki byte'ı yarıya indirdi, kitap detay ekranında ise tek istekli GraphQL ve gRPC-Web dört istekli REST'i geride bıraktı. k6 ile aynı Bookstore senaryolarını üç stilde ölçüyor, karar boyutlarını ve birlikte kullanımı tartışıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Grpc.AspNetCore 2.85, Hot Chocolate 16.7 ve k6 2.4 ile derlenip denenmiştir; bütün ölçümler aynı makinede, Release build ile alınmıştır.
Önce Tahmin Et
Ordering service'i her siparişte Inventory'den iki kalemlik bir stok rezervasyonu istiyor. Aynı işi üç biçimde yazdık: JSON (JavaScript Object Notation) gövdeli bir REST (Representational State Transfer) endpoint'i, bir gRPC RPC'si (başka bir makinedeki fonksiyonu yerel gibi çağırma) ve bir GraphQL mutation'ı. Server aynı, iş kuralı aynı, veri aynı. 20 eşzamanlı client'la 30 saniye yük verdiğimizde hangisi saniyede en çok rezervasyon yapar ve server'da istek başına en az CPU harcar?
"Elbette gRPC; binary, HTTP/2 (HyperText Transfer Protocol 2), protobuf" diyorsan yalnız değilsin. Benim ölçümümde kazanan o olmadı. Neden olmadığını ve bunun karar için ne anlama geldiğini bu yazıda sayılarla konuşuyoruz.
Deney Düzeneği
Serinin önceki yazılarında üç stili ayrı uygulamalarda kurduk. Karşılaştırma için hepsini tek bir ASP.NET Core uygulamasına, aynı bellek içi veriye (50 kitap, 10 yazar, 200 yorum) bağladım. Veritabanını bilerek dışarıda bıraktım: veritabanı süresi üç stilde de aynı olurdu ve API stilinin kendi maliyetini gölgelerdi. Kestrel iki port dinliyor:
builder.WebHost.ConfigureKestrel(kestrel =>
{
// tarayıcı ve REST/GraphQL client'ları için HTTP/1.1, service-to-service gRPC için h2c
kestrel.ListenLocalhost(5300, o => o.Protocols = HttpProtocols.Http1);
kestrel.ListenLocalhost(5301, o => o.Protocols = HttpProtocols.Http2);
});
builder.Services.AddSingleton<BookstoreData>();
builder.Services.AddGrpc();
builder.Services.AddGraphQLServer().AddBenchTypes();
var app = builder.Build();
app.UseGrpcWeb();
app.MapPost("/api/reservations", (ReserveRequest request, BookstoreData data) =>
data.Reserve(request.OrderId, request.Lines) is { } reservation
? Results.Created($"/api/reservations/{reservation.ReservationId}", reservation)
: Results.Conflict());
app.MapGrpcService<InventoryGrpcService>().EnableGrpcWeb();
app.MapGrpcService<CatalogGrpcService>().EnableGrpcWeb();
app.MapGraphQL();
İki senaryo var. (a) Service-to-service: Ordering'in Inventory'ye stok rezervasyonu; REST'te bir POST, gRPC'de InventoryService/ReserveStock, GraphQL'de reserveStock mutation'ı. (b) Angular kitap detay ekranı: kitap, yazar, stok ve son iki yorum. REST'te kaynak başına bir endpoint, GraphQL'de serinin önceki yazılarındaki tek sorgu, gRPC'de ise ekrana özel bir CatalogService/GetBookDetail RPC'si; tarayıcı doğrudan gRPC konuşamadığı için gRPC-Web üzerinden.
Yükü k6 ile verdim. Bütün script'ler aynı ayarları kullanıyor: 20 sanal kullanıcı, her biri bir öncekinin cevabını bekleyip yeni istek atıyor, önce 5 saniye ısınma, sonra 30 saniye ölçüm:
export const options = {
vus: 20,
duration: __ENV.DURATION || '30s',
summaryTrendStats: ['med', 'p(95)'],
};
import grpc from 'k6/net/grpc';
import { check } from 'k6';
import { summarize } from './summary.js';
import { lines } from './lines.js';
export { options } from './options.js';
const client = new grpc.Client();
client.load(['../Bookstore.Bench/Protos'], 'bench.proto');
const request = { order_id: 'ord-1', lines: lines() };
export default function () {
if (__ITER === 0) {
client.connect('localhost:5301', { plaintext: true });
}
const res = client.invoke('bookstore.bench.v1.InventoryService/ReserveStock', request);
check(res, { ok: (r) => r.status === grpc.StatusOK });
}
export function handleSummary(data) { return summarize((__ENV.LINES ? 'L' + __ENV.LINES + '-' : '') + 'a-grpc', data); }
handleSummary her koşunun sonunda tek satırlık bir özet basıyor: saniyedeki işlem, p50/p95 süre, işlem başına istek sayısı ve gönderilen/alınan byte. Server sürecinin CPU süresini koşudan önce ve sonra okuyup farkı işlem sayısına böldüm. REST ve GraphQL script'leri aynı düzende, http.post ile. Kitap detayının REST hâli tarayıcının yapacağını yapıyor: yazar için kitabın authorId'si gerektiğinden iki tur gidiyor.
// kitap detay ekranı: kitap, stok ve yorumlar paralel; yazar için kitabın authorId'si gerekiyor
export default function () {
const [book, stock, reviews] = http.batch([
['GET', `${base}/books/1`],
['GET', `${base}/books/1/stock`],
['GET', `${base}/books/1/reviews?limit=2`],
]);
const author = http.get(`${base}/authors/${book.json('authorId')}`);
check(null, { ok: () => [book, stock, reviews, author].every((r) => r.status === 200) });
}
k6'nın gRPC-Web desteği yok; isteği elle çerçeveledim. gRPC-Web gövdesi bir bayrak byte'ı, 4 byte uzunluk ve protobuf mesajından oluşuyor:
// gRPC-Web çerçevesi: 1 byte bayrak + 4 byte uzunluk + protobuf (book_id=1, review_count=2)
const frame = new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x04, 0x08, 0x01, 0x10, 0x02]).buffer;
const params = { headers: { 'Content-Type': 'application/grpc-web+proto', 'X-Grpc-Web': '1' }, responseType: 'binary' };
Bir uyarı: server ve k6 aynı makinede çalıştı ve aynı CPU'yu paylaştı. Ağ gecikmesi yok, her istek loopback'ten geçiyor. Bu yüzden saniyedeki işlem sayısı server'ın değil "server + client" ikilisinin kapasitesi; server'ın kendi maliyeti için işlem başına server CPU'su daha güvenilir gösterge. Sayıların mutlak değeri senin makinende farklı olur, sıralamalar ve oranlar asıl bilgi.
Sonuçlar
| Senaryo | Stil | İşlem/s | p50 / p95 (ms) | İstek/işlem | Gönderilen / alınan byte | Server CPU (µs/işlem) |
|---|---|---|---|---|---|---|
| (a) 2 kalem | REST | 87.388 | 0,19 / 0,42 | 1 | 245 / 337 | 31 |
| (a) 2 kalem | gRPC | 59.279 | 0,28 / 0,67 | 1 | 117 / 108 | 47 |
| (a) 2 kalem | GraphQL | 85.924 | 0,21 / 0,36 | 1 | 409 / 298 | 47 |
| (a) 50 kalem | REST | 77.871 | 0,22 / 0,44 | 1 | 2.070 / 337 | 44 |
| (a) 50 kalem | gRPC | 20.287 | 0,81 / 2,10 | 1 | 1.029 / 108 | 70 |
| (a) 50 kalem | GraphQL | 61.566 | 0,29 / 0,50 | 1 | 2.234 / 298 | 91 |
| (b) kitap detayı | REST | 30.817 | 0,61 / 1,03 | 4 | 348 / 1.046 | 97 |
| (b) kitap detayı | gRPC-Web | 102.427 | 0,17 / 0,33 | 1 | 203 / 294 | 28 |
| (b) kitap detayı | GraphQL | 82.973 | 0,22 / 0,38 | 1 | 302 / 429 | 47 |
Byte'lar HTTP header'ları ve çerçeveleme dahil, k6'nın ölçtüğü gerçek trafik. Yalnız yanıt gövdelerine bakınca kitap detayı REST'te 410 byte (93 + 66 + 27 + 224), GraphQL'de 257 byte, gRPC-Web'de 140 byte (114 byte protobuf mesajı, çerçeve ve grpc-status: 0 trailer'ı).
Sonuçları Okumak: Service-to-Service
Baştaki tahmin tutmadı. İki kalemlik rezervasyonda REST, server'da işlem başına 31 µs CPU ile en ucuzu oldu; gRPC ve GraphQL 47 µs. Mesaj büyüyünce (50 kalem) fark açıldı ama sıralama değişmedi: REST 44, gRPC 70, GraphQL 91 µs. Bu senaryoda System.Text.Json ile HTTP/1.1, protobuf ile HTTP/2'den daha az CPU harcadı. HTTP/2'nin akış yönetimi, header sıkıştırması ve gRPC'nin çerçeveleme ve trailer'ları küçük bir unary çağrıda sabit bir ek maliyet; mesaj boyutu küçükken bu maliyet protobuf'un kazancından büyük kalıyor.
gRPC'nin throughput'u daha da düşük göründü (59 bin karşısında 87 bin). Bunun büyük kısmı client tarafında: k6 sürecinin kendi CPU süresini ayrıca ölçtüğümde, çağrı başına k6'nın harcadığı CPU REST'te 51 µs, gRPC'de 87 µs çıktı. k6'nın gRPC client'ı mesajları proto tanımından dinamik olarak işliyor; .NET'in derlenmiş client'ları ile aynı şey değil. Aynı makinede çalıştıkları için client'ın harcadığı CPU server'dan çalınıyor ve throughput'u düşürüyor. Bu yüzden "gRPC yavaş" sonucuna değil, "küçük unary çağrılarda gRPC'nin CPU avantajı yok" sonucuna varıyorum.
gRPC'nin açık kazandığı yer ağdaki byte. İki kalemde gönderilen 117 byte REST'in yarısından az, alınan 108 byte üçte biri; 50 kalemde gönderilen 1.029 byte REST'in yarısı. Kazancın bir kısmı protobuf'tan, bir kısmı HTTP/2'nin header sıkıştırmasından (HPACK, Header Compression for HTTP/2) geliyor: aynı bağlantıdaki tekrar eden header'lar her istekte yeniden gönderilmiyor. Bölgeler arası trafiğin ücretli olduğu bir bulutta ya da dar bant genişliğinde bu sayılar doğrudan maliyet.
GraphQL service-to-service'te bir şey kazandırmadı. Gövde REST'ten büyük (sorgu metni her istekte gidiyor; persisted operation kullanmadım), CPU REST'ten fazla. Parse, doğrulama ve execution motoru, iki service arasındaki sabit şekilli bir çağrıda karşılığı olmayan bir esneklik bedeli.
Sonuçları Okumak: Kitap Detay Ekranı
Burada sonuç tersine dönüyor. REST dört istek attı ve server'da ekran başına 97 µs CPU harcadı: dört isteğin her birinin routing, serileştirme ve HTTP işleme maliyeti ayrı. GraphQL tek istekle 47 µs'de, ekrana özel gRPC-Web RPC'si 28 µs'de bitti. Alınan byte REST'te 1.046, GraphQL'de 429, gRPC-Web'de 294. REST yanıtları ekranın kullanmadığı alanları da taşıdı (isbn, bio, createdAt), header'lar dört kez gitti.
Gecikme tarafında yerel makinedeki p95 farkı (1,03 ms karşısında 0,38 ms) küçük görünüyor, çünkü loopback'te bir tur mikrosaniyeler sürüyor. Gerçek kullanıcıda tablo başka: REST ekranı yazar için kitabın cevabını beklemek zorunda, yani en az iki ardışık tur. Gidiş-dönüşü 80 ms olan bir mobil bağlantıda bu yalnız ağ beklemesi olarak en az 160 ms demek; tek istekli GraphQL ve gRPC-Web'de en az 80 ms. Bu bir ölçüm değil, tur sayısından çıkan basit bir hesap; ama mobil ekranlarda GraphQL'in asıl değeri tam bu.
gRPC-Web'in burada en ucuz çıkmasının sebebi, RPC'nin tam bu ekran için tasarlanmış olması. Aynı şeyi REST'te ekrana özel bir BFF (Backend for Frontend; her frontend'e özel ara backend katmanı) endpoint'iyle de yapabilirdin; o zaman fark tek bir isteğe iner. Yani bu senaryoda kazanan teknoloji değil, ekranın ihtiyacını tek bir çağrıda karşılayan tasarım. GraphQL bunu her ekran için ayrı endpoint yazmadan, client'ın sorgusuyla sağlıyor; gRPC-Web ve BFF her ekran için server'da kod istiyor.
Karar Boyutları

Ölçüm kararın yalnız bir parçası. Seriyi boyunca karşılaştığımız boyutlar:
- Client türü: tarayıcı gRPC'yi doğrudan konuşamaz (gRPC-Web ya da transcoding gerekir); üçüncü taraf geliştiriciler REST bekler; iç service'ler için gRPC'nin üretilen client'ları en az uğraştıran yol.
- Contract yönetimi: gRPC'de .proto ve field number kuralları, GraphQL'de schema ve
@deprecatedile versiyonsuz evrim, REST'te OpenAPI ve URL ya da header versiyonlama. - Caching: REST'te URL başına HTTP cache bedava; GraphQL'de ancak persisted operation ve GET ile, gRPC'de pratikte yok.
- Streaming: gRPC'de üç streaming türü doğrudan destekleniyor; GraphQL'de subscription ve
@defer, REST'te SSE (sunucudan istemciye tek yönlü event akışı) ya da ayrı bir kanal. - Güvenlik yüzeyi: GraphQL'de cost analysis, derinlik ve persisted operation olmadan bir sorgu tek başına server'ı yorabilir; REST ve gRPC'de bir isteğin maliyeti endpoint'e bağlı ve öngörülebilir.
- Observability ve tooling: REST'te her araç hazır; gRPC'de status code'lar ve deadline'lar zengin ama curl ile denemek zor; GraphQL'de tek endpoint, operation adına göre ölçüm kurmayı şart koşuyor.
Birlikte Kullanmak
Gerçek sistemlerde soru çoğu zaman "hangisi" değil "nerede hangisi". Bookstore için vardığım düzen: dışarıya açık, üçüncü tarafların kullandığı katalog API'si REST; Angular ve mobil uygulamanın konuştuğu katman GraphQL; service'ler arasında, özellikle streaming ve deadline'ın önemli olduğu Ordering → Inventory gibi çağrılarda gRPC. GraphQL katmanı burada bir BFF rolü oynuyor: resolver'lar arkadaki gRPC service'lerini çağırıyor, serinin GraphQL temelleri yazısındaki stok field'ı gibi. Microservices serisinin API Gateway ve Backend for Frontend yazısındaki "edge'de ne olur" tartışması ve API Gateway ve YARP (Microsoft'un .NET tabanlı reverse proxy kütüphanesi) serisi bu katmanın nerede duracağını konuşuyor.
Service sayısı arttıkça her ekip kendi GraphQL server'ını yazarsa client birden çok graph ile uğraşmak zorunda kalır. Hot Chocolate Fusion, birden çok GraphQL service'inin schema'sını tek bir graph'ta birleştiren bir gateway; GraphQL çalışma grubundaki Composite Schemas spesifikasyonu çalışmasına dayanıyor. Birkaç service ve tek bir ekiple çoğu zaman gereksiz: tek bir BFF GraphQL server'ı, arkasındaki gRPC service'lerini çağırarak aynı işi daha az hareketli parçayla yapar. Fusion'ı, farklı ekiplerin kendi alanlarını bağımsız deploy etmek istediği bir organizasyonda düşün.
| Durum | Kullanma | Neden |
|---|---|---|
| Public CRUD API, üçüncü taraf client'lar | GraphQL | HTTP cache, öngörülebilir maliyet ve tooling kaybı, karşılığında esneklik gerekmiyor |
| Yalnız tarayıcıdan kullanılan bir uygulama | gRPC | gRPC-Web ve proxy katmanı ekler; REST ya da GraphQL aynı işi daha az parçayla yapar |
| Basit, sabit şekilli iç çağrı (birkaç alan, düşük trafik) | GraphQL ve çoğu zaman gRPC | Ölçümde REST JSON en az CPU harcadı; contract ve streaming ihtiyacı yoksa ek katman kazandırmıyor |
| Yüksek hacimli, streaming'li ya da deadline zincirli iç trafik | REST | Byte, streaming ve deadline propagation gRPC'nin doğal alanı |
| Çok kaynağı birleştiren mobil ekran | Ekran başına REST zinciri | Ardışık turlar mobil ağda yüzlerce milisaniye ekler |
Üretimde Dikkat
Kendi ölçümünü kendi trafiğinle yap. Buradaki sayılar bir geliştirici makinesinde, loopback üzerinde ve veritabanısız alındı. Gerçek sistemde veritabanı ve ağ süresi API stilinin farkını çoğu zaman bastırır; ölçümü production'a benzeyen bir ortamda, gerçek mesaj boyutlarıyla tekrarla. k6 ile yük testini nasıl tasarlayacağını Performance ve Load Testing serisinde ayrıntılı anlatıyoruz.
Yük üreticiyi server'dan ayır. Aynı makinede çalışan yük üretici CPU'yu paylaşır ve özellikle gRPC gibi client tarafı pahalı protokollerde sonuçları çarpıtır. Server CPU'sunu işlem başına ayrıca ölçmek bu yüzden önemli.
Byte'ı da maliyet olarak say. CPU farkı küçük olsa bile bölgeler arası ya da mobil trafikte gRPC'nin yarı yarıya küçük mesajları faturaya ve pil ömrüne yansır.
Kararı tek bir sayıya bağlama. Bu yazıdaki en büyük fark (kitap detayında REST'in dört isteği) teknolojiden değil, endpoint tasarımından geldi.
Sık Düşülen Tuzaklar
"gRPC her zaman daha hızlı" varsayımı. Küçük unary çağrılarda JSON ve HTTP/1.1 daha az server CPU'su harcayabilir; gRPC'yi contract, streaming, deadline ve byte için seç.
REST'i kaynak başına endpoint ile ölçüp GraphQL'i ekran sorgusuyla ölçmek ve REST'i kaybeden ilan etmek. Adil karşılaştırmada REST'in BFF seçeneği de karşılaştırmaya dahil edilmeli.
Service-to-service'te GraphQL kullanmak. Esnekliği kullanılmayan bir sorgu motoru her çağrıda parse ve doğrulama maliyeti öder.
Yük testini Debug build'de ya da log seviyesi Information'dayken yapmak. Loglama tek başına çağrı başına süreyi katlayabilir.
Küçük Bir Deneme
★☆☆ k6 script'lerini kendi makinende çalıştır ve tablonun sıralamasını karşılaştır. Hangi oranlar korunuyor, hangileri değişiyor?
★★☆ Kitap detayı için REST'e tek istekli bir BFF endpoint'i (/api/screens/book-detail/1) ekle ve (b) senaryosunu yeniden ölç. GraphQL ile arasındaki fark ne kadar kaldı?
★★★ (a) senaryosunu k6 yerine bir .NET client'ıyla (Grpc.Net.Client ve HttpClient, ayrı bir makineden ya da container'dan) ölç ve client tarafı maliyetin sonuçları nasıl değiştirdiğini yaz.
Seri boyunca gRPC'yi ve GraphQL'i .NET 10 ve Hot Chocolate 16 ile kurduk. Ama okuyacağın projelerin çoğu daha eski sürümlerle yazılmış olacak. Serinin son yazısında .NET Core 3.0'dan .NET 11'e gRPC'nin, Hot Chocolate 13'ten 16'ya GraphQL tarafının nasıl değiştiğini ve eski bir projeyi okurken nelere dikkat edeceğini konuşuyoruz.