399 lines
14 KiB
C#
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;
|
|
}
|
|
}
|