Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Mimariye Giriş — #03

Quality Attribute Senaryoları: "Hızlı Olsun"u Ölçülebilir Bir Gereksinime Çevirmek

Mert Özen 7 Kas 2026 10 dk 4 görüntülenme
Quality Attribute Senaryoları: "Hızlı Olsun"u Ölçülebilir Bir Gereksinime Çevirmek

"Hızlı olsun" gibi bir cümle, SEI'nin altı parçalı quality attribute senaryosuyla (source, stimulus, environment, artifact, response, response measure) ölçülebilir ve test edilebilir bir requirement'a dönüşür. Örnekte ortalama 121 ms ile hedefin altında görünen arama, p95'te 659 ms ile hedefi kaçırıyor; utility tree ile önceliklendirme ve senaryoları teste ve monitoring'e bağlamak.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un requirement dokümanında katalog araması için tek bir cümle var: "Arama hızlı olmalı." Bir önceki yazıda bunun bir dilek olduğunu söylemiştik. Ekip bu yazıda onu ölçmeye karar veriyor. Aşağıdaki program arama endpoint'ini ayağa kaldırıyor, 20 eşzamanlı kullanıcıyla 400 istek atıyor ve süreleri ASP.NET Core'un kendi ürettiği metrikten okuyor. Endpoint'teki gecikmeyi sabit bir seed'le simüle ediyoruz: isteklerin çoğu hızlı, küçük bir kısmı cache'te olmayan yavaş bir sorguya düşüyor.

using System.Collections.Concurrent;
using System.Diagnostics.Metrics;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.WebHost.UseUrls("http://127.0.0.1:5203");
WebApplication app = builder.Build();

// Test için sabit seed: her 100 aramadan ~8'i yavaş sorguya düşüyor
Random random = new(42);
int[] delays = [.. Enumerable.Range(0, 400).Select(_ =>
    random.NextDouble() < 0.08 ? random.Next(450, 900) : random.Next(30, 90))];

app.MapGet("/books/search", async (int i) =>
{
    await Task.Delay(delays[i]);
    return Results.Ok(new[] { "Dune", "Emma" });
});

ConcurrentBag<double> durationsMs = [];
using MeterListener listener = new();
listener.InstrumentPublished = (instrument, l) =>
{
    if (instrument.Meter.Name == "Microsoft.AspNetCore.Hosting" &&
        instrument.Name == "http.server.request.duration")
    {
        Console.WriteLine($"listening: {instrument.Meter.Name} / {instrument.Name} ({instrument.Unit})");
        l.EnableMeasurementEvents(instrument);
    }
};
listener.SetMeasurementEventCallback<double>((_, seconds, _, _) => durationsMs.Add(seconds * 1000));
listener.Start();

await app.StartAsync();

using HttpClient http = new() { BaseAddress = new Uri("http://127.0.0.1:5203") };
await Parallel.ForEachAsync(Enumerable.Range(0, 400), new ParallelOptions { MaxDegreeOfParallelism = 20 },
    async (i, ct) => await http.GetStringAsync($"/books/search?i={i}", ct));

await app.StopAsync();

double[] sorted = [.. durationsMs.Order()];
Console.WriteLine($"requests: {sorted.Length}");
Console.WriteLine($"average:  {sorted.Average():F0} ms");
Console.WriteLine($"p50:      {Percentile(sorted, 50):F0} ms");
Console.WriteLine($"p95:      {Percentile(sorted, 95):F0} ms");
Console.WriteLine($"p99:      {Percentile(sorted, 99):F0} ms");
Console.WriteLine($"max:      {sorted[^1]:F0} ms");

QualityScenario search = new(
    Source: "Customer on the web site",
    Stimulus: "Searches the catalog by title",
    Artifact: "Catalog search endpoint",
    Environment: "Normal operation, 20 concurrent users",
    Response: "Matching books are returned",
    Percentile: 95,
    ThresholdMs: 300);

