feat : 스트레스 테스트 기능 추가 / 패킷 처리량 제한 / 프로젝트 상황 리드미 추가

This commit is contained in:
qornwh1
2026-03-05 10:58:49 +09:00
parent 2be1302b24
commit ea3f64a40d
11 changed files with 1668 additions and 31 deletions

View File

@@ -9,8 +9,6 @@
</PropertyGroup>
<ItemGroup>
<!-- Release 빌드에서 메모리 덤프 생성용 -->
<PackageReference Include="Microsoft.Diagnostics.NETCore.Client" Version="0.2.553101" Condition="'$(Configuration)' == 'Release'" />
<PackageReference Include="LiteNetLib" Version="2.0.2" />
<PackageReference Include="protobuf-net" Version="3.2.56" />
<PackageReference Include="Serilog" Version="4.3.1" />

View File

@@ -1,14 +1,15 @@
using ClientTester.DummyService;
using ClientTester.EchoDummyService;
using ClientTester.StressTest;
using ClientTester.Utils;
using Serilog;
class EcoClientTester
{
public static readonly string SERVER_IP = "localhost";
public static readonly int SERVER_PORT = 9500;
public static string SERVER_IP = "localhost";
public static int SERVER_PORT = 9500;
public static readonly string CONNECTION_KEY = "test";
public static readonly int CLIENT_COUNT = 10;
public static int CLIENT_COUNT = 100;
private async Task StartEchoDummyTest()
{
@@ -78,8 +79,78 @@ class EcoClientTester
}
}
private async Task StartStressTest(int duration, int sendInterval, int rampInterval, int clientsPerRamp)
{
try
{
CancellationTokenSource cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
Log.Warning("[SHUTDOWN] Ctrl+C 감지, 종료 중...");
cts.Cancel();
};
StressTestService service = new StressTestService(
CLIENT_COUNT, SERVER_IP, SERVER_PORT, CONNECTION_KEY,
durationSec: duration,
sendIntervalMs: sendInterval,
rampUpIntervalMs: rampInterval,
clientsPerRamp: clientsPerRamp);
service.OnTestCompleted += () =>
{
Log.Information("[STRESS] 테스트 시간 종료.");
};
try
{
await service.RunAsync(cts.Token);
}
catch (OperationCanceledException) { }
service.PrintFinalReport();
string timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
string csvPath = $"results/stress_{CLIENT_COUNT}clients_{timestamp}.csv";
Directory.CreateDirectory("results");
service.ExportCsv(csvPath);
service.Stop();
await Log.CloseAndFlushAsync();
}
catch (Exception e)
{
Log.Error($"[SHUTDOWN] 예외 발생 : {e}");
}
}
private static void PrintUsage()
{
Console.WriteLine();
Console.WriteLine("사용법:");
Console.WriteLine(" EchoClientTester 대화형 모드");
Console.WriteLine(" EchoClientTester stress [옵션] 스트레스 테스트");
Console.WriteLine();
Console.WriteLine("스트레스 테스트 옵션:");
Console.WriteLine(" -c, --clients <수> 클라이언트 수 (기본: 50)");
Console.WriteLine(" -d, --duration <초> 테스트 시간 (기본: 60, 0=무제한)");
Console.WriteLine(" -i, --interval <ms> 전송 주기 (기본: 100)");
Console.WriteLine(" -r, --ramp <ms> Ramp-up 간격 (기본: 1000)");
Console.WriteLine(" -b, --batch <수> Ramp-up 당 클라이언트 수 (기본: 10)");
Console.WriteLine(" --ip <주소> 서버 IP (기본: localhost)");
Console.WriteLine(" --port <포트> 서버 포트 (기본: 9500)");
Console.WriteLine();
Console.WriteLine("예시:");
Console.WriteLine(" EchoClientTester stress -c 100 -d 120");
Console.WriteLine(" EchoClientTester stress -c 200 -d 60 -r 500 -b 20 --ip 192.168.0.10");
}
private static async Task Main(string[] args)
{
// 유니코드 문자(═, ║ 등) 콘솔 깨짐 방지
Console.OutputEncoding = System.Text.Encoding.UTF8;
// 크래시 덤프 핸들러 (Release: .log + .dmp / Debug: .log)
CrashDumpHandler.Register();
@@ -92,29 +163,95 @@ class EcoClientTester
.WriteTo.File($"logs2/log_{timestamp}.txt")
.CreateLogger();
Log.Information("========== 더미 클라 테스터 ==========");
Log.Information("1. 에코 서버");
Log.Information("2. 더미 클라(이동만)");
Log.Information("====================================");
Log.Information("1 / 2 : ");
EcoClientTester tester = new EcoClientTester();
string? input = Console.ReadLine();
if (!int.TryParse(input, out int choice) || (choice != 1 && choice != 2))
// CLI 모드: stress 명령
if (args.Length > 0 && args[0].Equals("stress", StringComparison.OrdinalIgnoreCase))
{
Log.Warning("1 또는 2만 입력하세요.");
// 기본값
CLIENT_COUNT = 50;
int duration = 60;
int sendInterval = 100;
int rampInterval = 1000;
int clientsPerRamp = 10;
for (int i = 1; i < args.Length; i++)
{
switch (args[i])
{
case "-c": case "--clients":
if (i + 1 < args.Length) CLIENT_COUNT = int.Parse(args[++i]);
break;
case "-d": case "--duration":
if (i + 1 < args.Length) duration = int.Parse(args[++i]);
break;
case "-i": case "--interval":
if (i + 1 < args.Length) sendInterval = int.Parse(args[++i]);
break;
case "-r": case "--ramp":
if (i + 1 < args.Length) rampInterval = int.Parse(args[++i]);
break;
case "-b": case "--batch":
if (i + 1 < args.Length) clientsPerRamp = int.Parse(args[++i]);
break;
case "--ip":
if (i + 1 < args.Length) SERVER_IP = args[++i];
break;
case "--port":
if (i + 1 < args.Length) SERVER_PORT = int.Parse(args[++i]);
break;
case "-h": case "--help":
PrintUsage();
return;
}
}
await tester.StartStressTest(duration, sendInterval, rampInterval, clientsPerRamp);
return;
}
// CLI 모드: help
if (args.Length > 0 && (args[0] == "-h" || args[0] == "--help"))
{
PrintUsage();
return;
}
// 대화형 모드 (기존)
Log.Information("========== 더미 클라 테스터 ==========");
Log.Information("1. 에코 서버");
Log.Information("2. 더미 클라(이동만)");
Log.Information("3. 스트레스 테스트 (부하)");
Log.Information("====================================");
Log.Information("1 / 2 / 3 : ");
string? input = Console.ReadLine();
if (!int.TryParse(input, out int choice) || choice < 1 || choice > 3)
{
Log.Warning("1, 2, 3 중 입력하세요.");
return;
}
EcoClientTester tester = new EcoClientTester();
if (choice == 1)
{
// 에코 서버 실행
await tester.StartEchoDummyTest();
}
else if (choice == 2)
{
// 더미 클라 실행
await tester.StartDummyTest();
}
else if (choice == 3)
{
// 대화형 스트레스 테스트 설정
Log.Information("클라이언트 수 (기본 50): ");
string? countInput = Console.ReadLine();
CLIENT_COUNT = string.IsNullOrEmpty(countInput) ? 50 : int.Parse(countInput);
Log.Information("테스트 시간(초) (기본 60, 0=무제한): ");
string? durInput = Console.ReadLine();
int dur = string.IsNullOrEmpty(durInput) ? 60 : int.Parse(durInput);
await tester.StartStressTest(dur, 100, 1000, 10);
}
}
}

