Files
a301_mmo_game_server/ClientTester/EchoClientTester/StressTest/StressTestService.cs

399 lines
14 KiB
C#

using System.Diagnostics;
using System.Globalization;
using System.Text;
using LiteNetLib;
using Serilog;
namespace ClientTester.StressTest;
/// <summary>
/// 스트레스/부하 테스트 서비스.
///
/// 기능:
/// - 점진적 Ramp-up (N초 간격으로 클라이언트 추가)
/// - 테스트 지속시간 제한
/// - 실시간 처리량 (packets/sec) 측정
/// - 퍼센타일 레이턴시 (P50, P95, P99) 계산
/// - CSV 결과 내보내기
/// </summary>
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<StressTestClient> 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;
/// <summary>
/// 스트레스 테스트 서비스 생성.
/// </summary>
/// <param name="totalClients">총 클라이언트 수</param>
/// <param name="ip">서버 IP</param>
/// <param name="port">서버 포트</param>
/// <param name="key">연결 키</param>
/// <param name="durationSec">테스트 지속 시간 (초). 0 = 무제한</param>
/// <param name="sendIntervalMs">패킷 전송 주기 (ms)</param>
/// <param name="rampUpIntervalMs">Ramp-up 간격 (ms). 0 = 모두 즉시 접속</param>
/// <param name="clientsPerRamp">Ramp-up 당 추가할 클라이언트 수</param>
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();
}
/// <summary>점진적으로 클라이언트 추가</summary>
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<StressTestClient> snapshot;
lock (clientsLock)
{
snapshot = new List<StressTestClient>(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<StressTestClient> snapshot;
lock (clientsLock)
{
snapshot = new List<StressTestClient>(clients);
}
foreach (StressTestClient c in snapshot)
{
c.UpdateAndSendTransform();
c.SendPing();
}
await Task.Delay(sendIntervalMs, ct).ConfigureAwait(false);
}
}
/// <summary>10초마다 실시간 통계 출력</summary>
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<StressTestClient> snapshot;
lock (clientsLock)
{
snapshot = new List<StressTestClient>(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);
}
/// <summary>최종 결과 리포트 출력</summary>
public void PrintFinalReport()
{
List<StressTestClient> snapshot;
lock (clientsLock)
{
snapshot = new List<StressTestClient>(clients);
}
int totalSent = 0, totalRecv = 0, connected = 0;
List<double> 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<StressTestClient> 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("╚═══════════════════════════════════════════════╝");
}
/// <summary>결과를 CSV 파일로 내보내기</summary>
public void ExportCsv(string path)
{
List<StressTestClient> snapshot;
lock (clientsLock)
{
snapshot = new List<StressTestClient>(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<double> 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<StressTestClient> snapshot;
lock (clientsLock)
{
snapshot = new List<StressTestClient>(clients);
}
foreach (StressTestClient c in snapshot)
{
c.Stop();
}
Log.Information("[STRESS] 모든 클라이언트 종료됨.");
}
private static double Percentile(List<double> 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;
}
}