double measured = Percentile(sorted, search.Percentile);
Console.WriteLine($"scenario p{search.Percentile} < {search.ThresholdMs} ms: " +
    $"{(measured < search.ThresholdMs ? "PASS" : "FAIL")} ({measured:F0} ms)");

// Nearest-rank yöntemi: sıralı listede ceil(p/100 * n). eleman
static double Percentile(double[] sorted, double p) =>
    sorted[(int)Math.Ceiling(p / 100 * sorted.Length) - 1];

public sealed record QualityScenario(
    string Source,
    string Stimulus,
    string Artifact,
    string Environment,
    string Response,
    double Percentile,
    double ThresholdMs);

Hedef 300 milisaniye. Ortalama kaç çıkar, hedefin altında mı? Peki isteklerin yüzde 95'i hangi sürenin altında kalır? Senaryo geçer mi?

Ortalama Neden Yanıltır?

listening: Microsoft.AspNetCore.Hosting / http.server.request.duration (s)
requests: 400
average:  121 ms
p50:      66 ms
p95:      659 ms
p99:      828 ms
max:      880 ms
scenario p95 < 300 ms: FAIL (659 ms)

Ortalama 121 milisaniye. Bir dashboard'da yalnız ortalamayı gösteriyorsan, hedefin yarısının altındasın ve herkes mutlu. Oysa isteklerin yüzde 5'i 659 milisaniyeden, yüzde 1'i 828 milisaniyeden uzun sürüyor. Medyan (p50) 66 milisaniye: tipik bir istek çok hızlı, ama dağılımın kuyruğu uzun. Ortalama bu iki dünyayı tek bir sayıda karıştırıyor ve ikisini de yanlış anlatıyor.

Kuyruğun neden önemli olduğunu bir hesapla görelim. Burada isteklerin yaklaşık yüzde 8'i yavaş. Bir müşteri bir oturumda on arama yapıyorsa, en az birinin yavaş olma ihtimali 1 − 0,92¹⁰, yani yaklaşık yüzde 57. Kullanıcıların yarısından fazlası her oturumda en az bir kez takılıyor ve ortalama bunu hiç göstermiyor. Latency'yi bu yüzden percentile'larla konuşuruz: p50 tipik deneyimi, p95 ve p99 kuyruğu anlatır.

Programın ilk satırı da önemli bir şey söylüyor: süreleri kendimiz ölçmedik. ASP.NET Core, .NET 8'den beri her HTTP (Hypertext Transfer Protocol) isteğinin süresini Microsoft.AspNetCore.Hosting meter'ındaki http.server.request.duration histogram'ına saniye cinsinden yazıyor. MeterListener bu instrument'ı process içinde dinlemenin BCL (Base Class Library) yolu; production'da aynı metriği OpenTelemetry ile bir monitoring sistemine gönderirsin. Yani "hızlı olsun" requirement'ını ölçecek veri zaten framework'te üretiliyor, eksik olan şey neyin ölçüleceğini ve eşiğin ne olduğunu söyleyen cümle.

Quality Attribute Senaryosu: Altı Parça

Bu cümleyi yazmanın yerleşik bir kalıbı var. SEI (Software Engineering Institute) araştırmacıları Bass, Clements ve Kazman'ın Software Architecture in Practice kitabı (4. baskı, 2021), bir quality attribute requirement'ını altı parçalı bir senaryo olarak yazmayı önerir:

Source of stimulus: uyarıyı üreten kim ya da ne. Bir müşteri, bir saldırgan, ödeme sağlayıcısı, bir developer. Kaynak önemli, çünkü sistem güvendiği bir kaynağa başka, tanımadığı bir kaynağa başka tepki verebilir. Stimulus: sisteme gelen ve karşılanması gereken koşul; bir istek, bir hata, bir değişiklik talebi. Environment: uyarının geldiği andaki koşullar; normal çalışma, kampanya zirvesi, bir bağımlılığın düştüğü durum, deploy sırası. Artifact: uyarının hedef aldığı parça; bütün sistem ya da bir endpoint, bir module. Response: sistemin uyarıya verdiği tepki. Response measure: tepkinin ölçülebilir hedefi; senaryoyu test edilebilir yapan parça budur.

