feat : 스트레스 테스트 기능 추가 / 패킷 처리량 제한 / 프로젝트 상황 리드미 추가
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
203
ClientTester/EchoClientTester/StressTest/StressTestClient.cs
Normal file
203
ClientTester/EchoClientTester/StressTest/StressTestClient.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
398
ClientTester/EchoClientTester/StressTest/StressTestService.cs
Normal file
398
ClientTester/EchoClientTester/StressTest/StressTestService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user