using System.Diagnostics; using System.Globalization; using System.Text; using LiteNetLib; using Serilog; namespace ClientTester.StressTest; /// /// 스트레스/부하 테스트 서비스. /// /// 기능: /// - 점진적 Ramp-up (N초 간격으로 클라이언트 추가) /// - 테스트 지속시간 제한 /// - 실시간 처리량 (packets/sec) 측정 /// - 퍼센타일 레이턴시 (P50, P95, P99) 계산 /// - CSV 결과 내보내기 /// public class StressTestService { private readonly string ip; private readonly int port; private readonly string key; private readonly int totalClients; private readonly int rampUpIntervalMs; private readonly int clientsPerRamp; private readonly int sendIntervalMs; private readonly int durationSec; private readonly List clients = new(); private readonly object clientsLock = new(); private readonly Stopwatch testClock = new(); // 실시간 처리량 추적 private int prevTotalSent; private int prevTotalRecv; private long prevStatsTick; public event Action? OnTestCompleted; /// /// 스트레스 테스트 서비스 생성. /// /// 총 클라이언트 수 /// 서버 IP /// 서버 포트 /// 연결 키 /// 테스트 지속 시간 (초). 0 = 무제한 /// 패킷 전송 주기 (ms) /// Ramp-up 간격 (ms). 0 = 모두 즉시 접속 /// Ramp-up 당 추가할 클라이언트 수 public StressTestService( int totalClients, string ip, int port, string key, int durationSec = 60, int sendIntervalMs = 100, int rampUpIntervalMs = 1000, int clientsPerRamp = 10) { this.totalClients = totalClients; this.ip = ip; this.port = port; this.key = key; this.durationSec = durationSec; this.sendIntervalMs = sendIntervalMs; this.rampUpIntervalMs = rampUpIntervalMs; this.clientsPerRamp = clientsPerRamp; } public async Task RunAsync(CancellationToken ct) { Log.Information("╔═══════════════════════════════════════════════╗"); Log.Information("║ STRESS TEST 시작 ║"); Log.Information("╠═══════════════════════════════════════════════╣"); Log.Information("║ 서버 : {Ip}:{Port}", ip, port); Log.Information("║ 클라이언트 : {Count}명", totalClients); Log.Information("║ 지속시간 : {Dur}초", durationSec == 0 ? "무제한" : durationSec); Log.Information("║ 전송주기 : {Int}ms", sendIntervalMs); Log.Information("║ Ramp-up : {Per}명 / {Int}ms", clientsPerRamp, rampUpIntervalMs); Log.Information("╚═══════════════════════════════════════════════╝"); testClock.Start(); prevStatsTick = Stopwatch.GetTimestamp(); using CancellationTokenSource durationCts = durationSec > 0 ? new CancellationTokenSource(TimeSpan.FromSeconds(durationSec)) : new CancellationTokenSource(); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, durationCts.Token); await Task.WhenAll( RampUpLoopAsync(linked.Token), PollLoopAsync(linked.Token), SendLoopAsync(linked.Token), StatsLoopAsync(linked.Token) ); testClock.Stop(); OnTestCompleted?.Invoke(); } /// 점진적으로 클라이언트 추가 private async Task RampUpLoopAsync(CancellationToken ct) { int created = 0; while (created < totalClients && !ct.IsCancellationRequested) { int batch = Math.Min(clientsPerRamp, totalClients - created); for (int i = 0; i < batch; i++) { long id = created + 1; // 1-based (더미 범위) StressTestClient client = new StressTestClient(id, ip, port, key); lock (clientsLock) { clients.Add(client); } created++; } Log.Information("[RAMP-UP] {Created}/{Total} 클라이언트 생성됨 ({Elapsed:F1}s)", created, totalClients, testClock.Elapsed.TotalSeconds); if (created < totalClients && rampUpIntervalMs > 0) { await Task.Delay(rampUpIntervalMs, ct).ConfigureAwait(false); } } Log.Information("[RAMP-UP] 완료 - 총 {Count}명 접속", created); } private async Task PollLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { List snapshot; lock (clientsLock) { snapshot = new List(clients); } foreach (StressTestClient c in snapshot) { try { c.PollEvents(); } catch { /* 무시 */ } } await Task.Delay(10, ct).ConfigureAwait(false); } } private async Task SendLoopAsync(CancellationToken ct) { await Task.Delay(500, ct).ConfigureAwait(false); while (!ct.IsCancellationRequested) { List snapshot; lock (clientsLock) { snapshot = new List(clients); } foreach (StressTestClient c in snapshot) { c.UpdateAndSendTransform(); c.SendPing(); } await Task.Delay(sendIntervalMs, ct).ConfigureAwait(false); } } /// 10초마다 실시간 통계 출력 private async Task StatsLoopAsync(CancellationToken ct) { await Task.Delay(5000, ct).ConfigureAwait(false); while (!ct.IsCancellationRequested) { PrintLiveStats(); await Task.Delay(10000, ct).ConfigureAwait(false); } } private void PrintLiveStats() { List snapshot; lock (clientsLock) { snapshot = new List(clients); } int totalSent = snapshot.Sum(c => c.SentCount); int totalRecv = snapshot.Sum(c => c.ReceivedCount); int connected = snapshot.Count(c => c.IsConnected); long now = Stopwatch.GetTimestamp(); double elapsedSec = (now - prevStatsTick) * 1.0 / Stopwatch.Frequency; double sendRate = elapsedSec > 0 ? (totalSent - prevTotalSent) / elapsedSec : 0; double recvRate = elapsedSec > 0 ? (totalRecv - prevTotalRecv) / elapsedSec : 0; prevTotalSent = totalSent; prevTotalRecv = totalRecv; prevStatsTick = now; double avgRtt = 0; int rttClients = 0; foreach (StressTestClient c in snapshot) { if (c.RttCount > 0) { avgRtt += c.AvgRttMs; rttClients++; } } if (rttClients > 0) { avgRtt /= rttClients; } Log.Information( "[LIVE {Elapsed:F0}s] 접속={Conn}/{Total} | 전송={SendRate:F0}/s 수신={RecvRate:F0}/s | AvgRTT={Rtt:F2}ms", testClock.Elapsed.TotalSeconds, connected, clients.Count, sendRate, recvRate, avgRtt); } /// 최종 결과 리포트 출력 public void PrintFinalReport() { List snapshot; lock (clientsLock) { snapshot = new List(clients); } int totalSent = 0, totalRecv = 0, connected = 0; List allRtt = new(); foreach (StressTestClient c in snapshot) { totalSent += c.SentCount; totalRecv += c.ReceivedCount; if (c.IsConnected) { connected++; } foreach (double r in c.RttSamples) { allRtt.Add(r); } } allRtt.Sort(); double p50 = Percentile(allRtt, 0.50); double p95 = Percentile(allRtt, 0.95); double p99 = Percentile(allRtt, 0.99); double avg = allRtt.Count > 0 ? allRtt.Average() : 0; double min = allRtt.Count > 0 ? allRtt[0] : 0; double max = allRtt.Count > 0 ? allRtt[^1] : 0; double durSec = testClock.Elapsed.TotalSeconds; double throughput = durSec > 0 ? totalSent / durSec : 0; Log.Information(""); Log.Information("╔═══════════════════════════════════════════════╗"); Log.Information("║ STRESS TEST 최종 리포트 ║"); Log.Information("╠═══════════════════════════════════════════════╣"); Log.Information("║ 테스트 시간 : {Dur:F1}초", durSec); Log.Information("║ 클라이언트 : {Conn}/{Total} 접속 유지", connected, snapshot.Count); Log.Information("╠═══════════════════════════════════════════════╣"); Log.Information("║ [처리량]"); Log.Information("║ 총 전송 : {Sent:N0} 패킷", totalSent); Log.Information("║ 총 수신 : {Recv:N0} 패킷", totalRecv); Log.Information("║ 처리량 : {Thr:F1} 패킷/초", throughput); Log.Information("╠═══════════════════════════════════════════════╣"); Log.Information("║ [레이턴시] (RTT 샘플: {Count:N0}개)", allRtt.Count); Log.Information("║ Min : {Min:F2} ms", min); Log.Information("║ Avg : {Avg:F2} ms", avg); Log.Information("║ P50 : {P50:F2} ms", p50); Log.Information("║ P95 : {P95:F2} ms", p95); Log.Information("║ P99 : {P99:F2} ms", p99); Log.Information("║ Max : {Max:F2} ms", max); Log.Information("╠═══════════════════════════════════════════════╣"); // 클라이언트별 요약 (상위 5명 최악 RTT) List worstClients = snapshot .Where(c => c.RttCount > 0) .OrderByDescending(c => c.AvgRttMs) .Take(5) .ToList(); if (worstClients.Count > 0) { Log.Information("║ [최악 RTT 상위 5 클라이언트]"); foreach (StressTestClient c in worstClients) { NetStatistics? stats = c.peer?.Statistics; float lossPct = stats?.PacketLossPercent ?? 0f; Log.Information("║ Client {Id:000}: AvgRTT={Rtt:F2}ms Loss={Loss:F1}% Sent={S} Recv={R}", c.clientId, c.AvgRttMs, lossPct, c.SentCount, c.ReceivedCount); } } Log.Information("╚═══════════════════════════════════════════════╝"); } /// 결과를 CSV 파일로 내보내기 public void ExportCsv(string path) { List snapshot; lock (clientsLock) { snapshot = new List(clients); } StringBuilder sb = new(); sb.AppendLine("ClientId,Sent,Received,AvgRttMs,RttCount,PacketLoss,PacketLossPct,Connected"); foreach (StressTestClient c in snapshot) { NetStatistics? stats = c.peer?.Statistics; long loss = stats?.PacketLoss ?? 0; float lossPct = stats?.PacketLossPercent ?? 0f; sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0},{1},{2},{3:F3},{4},{5},{6:F1},{7}", c.clientId, c.SentCount, c.ReceivedCount, c.AvgRttMs, c.RttCount, loss, lossPct, c.IsConnected)); } // 요약 행 List allRtt = new(); foreach (StressTestClient c in snapshot) foreach (double r in c.RttSamples) { allRtt.Add(r); } allRtt.Sort(); sb.AppendLine(); sb.AppendLine("# Summary"); sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "# Duration={0:F1}s,Clients={1},TotalSent={2},TotalRecv={3}", testClock.Elapsed.TotalSeconds, snapshot.Count, snapshot.Sum(c => c.SentCount), snapshot.Sum(c => c.ReceivedCount))); sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "# P50={0:F2}ms,P95={1:F2}ms,P99={2:F2}ms", Percentile(allRtt, 0.50), Percentile(allRtt, 0.95), Percentile(allRtt, 0.99))); File.WriteAllText(path, sb.ToString()); Log.Information("[CSV] 결과 저장: {Path}", path); } public void Stop() { List snapshot; lock (clientsLock) { snapshot = new List(clients); } foreach (StressTestClient c in snapshot) { c.Stop(); } Log.Information("[STRESS] 모든 클라이언트 종료됨."); } private static double Percentile(List sorted, double p) { if (sorted.Count == 0) { return 0; } double index = p * (sorted.Count - 1); int lower = (int)Math.Floor(index); int upper = (int)Math.Ceiling(index); if (lower == upper) { return sorted[lower]; } double frac = index - lower; return sorted[lower] * (1 - frac) + sorted[upper] * frac; } }