Kitap iki tür senaryodan bahseder. General scenario'lar sisteme özel değildir; "bir bağımlılık yanıt vermezse sistem çalışmaya devam eder" gibi her sisteme uygulanabilecek kalıplardır. Concrete scenario ise bunun belirli bir sisteme uyarlanmış halidir ve fonksiyonel requirement'larda use case'in oynadığı rolü quality attribute'lar için oynar. Açılıştaki QualityScenario record'u bir concrete scenario'yu koda dökmüş halde: altı parçanın beşi metin, response measure ise bir percentile ve bir eşik. Programın son satırı da senaryoyu bir teste çeviriyor ve sonuç net: FAIL.

Bookstore'dan Üç Senaryo

Bir önceki yazıda Bookstore için ilk üç characteristic'i seçmiştik: availability, performance ve modifiability. Her biri için bir concrete scenario:

ParçaPerformanceAvailabilityModifiability
SourceWeb sitesindeki müşteriÖdeme sağlayıcısıÜrün ekibi
StimulusKitabı başlığa göre arar5 saniye boyunca yanıt vermezİkinci ülke için yeni bir ödeme yöntemi ister
EnvironmentKampanya zirvesi, saniyede 200 istekKampanya zirvesiNormal geliştirme dönemi
ArtifactCatalog arama endpoint'iCheckout akışıPayment module'ü
ResponseEşleşen kitaplar dönerSipariş PaymentPending olarak kaydedilir, müşteriye "siparişin alındı" denir, ödeme arka planda tekrar denenirYöntem eklenir, test edilir, deploy edilir
Response measurep95 < 300 ms, hata oranı < %0,1Kayıp sipariş 0; checkout p95 < 800 ms; sağlayıcı dönünce 15 dakika içinde ödeme tamamlanırEn fazla 3 developer-gün; Payment dışında hiçbir module değişmez

Tablodaki her hücre bir tartışmayı kapatıyor. "Hızlı" artık "kampanya zirvesinde p95 300 milisaniyenin altında". "Yüksek erişilebilirlik" artık "ödeme sağlayıcısı düştüğünde bile sipariş kaybetmemek". "Bakımı kolay" ise "yeni ödeme yöntemi üç günde ve tek module'e dokunarak". Üçüncüsü özellikle değerli, çünkü modifiability genellikle ölçülemez sanılır. Ölçülebilir; sadece birimi milisaniye değil, developer-gün ve değişen module sayısıdır.

Availability senaryosundaki response da bir mimari karar ima ediyor: ödeme sağlayıcısı yanıt vermediğinde siparişi reddetmek yerine "PaymentPending" durumunda kabul etmek. Bu, bir sonraki yazının konusu olan trade-off'lardan birinin habercisi; consistency'den biraz vazgeçip availability kazanıyoruz.

Utility Tree ile Önceliklendirme

Senaryolar çoğaldıkça hangisinin önce ele alınacağı sorusu gelir. ATAM (Architecture Tradeoff Analysis Method, SEI'den Kazman, Klein ve Clements'in 2000 tarihli yöntemi) bunun için utility tree kullanır. Ağacın kökü "utility", yani sistemin genel iyiliği; altında quality attribute'lar, onların altında daha somut alt başlıklar, yapraklarda da concrete scenario'lar var. Her senaryoya iki puan verilir: iş için önemi ve mimari açıdan gerçekleştirmenin zorluğu, ikisi de H (high), M (medium) ya da L (low). Bookstore için küçük bir ağaç:

  • Performance › arama › kampanyada saniyede 200 istek altında p95 < 300 ms (H, M)
  • Performance › checkout › checkout p95 < 800 ms (H, M)
  • Availability › dış bağımlılık › ödeme sağlayıcısı 5 saniye yanıt vermezse sipariş kaybolmaz (H, H)
  • Availability › deploy › haftalık deploy sırasında checkout 1 dakikadan uzun kesilmez (M, M)
  • Modifiability › yeni ödeme yöntemi en fazla 3 developer-gün, yalnız Payment değişir (H, M)
  • Modifiability › arayüze İngilizce eklemek mevcut sayfaları değiştirmeden yapılır (M, L)