View File

@@ -0,0 +1,203 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using ClientTester.DummyService;
using ClientTester.Packet;
using LiteNetLib;
using LiteNetLib.Utils;
using Serilog;
namespace ClientTester.StressTest;
/// <summary>
/// 스트레스 테스트용 클라이언트.
/// Echo RTT 측정 + 이동 패킷 전송을 동시에 수행.
/// </summary>
public class StressTestClient
{
private NetManager manager;
private EventBasedNetListener listener;
private NetDataWriter? writer;
public NetPeer? peer;
public long clientId;
// 이동
private Vector3 position = new Vector3();
private int rotY;
private float moveSpeed = 3.5f;
private float distance;
private float preTime;
private readonly Stopwatch moveClock = Stopwatch.StartNew();
private float posX, posZ, dirX, dirZ;
public MapBounds Map { get; set; } = new MapBounds(-50f, 50f, -50f, 50f);
// RTT 측정
private readonly ConcurrentDictionary<int, long> pendingPings = new();
private int seqNumber;
private const int MAX_PENDING = 500;
/// <summary>개별 RTT 기록 (퍼센타일 계산용)</summary>
public ConcurrentBag<double> RttSamples { get; } = new();
// 통계
public int SentCount { get; set; }
public int ReceivedCount { get; set; }
public int RttCount { get; set; }
public double TotalRttMs { get; set; }
public double LastRttMs { get; set; }
public double AvgRttMs => RttCount > 0 ? TotalRttMs / RttCount : 0;
public bool IsConnected => peer != null;
public StressTestClient(long clientId, string ip, int port, string key)
{
this.clientId = clientId;
listener = new EventBasedNetListener();
manager = new NetManager(listener);
writer = new NetDataWriter();
listener.PeerConnectedEvent += netPeer =>
{
peer = netPeer;
Log.Debug("[Stress {Id:000}] 연결됨", clientId);
DummyAccTokenPacket token = new DummyAccTokenPacket { Token = clientId };
byte[] data = PacketSerializer.Serialize((ushort)PacketCode.DUMMY_ACC_TOKEN, token);
writer!.Put(data);
peer.Send(writer, DeliveryMethod.ReliableOrdered);
writer.Reset();
};
listener.NetworkReceiveEvent += (p, reader, ch, dm) =>
{
ReceivedCount++;
try
{
byte[] raw = reader.GetRemainingBytes();
if (raw.Length >= 4)
{
ushort type = BitConverter.ToUInt16(raw, 0);
ushort size = BitConverter.ToUInt16(raw, 2);
if (type == (ushort)PacketCode.ECHO && size > 0 && raw.Length >= 4 + size)
{
byte[] payload = new byte[size];
Array.Copy(raw, 4, payload, 0, size);
EchoPacket echo = PacketSerializer.DeserializePayload<EchoPacket>(payload);
if (echo.Str.StartsWith("Echo seq:") &&
int.TryParse(echo.Str.AsSpan("Echo seq:".Length), out int seq) &&
pendingPings.TryRemove(seq, out long sentTick))
{
double rttMs = (Stopwatch.GetTimestamp() - sentTick) * 1000.0 / Stopwatch.Frequency;
LastRttMs = rttMs;
TotalRttMs += rttMs;
RttCount++;
RttSamples.Add(rttMs);
}
}
}
}
catch
{
// 파싱 실패는 무시 (다른 패킷 타입)
}
};
listener.PeerDisconnectedEvent += (p, info) =>
{
Log.Debug("[Stress {Id:000}] 끊김: {Reason}", clientId, info.Reason);
peer = null;
};
manager.Start();
manager.Connect(ip, port, key);
}
public void UpdateAndSendTransform()
{
if (peer == null || writer == null)
{
return;
}
// 이동 업데이트
float now = (float)moveClock.Elapsed.TotalSeconds;
float delta = preTime > 0f ? now - preTime : 0.1f;
preTime = now;
if (distance <= 0f)
{
int wallRotY = Map.GetRotYAwayFromWall(posX, posZ);
rotY = wallRotY >= 0
? (wallRotY + Random.Shared.Next(-30, 31) + 360) % 360
: (rotY + Random.Shared.Next(-30, 31) + 360) % 360;
float rad = rotY * MathF.PI / 180f;
dirX = MathF.Sin(rad);
dirZ = MathF.Cos(rad);
distance = moveSpeed * (3f + (float)Random.Shared.NextDouble() * 9f);
}
float step = MathF.Min(moveSpeed * delta, distance);
float nextX = posX + dirX * step;
float nextZ = posZ + dirZ * step;
bool hitWall = Map.Clamp(ref nextX, ref nextZ);
posX = nextX;
posZ = nextZ;
distance = hitWall ? 0f : distance - step;
position.X = (int)MathF.Round(posX);
position.Z = (int)MathF.Round(posZ);
// 전송
TransformPlayerPacket pkt = new TransformPlayerPacket
{
PlayerId = (int)clientId,
RotY = rotY,
Position = new Packet.Vector3 { X = position.X, Y = 0, Z = position.Z }
};
byte[] data = PacketSerializer.Serialize((ushort)PacketCode.TRANSFORM_PLAYER, pkt);
writer.Put(data);
peer.Send(writer, DeliveryMethod.Unreliable);
SentCount++;
writer.Reset();
}
public void SendPing()
{
if (peer == null || writer == null)
{
return;
}
int seq = seqNumber++;
pendingPings[seq] = Stopwatch.GetTimestamp();
if (pendingPings.Count > MAX_PENDING)
{
int cutoff = seq - MAX_PENDING;
foreach (int k in pendingPings.Keys)
{
if (k < cutoff)
{
pendingPings.TryRemove(k, out _);
}
}
}
EchoPacket echo = new EchoPacket { Str = $"Echo seq:{seq}" };
byte[] data = PacketSerializer.Serialize((ushort)PacketCode.ECHO, echo);
writer.Put(data);
peer.Send(writer, DeliveryMethod.ReliableUnordered);
SentCount++;
writer.Reset();
}
public void PollEvents()
{
manager.PollEvents();
}
public void Stop()
{
manager.Stop();
}
}

