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
|
||||
}
|
||||
|
||||
304
ClientTester/README.md
Normal file
304
ClientTester/README.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# ClientTester - MMO 서버 테스트 도구
|
||||
|
||||
MMO Game Server의 네트워크 성능 검증 및 부하 테스트를 위한 클라이언트 시뮬레이터.
|
||||
|
||||
---
|
||||
|
||||
## 1. 환경 (라이브러리)
|
||||
|
||||
| 항목 | 버전 | 용도 |
|
||||
|------|------|------|
|
||||
| .NET | 9.0 | 런타임 |
|
||||
| LiteNetLib | 2.0.2 | UDP 네트워크 통신 (서버와 동일) |
|
||||
| protobuf-net | 3.2.56 | 패킷 직렬화/역직렬화 |
|
||||
| Serilog | 4.3.1 | 구조적 로깅 |
|
||||
| Serilog.Sinks.Console | 6.0.0 | 콘솔 출력 |
|
||||
| Serilog.Sinks.File | 7.0.0 | 파일 로깅 |
|
||||
| Microsoft.Diagnostics.NETCore.Client | 0.2.553101 | 크래시 힙 덤프 (Release만) |
|
||||
|
||||
### 빌드
|
||||
|
||||
```bash
|
||||
cd ClientTester/EchoClientTester
|
||||
dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 디렉토리 구조
|
||||
|
||||
```
|
||||
ClientTester/
|
||||
├── EchoClientTester/
|
||||
│ ├── Program.cs # 진입점 (대화형 + CLI)
|
||||
│ ├── EchoClientTester.csproj
|
||||
│ ├── Vector3.cs # 위치 구조체
|
||||
│ │
|
||||
│ ├── DummyService/ # 이동 테스트
|
||||
│ │ ├── DummyClientService.cs # 다중 더미 클라 관리
|
||||
│ │ ├── DummyClients.cs # 개별 더미 클라 (이동 시뮬)
|
||||
│ │ └── MapBounds.cs # 맵 경계 / 벽 충돌
|
||||
│ │
|
||||
│ ├── EchoDummyService/ # Echo RTT 테스트
|
||||
│ │ ├── EchoDummyClientService.cs # 다중 에코 클라 관리
|
||||
│ │ └── EchoDummyClients.cs # 개별 에코 클라 (RTT 측정)
|
||||
│ │
|
||||
│ ├── StressTest/ # 부하/스트레스 테스트
|
||||
│ │ ├── StressTestService.cs # 오케스트레이터 (Ramp-up, 통계, CSV)
|
||||
│ │ └── StressTestClient.cs # 통합 클라 (이동 + Echo 동시)
|
||||
│ │
|
||||
│ ├── Packet/ # 패킷 정의 (서버와 동일 프로토콜)
|
||||
│ │ ├── PacketHeader.cs # PacketCode Enum
|
||||
│ │ ├── PacketBody.cs # Protobuf 패킷 클래스
|
||||
│ │ └── PacketSerializer.cs # 직렬화 유틸
|
||||
│ │
|
||||
│ └── Utils/
|
||||
│ └── CrashDumpHandler.cs # 크래시 로그 + 힙 덤프
|
||||
│
|
||||
└── TempServer/ # 간이 에코 서버 (독립 테스트용)
|
||||
└── Program.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 사용방법
|
||||
|
||||
### 3.1 대화형 모드
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
```
|
||||
========== 더미 클라 테스터 ==========
|
||||
1. 에코 서버
|
||||
2. 더미 클라(이동만)
|
||||
3. 스트레스 테스트 (부하)
|
||||
====================================
|
||||
1 / 2 / 3 :
|
||||
```
|
||||
|
||||
| 모드 | 설명 |
|
||||
|------|------|
|
||||
| 1. 에코 서버 | Echo 패킷 송수신, RTT(Round-Trip Time) 측정 |
|
||||
| 2. 더미 클라 | 이동(Transform) 패킷 전송, 패킷 손실률 측정 |
|
||||
| 3. 스트레스 테스트 | Ramp-up + 이동 + Echo 동시, 퍼센타일 레이턴시, CSV 내보내기 |
|
||||
|
||||
### 3.2 CLI 모드 (스트레스 테스트)
|
||||
|
||||
```bash
|
||||
# 기본 실행 (50명, 60초)
|
||||
dotnet run -- stress
|
||||
|
||||
# 100명 클라이언트, 120초 테스트
|
||||
dotnet run -- stress -c 100 -d 120
|
||||
|
||||
# 원격 서버 대상, 커스텀 설정
|
||||
dotnet run -- stress -c 200 -d 60 -r 500 -b 20 --ip 192.168.0.10 --port 9500
|
||||
|
||||
# 도움말
|
||||
dotnet run -- --help
|
||||
```
|
||||
|
||||
#### CLI 옵션
|
||||
|
||||
| 옵션 | 축약 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `--clients` | `-c` | 50 | 총 클라이언트 수 |
|
||||
| `--duration` | `-d` | 60 | 테스트 지속 시간 (초, 0=무제한) |
|
||||
| `--interval` | `-i` | 100 | 패킷 전송 주기 (ms) |
|
||||
| `--ramp` | `-r` | 1000 | Ramp-up 간격 (ms) |
|
||||
| `--batch` | `-b` | 10 | Ramp-up 당 추가 클라이언트 수 |
|
||||
| `--ip` | | localhost | 서버 IP 주소 |
|
||||
| `--port` | | 9500 | 서버 포트 |
|
||||
|
||||
### 3.3 출력 파일
|
||||
|
||||
| 경로 | 내용 |
|
||||
|------|------|
|
||||
| `logs2/log_{timestamp}.txt` | 전체 실행 로그 |
|
||||
| `results/stress_{N}clients_{timestamp}.csv` | 스트레스 테스트 결과 (클라이언트별 통계) |
|
||||
| `crashes/crash_{timestamp}.log` | 크래시 스택 트레이스 |
|
||||
| `crashes/crash_{timestamp}.dmp` | 힙 덤프 (Release 빌드만) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 파이프라인
|
||||
|
||||
### 4.1 에코 테스트 (모드 1)
|
||||
|
||||
```
|
||||
[클라이언트 N개 동시 생성]
|
||||
↓
|
||||
[연결] UDP Connect → ServerBase.OnConnectionRequest()
|
||||
↓
|
||||
[PollLoop] 10ms 주기로 네트워크 이벤트 처리
|
||||
[SendLoop] 100ms 주기로 Echo 패킷 전송
|
||||
↓
|
||||
[송신] EchoPacket { Str = "Echo seq:{N}" }
|
||||
→ PacketSerializer.Serialize()
|
||||
→ 4바이트 헤더(type + size) + Protobuf 페이로드
|
||||
→ DeliveryMethod.ReliableUnordered
|
||||
↓
|
||||
[수신] 서버가 Echo 패킷 그대로 반환
|
||||
→ seq 번호로 pendingPings 조회
|
||||
→ RTT 계산: (수신시각 - 송신시각) / Stopwatch.Frequency
|
||||
↓
|
||||
[통계] 클라이언트별: AvgRTT, LastRTT, Sent, Recv, PacketLoss%
|
||||
전체: 총 Sent/Recv, 평균 RTT
|
||||
```
|
||||
|
||||
### 4.2 이동 테스트 (모드 2)
|
||||
|
||||
```
|
||||
[클라이언트 N개 동시 생성]
|
||||
↓
|
||||
[연결 + 인증]
|
||||
DummyAccTokenPacket { Token = clientId }
|
||||
→ DeliveryMethod.ReliableOrdered
|
||||
↓
|
||||
[이동 시뮬레이션 - UpdateDummy()]
|
||||
┌────────────────────────────────────────┐
|
||||
│ 1. 델타 타임 계산 (Stopwatch) │
|
||||
│ 2. 남은 거리 = 0 이면: │
|
||||
│ - 벽 근처? → 반대 방향 회전 │
|
||||
│ - 아니면 → 현재 방향 ±30도 랜덤 회전 │
|
||||
│ - 목표 거리 = moveSpeed × (3~12초) │
|
||||
│ 3. 이번 틱 이동 (moveSpeed × delta) │
|
||||
│ 4. MapBounds.Clamp() → 벽 충돌 처리 │
|
||||
└────────────────────────────────────────┘
|
||||
↓
|
||||
[송신] TransformPlayerPacket { PlayerId, Position(X,0,Z), RotY }
|
||||
→ DeliveryMethod.Unreliable (손실 감수)
|
||||
↓
|
||||
[통계] 클라이언트별: Sent, Recv, PacketLoss%
|
||||
```
|
||||
|
||||
### 4.3 스트레스 테스트 (모드 3 / CLI)
|
||||
|
||||
```
|
||||
[설정 입력] CLI 인자 또는 대화형 입력
|
||||
↓
|
||||
[Ramp-up Loop]
|
||||
┌────────────────────────────────────┐
|
||||
│ rampInterval(1000ms)마다 │
|
||||
│ clientsPerRamp(10)명씩 클라이언트 생성 │
|
||||
│ → totalClients 도달 시 중단 │
|
||||
└────────────────────────────────────┘
|
||||
↓ (병렬 실행)
|
||||
[PollLoop] 10ms 주기 네트워크 이벤트
|
||||
[SendLoop] sendInterval(100ms) 주기
|
||||
├─ UpdateAndSendTransform() (이동 - Unreliable)
|
||||
└─ SendPing() (Echo - ReliableUnordered)
|
||||
[StatsLoop] 10초마다 실시간 통계 출력
|
||||
↓
|
||||
[지속시간 만료 또는 Ctrl+C]
|
||||
↓
|
||||
[최종 리포트]
|
||||
├─ 처리량: 총 Sent/Recv, 패킷/초
|
||||
├─ 레이턴시: Min, Avg, P50, P95, P99, Max
|
||||
├─ 최악 RTT 상위 5 클라이언트 상세
|
||||
└─ CSV 파일 자동 저장
|
||||
```
|
||||
|
||||
### 4.4 패킷 직렬화 파이프라인
|
||||
|
||||
```
|
||||
객체 (e.g. TransformPlayerPacket)
|
||||
↓
|
||||
protobuf-net Serialize → byte[] payload
|
||||
↓
|
||||
PacketSerializer.Serialize()
|
||||
↓
|
||||
┌──────────────────────────────┐
|
||||
│ [2B type] [2B size] [payload] │
|
||||
│ ushort ushort byte[] │
|
||||
└──────────────────────────────┘
|
||||
↓
|
||||
NetDataWriter.Put(data)
|
||||
↓
|
||||
peer.Send(writer, DeliveryMethod)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 테스트 모드별 기능 비교
|
||||
|
||||
| 기능 | 에코 (1) | 이동 (2) | 스트레스 (3) |
|
||||
|------|:--------:|:--------:|:------------:|
|
||||
| 동시접속 클라이언트 | 10 (고정) | 10 (고정) | N (설정 가능) |
|
||||
| 인증 패킷 전송 | X | O | O |
|
||||
| 이동 시뮬레이션 | X | O | O |
|
||||
| Echo RTT 측정 | O | X | O |
|
||||
| 패킷 손실 추적 | O | O | O |
|
||||
| 점진적 Ramp-up | X | X | O |
|
||||
| 지속시간 제한 | 무제한 | 무제한 | 설정 가능 |
|
||||
| 실시간 통계 | X | X | O (10초 주기) |
|
||||
| 퍼센타일 레이턴시 | X (평균만) | X | O (P50/P95/P99) |
|
||||
| CSV 내보내기 | X | X | O |
|
||||
| CLI 인자 | X | X | O |
|
||||
| 최악 클라이언트 분석 | X | X | O (상위 5) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 구현 진행도
|
||||
|
||||
### 완료
|
||||
|
||||
| 항목 | 상태 | 설명 |
|
||||
|------|:----:|------|
|
||||
| Echo RTT 측정 | **완료** | seq 기반 ping/pong, Stopwatch 고정밀 타이머 |
|
||||
| 더미 이동 시뮬레이션 | **완료** | 벽 충돌, 랜덤 방향 전환, 델타 타임 기반 이동 |
|
||||
| 맵 경계 처리 | **완료** | MapBounds (Clamp, 벽 반대 방향 계산) |
|
||||
| 패킷 손실 추적 | **완료** | LiteNetLib.NetStatistics 활용 |
|
||||
| 크래시 덤프 | **완료** | Debug: .log / Release: .log + .dmp |
|
||||
| 로깅 (콘솔+파일) | **완료** | Serilog 타임스탬프별 로그 파일 |
|
||||
| Graceful Shutdown | **완료** | Ctrl+C → CancellationToken → 통계 출력 후 종료 |
|
||||
| 스트레스 테스트 | **완료** | Ramp-up, 퍼센타일, CSV, CLI 인자 |
|
||||
| CLI 인자 지원 | **완료** | `stress -c N -d T --ip --port` |
|
||||
|
||||
### 미구현 / 향후 작업
|
||||
|
||||
| 항목 | 상태 | 설명 |
|
||||
|------|:----:|------|
|
||||
| 채널 입장 시뮬레이션 | 미구현 | INTO_CHANNEL 패킷 전송 후 게임 루프 진입 |
|
||||
| 전투 액션 시뮬레이션 | 미구현 | ACTION_PLAYER (공격/스킬) 패킷 전송 |
|
||||
| 상태 변경 시뮬레이션 | 미구현 | STATE_PLAYER (HP/MP) 패킷 전송 |
|
||||
| JWT 토큰 인증 테스트 | 미구현 | ACC_TOKEN 방식 인증 플로우 검증 |
|
||||
| 서버 응답 검증 | 미구현 | 수신 패킷 내용 파싱 및 정합성 체크 |
|
||||
| 테스트 시나리오 스크립트 | 미구현 | JSON/YAML 기반 테스트 시나리오 정의 |
|
||||
| 그래프 출력 | 미구현 | RTT/처리량 시계열 차트 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 설정 참고
|
||||
|
||||
### 서버 연결 기본값
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| IP | `localhost` |
|
||||
| Port | `9500` |
|
||||
| Connection Key | `test` |
|
||||
|
||||
### 이동 시뮬레이션 파라미터
|
||||
|
||||
| 항목 | 값 | 설명 |
|
||||
|------|-----|------|
|
||||
| moveSpeed | 3.5 | 이동 속도 (units/sec) |
|
||||
| 맵 범위 | -50 ~ 50 (X, Z) | 100x100 영역 |
|
||||
| 벽 감지 마진 | 0.5 | 벽 근처 판정 거리 |
|
||||
| 목표 거리 | moveSpeed x 3~12초 | 직진 후 방향 전환 |
|
||||
| 방향 전환 | 현재 ±30도 랜덤 | 벽 근처 시 반대 방향 강제 |
|
||||
|
||||
### 스트레스 테스트 기본값
|
||||
|
||||
| 항목 | 값 | 설명 |
|
||||
|------|-----|------|
|
||||
| 클라이언트 수 | 50 | `-c` 옵션 |
|
||||
| 지속 시간 | 60초 | `-d` 옵션 |
|
||||
| 전송 주기 | 100ms | `-i` 옵션 |
|
||||
| Ramp-up 간격 | 1000ms | `-r` 옵션 |
|
||||
| Ramp-up 배치 | 10명 | `-b` 옵션 |
|
||||
| 실시간 통계 주기 | 10초 | 고정 |
|
||||
| Echo 최대 pending | 500 | 메모리 누수 방지 |
|
||||
Reference in New Issue
Block a user