(H, H) işaretli senaryolar mimarinin en çok emek isteyeceği yerdir; ilk analiz ve prototip oraya gider. (H, L) olanlar önemli ama kolay, büyük ihtimalle mevcut yapıyla zaten karşılanıyor. (L, H) olanlar ise sorgulanmalı: zor ve önemsiz bir hedef için mimariyi karmaşıklaştırmaya değer mi? Bu tablo FoSA'nın (Fundamentals of Software Architecture, Richards ve Ford) "en önemli üç characteristic" pratiğiyle de uyumlu: üç characteristic'in her biri için bir ya da iki (H, *) senaryo, bir ekibin bir çeyrekte gerçekten takip edebileceği kadar hedef demek. ATAM'ın tamamı, yani paydaşlarla yapılan atölyeler ve sensitivity point, trade-off point gibi kavramlar, ilerideki Architecture Decision'lar serisinin konusu; burada yalnız ağacı önceliklendirme aracı olarak kullanıyoruz.

Ölçülebilir Hedefler: Percentile, Hata Oranı, Süre

Bir response measure yazarken birkaç ayrıntı fark yaratıyor. Birincisi percentile'ı nasıl hesapladığın. Açılıştaki kod nearest-rank yöntemini kullanıyor: sıralı listede ⌈p/100 × n⌉. eleman. 400 ölçümde p95, 380. ölçüm. Monitoring sistemleri histogram bucket'ları üzerinden interpolasyon yaptığı için aynı veride birkaç milisaniye farklı sonuç verebilir; önemli olan ekip içinde aynı tanımı kullanmak.

İkincisi pencere ve yük. "p95 < 300 ms" tek başına eksik; hangi zaman aralığında ve hangi yük altında? Gece üçte, saniyede iki istekle ölçülen p95 kampanya günü hakkında hiçbir şey söylemez. Senaryodaki environment parçası tam olarak bunun için var. Bizim ölçümümüz 20 eşzamanlı kullanıcıyla yapıldı; kampanya senaryosunu doğrulamak için saniyede 200 istek üretecek gerçek bir load test gerekir.

Üçüncüsü yalnız hız değil, doğruluk. Hızlı ama yüzde 5'i hata dönen bir endpoint p95 hedefini kolayca tutturur, çünkü hata genellikle hızlı döner. Bu yüzden performance senaryosuna bir hata oranı eşiği de ekledik. http.server.request.duration histogram'ı her ölçümü http.response.status_code tag'iyle birlikte yazıyor, yani hata oranı aynı veriden çıkarılabilir.

Dördüncüsü, ölçülemeyen cümleyi ölçülebilir olana çevirmek. "Kod maintainable olmalı" yerine "yeni ödeme yöntemi 3 developer-günde"; "sistem güvenli olmalı" yerine "giriş yapmış bir müşteri başka bir müşterinin sipariş numarasını isterse API (Application Programming Interface) 404 döner ve bu event 1 dakika içinde güvenlik loguna düşer". İkinci cümle hem test edilebilir hem de OWASP (Open Worldwide Application Security Project) Top 10 serisindeki IDOR (Insecure Direct Object Reference) yazısının doğrudan bir karşılığı.

Senaryoyu Teste ve Monitoring'e Bağlamak

Bir senaryo dokümanda kalırsa altı ay içinde unutulur. Onu yaşatmanın iki yolu var ve ikisine de ihtiyacın var.