View File

@@ -0,0 +1,398 @@
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;
}
}

View File

@@ -1,11 +1,8 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using Serilog;
#if !DEBUG
using Microsoft.Diagnostics.NETCore.Client;
#endif
namespace ClientTester.Utils;
/// <summary>
@@ -162,10 +159,38 @@ public static class CrashDumpHandler
}
#if !DEBUG
// Windows MiniDumpWriteDump P/Invoke
[DllImport("dbghelp.dll", SetLastError = true)]
private static extern bool MiniDumpWriteDump(
IntPtr hProcess, uint processId, IntPtr hFile,
uint dumpType, IntPtr exceptionParam,
IntPtr userStreamParam, IntPtr callbackParam);
private const uint MiniDumpWithFullMemory = 0x00000002;
private static void WriteDumpFile(string path)
{
DiagnosticsClient client = new DiagnosticsClient(Environment.ProcessId);
client.WriteDump(DumpType.WithHeap, path, logDumpGeneration: false);
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Log.Warning("[CrashDump] 덤프 생성은 Windows만 지원");
return;
}
using Process process = Process.GetCurrentProcess();
using FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
bool success = MiniDumpWriteDump(
process.Handle,
(uint)process.Id,
fs.SafeFileHandle.DangerousGetHandle(),
MiniDumpWithFullMemory,
IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
if (!success)
{
int err = Marshal.GetLastWin32Error();
Log.Error("[CrashDump] MiniDumpWriteDump 실패 (Win32 Error={Err})", err);
}
}
#endif
}