Birincisi teste bağlamak. Açılıştaki programın son satırı küçük bir örnek: ölçümü senaryonun eşiğiyle karşılaştırıp PASS ya da FAIL üretiyor. Aynı karşılaştırma bir CI (Continuous Integration) pipeline'ında, her gece kampanya yüküne yakın bir load test sonrasında çalışabilir. Modifiability senaryosu için test farklı görünür: Payment module'ünün Ordering'e referans vermediğini kontrol eden bir architecture test. Bu tür testlere fitness function deniyor ve bu serinin son yazısının konusu.

İkincisi monitoring'e bağlamak. Production'da aynı http.server.request.duration metriğinden p95'i hesaplayan bir dashboard ve eşik aşıldığında tetiklenen bir alarm, senaryoyu her gün yeniden doğrular. Burada önemli bir sınır var: senaryodaki hedefi bir SLO'ya (Service Level Objective) çevirmek, ölçümü bir SLI (Service Level Indicator) olarak tanımlamak ve error budget ile işletmek ayrı bir disiplin; ilerideki SRE (Site Reliability Engineering) ve Production Incident'ları serisinde işleyeceğiz. Mimari açıdan bizim işimiz, hedefin baştan ölçülebilir yazılmasını ve ölçecek verinin sistemde üretilmesini sağlamak.

Sık Düşülen Tuzaklar

Ortalama latency'yi hedef yapmak. Açılıştaki ölçümde ortalama hedefin yarısının altındaydı, p95 iki katının üstünde. Latency hedeflerini percentile'la yaz ve p50 ile birlikte p95 ya da p99'a bak.

Environment'ı yazmamak. Yük ve koşul belirtilmemiş bir latency hedefi, en kolay koşulda ölçülüp "geçti" denir. Hedefin hangi yükte, hangi pencerede geçerli olduğunu senaryoya yaz.

Ölçülemeyen cümleleri listede tutmak. "Maintainable olsun", "kullanıcı dostu olsun", "güvenli olsun" bir senaryo değil. Her birini en az bir somut stimulus ve response measure ile yeniden yaz; yazamıyorsan characteristic'in önemini sorgula.

Yalnız mutlu yolu ölçmek. Performance senaryoları hep normal çalışma için yazılır, availability senaryoları ise hiç yazılmaz. Bir bağımlılığın düştüğü, deploy'un sürdüğü, kampanya trafiğinin geldiği environment'lar en çok öğrendiğin yerlerdir.

Hedefi kimsenin sahiplenmemesi. Senaryonun bir sahibi, bir testi ve bir alarmı yoksa sadece iyi niyetli bir dokümandır. Her (H, *) senaryonun yanına "kim takip ediyor" yaz.

Küçük Bir Deneme

★☆☆ Açılıştaki programda yavaş isteklerin oranını 0.08'den 0.03'e indir. Ortalama, p95 ve p99 nasıl değişiyor? Senaryo geçiyor mu? Sonra oranı 0.06 yap ve p95 ile p99'un neden farklı tepki verdiğini açıkla.

★★☆ MeterListener callback'inde tag'leri de oku ve http.response.status_code değerine göre ölçümleri ayır. Endpoint'e isteklerin yüzde 3'ünde 500 dönen bir dal ekleyip hata oranını hesapla ve senaryoya bir hata oranı eşiği ekle.

★★★ Kendi sisteminden bir availability ve bir modifiability senaryosunu altı parçasıyla yaz, utility tree'de (önem, zorluk) puanı ver. Response measure'ı otomatik doğrulayan bir test ya da alarm tasarla; tasarlayamıyorsan senaryoyu ölçülebilir olana kadar yeniden yaz.

Senaryoları yazınca bir şey fark edeceksin: hedefler birbiriyle çatışıyor. Availability senaryosu için siparişi PaymentPending olarak kabul etmek, "ödenmemiş sipariş olmaz" kuralını gevşetiyor. Bir sonraki yazı tam olarak bu çatışmalar hakkında: mimaride doğru cevap olmadığı, yalnız en az kötü seçenek olduğu fikri.