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 | 메모리 누수 방지 |
|
||||
@@ -8,8 +8,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Release 빌드에서 메모리 덤프 생성용 -->
|
||||
<PackageReference Include="Microsoft.Diagnostics.NETCore.Client" Version="0.2.553101" Condition="'$(Configuration)' == 'Release'" />
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="Dapper.Contrib" Version="2.0.78" />
|
||||
<PackageReference Include="LiteNetLib" Version="2.0.2" />
|
||||
|
||||
@@ -184,6 +184,22 @@ public abstract class ServerBase : INetEventListener
|
||||
return;
|
||||
}
|
||||
|
||||
// 패킷 레이트 리미팅 체크
|
||||
if (session.CheckRateLimit())
|
||||
{
|
||||
// 3회 연속 초과 시 강제 연결 해제
|
||||
if (session.RateLimitViolations >= 3)
|
||||
{
|
||||
Log.Warning("[Server] 레이트 리밋 초과 강제 해제 HashKey={Key} PeerId={Id}", session.HashKey, peer.Id);
|
||||
peer.Disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Warning("[Server] 레이트 리밋 초과 ({Count}회) HashKey={Key} PeerId={Id}", session.RateLimitViolations, session.HashKey,
|
||||
peer.Id);
|
||||
return; // 패킷 드롭
|
||||
}
|
||||
|
||||
HandlePacket(peer, session.HashKey, type, payload);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -22,10 +22,55 @@ public class Session
|
||||
set;
|
||||
}
|
||||
|
||||
public Session(long hashKey, NetPeer peer)
|
||||
// ─── 패킷 레이트 리미팅 ───────────────────────────
|
||||
private int packetCount;
|
||||
private long windowStartTicks;
|
||||
|
||||
/// <summary>초당 허용 패킷 수</summary>
|
||||
public int MaxPacketsPerSecond { get; set; }
|
||||
|
||||
/// <summary>연속 초과 횟수</summary>
|
||||
public int RateLimitViolations { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 패킷 수신 시 호출. 초당 제한 초과 시 true 반환.
|
||||
/// </summary>
|
||||
public bool CheckRateLimit()
|
||||
{
|
||||
long now = Environment.TickCount64;
|
||||
|
||||
// 1초(1000ms) 윈도우 초과 시 리셋
|
||||
if (now - windowStartTicks >= 1000)
|
||||
{
|
||||
windowStartTicks = now;
|
||||
packetCount = 0;
|
||||
}
|
||||
|
||||
packetCount++;
|
||||
|
||||
if (packetCount > MaxPacketsPerSecond)
|
||||
{
|
||||
RateLimitViolations++;
|
||||
return true; // 제한 초과
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>위반 카운트 초기화</summary>
|
||||
public void ResetViolations()
|
||||
{
|
||||
RateLimitViolations = 0;
|
||||
}
|
||||
|
||||
public Session(long hashKey, NetPeer peer, int maxPacketsPerSecond = 60)
|
||||
{
|
||||
HashKey = hashKey;
|
||||
Peer = peer;
|
||||
Token = null;
|
||||
MaxPacketsPerSecond = maxPacketsPerSecond;
|
||||
packetCount = 0;
|
||||
windowStartTicks = Environment.TickCount64;
|
||||
RateLimitViolations = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,6 @@ using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Serilog;
|
||||
|
||||
#if !DEBUG
|
||||
using Microsoft.Diagnostics.NETCore.Client;
|
||||
#endif
|
||||
|
||||
namespace ServerLib.Utils;
|
||||
|
||||
/// <summary>
|
||||
@@ -163,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
|
||||
}
|
||||
|
||||
489
PROJECT_REPORT.md
Normal file
489
PROJECT_REPORT.md
Normal file
@@ -0,0 +1,489 @@
|
||||
# A301 MMO Game Server - 프로젝트 보고서
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 프로젝트명 | A301 MMO Game Server |
|
||||
| 언어 | C# (.NET 9.0) |
|
||||
| 네트워크 | LiteNetLib (UDP 기반) |
|
||||
| 직렬화 | Protocol Buffers (protobuf-net) |
|
||||
| 데이터베이스 | MySQL (Dapper ORM) |
|
||||
| 로깅 | Serilog |
|
||||
| 배포 | Docker Compose |
|
||||
|
||||
---
|
||||
|
||||
## 2. 프로젝트 구현 구성
|
||||
|
||||
### 2.1 디렉토리 구조
|
||||
|
||||
```
|
||||
a301_mmo_game_server/
|
||||
├── MMOTestServer/
|
||||
│ ├── MMOserver/ # 게임 서버 실행 프로젝트
|
||||
│ │ ├── Api/
|
||||
│ │ │ └── RestApi.cs # JWT 토큰 검증 (외부 API 호출)
|
||||
│ │ ├── Game/
|
||||
│ │ │ ├── GameServer.cs # 메인 게임 서버 (ServerBase 상속)
|
||||
│ │ │ ├── Player.cs # 플레이어 엔티티 (상태 + 위치)
|
||||
│ │ │ ├── Channel/
|
||||
│ │ │ │ ├── Channel.cs # 단일 게임 채널
|
||||
│ │ │ │ ├── ChannelManager.cs # 채널 관리 (Singleton)
|
||||
│ │ │ │ └── Maps/
|
||||
│ │ │ │ └── Robby.cs # 로비 맵
|
||||
│ │ │ └── Engine/
|
||||
│ │ │ └── Vector3.cs # 3D 위치 구조체
|
||||
│ │ ├── Packet/
|
||||
│ │ │ ├── PacketHeader.cs # PacketCode Enum 정의
|
||||
│ │ │ └── PacketBody.cs # 모든 패킷 클래스 (ProtoContract)
|
||||
│ │ ├── RDB/ # 데이터베이스 계층
|
||||
│ │ │ ├── DbConnectionFactory.cs
|
||||
│ │ │ ├── Models/
|
||||
│ │ │ ├── Repositories/
|
||||
│ │ │ ├── Services/
|
||||
│ │ │ └── Handlers/
|
||||
│ │ ├── Utils/
|
||||
│ │ │ ├── Singleton.cs
|
||||
│ │ │ └── UuidGeneratorManager.cs
|
||||
│ │ ├── Program.cs # 서버 진입점
|
||||
│ │ └── config.json # DB 접속 설정
|
||||
│ │
|
||||
│ ├── ServerLib/ # 핵심 네트워크 라이브러리 (DLL)
|
||||
│ │ ├── Service/
|
||||
│ │ │ ├── ServerBase.cs # 추상 서버 베이스 클래스
|
||||
│ │ │ ├── Session.cs # 세션 상태 관리
|
||||
│ │ │ └── SessionManager.cs # 세션 매니저
|
||||
│ │ ├── Packet/
|
||||
│ │ │ ├── PacketSerializer.cs # Protobuf 직렬화 (4바이트 헤더)
|
||||
│ │ │ └── PacketType.cs # 기본 패킷 타입
|
||||
│ │ ├── RDB/
|
||||
│ │ │ ├── Database/
|
||||
│ │ │ │ └── IDbConnectionFactory.cs
|
||||
│ │ │ ├── Repositories/
|
||||
│ │ │ │ └── ARepository.cs # 제네릭 비동기 Repository 베이스
|
||||
│ │ │ └── Handlers/
|
||||
│ │ │ ├── HelperHandler.cs
|
||||
│ │ │ └── Response.cs
|
||||
│ │ └── Utils/
|
||||
│ │ └── CrashDumpHandler.cs # 크래시 덤프 핸들러
|
||||
│ │
|
||||
│ └── MMOserver.sln
|
||||
│
|
||||
├── ClientTester/ # 클라이언트 테스트 도구
|
||||
│ └── EchoClientTester/
|
||||
│ ├── DummyService/ # 더미 플레이어 시뮬레이션
|
||||
│ │ ├── DummyClientService.cs
|
||||
│ │ ├── DummyClients.cs
|
||||
│ │ └── MapBounds.cs
|
||||
│ ├── EchoDummyService/ # Echo/Ping 테스트
|
||||
│ │ ├── EchoDummyClientService.cs
|
||||
│ │ └── EchoDummyClients.cs
|
||||
│ └── Packet/
|
||||
│
|
||||
├── compose.yaml # Docker Compose (MySQL + Server)
|
||||
└── ReadMe.md # DB 아키텍처 문서
|
||||
```
|
||||
|
||||
### 2.2 핵심 모듈 구성
|
||||
|
||||
#### ServerLib (네트워크 코어 라이브러리)
|
||||
|
||||
| 모듈 | 파일 | 역할 |
|
||||
|------|------|------|
|
||||
| ServerBase | `ServerBase.cs` | 추상 서버 - 연결/해제/패킷 수신 이벤트 처리 |
|
||||
| Session | `Session.cs` | 인증 상태 + Peer 매핑 (hashKey, token) |
|
||||
| PacketSerializer | `PacketSerializer.cs` | 4바이트 헤더(type 2B + size 2B) + Protobuf 페이로드 |
|
||||
| ARepository | `ARepository.cs` | 제네릭 비동기 CRUD (Dapper.Contrib 기반) |
|
||||
| CrashDumpHandler | `CrashDumpHandler.cs` | 예외 로깅 + 메모리 힙 덤프 생성 |
|
||||
|
||||
#### MMOserver (게임 로직)
|
||||
|
||||
| 모듈 | 파일 | 역할 |
|
||||
|------|------|------|
|
||||
| GameServer | `GameServer.cs` | 패킷 라우팅 + 게임 로직 처리 (ServerBase 상속) |
|
||||
| Player | `Player.cs` | 플레이어 데이터 (위치, HP/MP, 닉네임 등) |
|
||||
| Channel | `Channel.cs` | 채널 단위 플레이어 그룹 관리 |
|
||||
| ChannelManager | `ChannelManager.cs` | 전역 채널 관리 (Singleton) |
|
||||
| RestApi | `RestApi.cs` | JWT 토큰 검증 (외부 인증 API 연동) |
|
||||
| UuidGeneratorManager | `UuidGeneratorManager.cs` | Lock-free 고유 ID 생성기 |
|
||||
|
||||
### 2.3 패킷 프로토콜 정의
|
||||
|
||||
| 코드 | 패킷명 | 전송 방식 | 설명 |
|
||||
|------|--------|-----------|------|
|
||||
| 1 | `ACC_TOKEN` | Reliable | 실제 JWT 토큰 인증 |
|
||||
| 1001 | `DUMMY_ACC_TOKEN` | Reliable | 더미 클라이언트 인증 |
|
||||
| 1000 | `ECHO` | Reliable | 네트워크 테스트 (Ping) |
|
||||
| 2 | `LOAD_CHANNEL` | Reliable | 채널 목록 응답 |
|
||||
| 3 | `INTO_CHANNEL` | Reliable | 채널 입장 |
|
||||
| 5 | `UPDATE_CHANNEL_USER` | Reliable | 유저 입장/퇴장 알림 |
|
||||
| 6 | `TRANSFORM_PLAYER` | **Unreliable** | 이동/회전 동기화 |
|
||||
| 7 | `ACTION_PLAYER` | Reliable | 공격/스킬/회피 액션 |
|
||||
| 8 | `STATE_PLAYER` | Reliable | HP/MP 상태 동기화 |
|
||||
|
||||
### 2.4 데이터베이스 계층 (RDB)
|
||||
|
||||
**아키텍처 패턴**: `Handler → Service → Repository → DB`
|
||||
|
||||
```
|
||||
[Handler] API 요청 수신 / JSON 응답 반환
|
||||
↓
|
||||
[Service] 비즈니스 로직 (검증, 변환)
|
||||
↓
|
||||
[Repository] 데이터 접근 (Dapper CRUD)
|
||||
↓
|
||||
[MySQL] 커넥션 풀 (Min: 5, Max: 100)
|
||||
```
|
||||
|
||||
### 2.5 스레드 모델
|
||||
|
||||
| 구분 | 방식 | 설명 |
|
||||
|------|------|------|
|
||||
| 네트워크 루프 | **싱글 스레드** | `netManager.PollEvents()` 1ms 주기 호출 |
|
||||
| 패킷 처리 | 동기 처리 | Poll 루프 내에서 순차적 처리 |
|
||||
| JWT 검증 | async/await | `HttpClient` 비동기 호출 |
|
||||
| DB 연산 | async/await | Dapper 비동기 쿼리 |
|
||||
| UUID 생성 | lock 기반 | 스레드 안전 ID 할당 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 파이프라인
|
||||
|
||||
### 3.1 클라이언트 접속 ~ 게임 플레이 전체 흐름
|
||||
|
||||
```
|
||||
[1] 클라이언트 접속 (UDP)
|
||||
│
|
||||
├─ LiteNetLib ConnectionRequest
|
||||
│ └─ ServerBase.OnConnectionRequest() → 연결 키 검증
|
||||
│
|
||||
▼
|
||||
[2] 인증 (Authentication)
|
||||
│
|
||||
├─ 클라이언트 → 서버: ACC_TOKEN 또는 DUMMY_ACC_TOKEN 패킷 전송
|
||||
│
|
||||
├─ [실제 인증] JWT 토큰 → RestApi.VerifyTokenAsync()
|
||||
│ └─ POST https://a301.api.tolelom.xyz/api/auth/verify
|
||||
│ ├─ 성공 → username 반환, hashKey 생성
|
||||
│ ├─ 401 → 즉시 실패 (토큰 무효)
|
||||
│ └─ 기타 오류 → 3회 재시도 (1초 간격)
|
||||
│
|
||||
├─ [더미 인증] hashKey 직접 수신 (≤1000 범위)
|
||||
│
|
||||
├─ Session 객체 생성 (hashKey, token, peer)
|
||||
│ └─ peer.Tag에 Session 저장
|
||||
│
|
||||
▼
|
||||
[3] 채널 선택
|
||||
│
|
||||
├─ 서버 → 클라이언트: LOAD_CHANNEL (채널 목록)
|
||||
├─ 클라이언트 → 서버: INTO_CHANNEL (채널 선택)
|
||||
│
|
||||
├─ 서버 처리:
|
||||
│ ├─ 채널에 플레이어 추가
|
||||
│ ├─ 기존 플레이어 목록 조회
|
||||
│ └─ 서버 → 클라이언트: INTO_CHANNEL 응답 (기존 플레이어 리스트)
|
||||
│
|
||||
├─ 서버 → 채널 전체: UPDATE_CHANNEL_USER (새 플레이어 입장 알림)
|
||||
│
|
||||
▼
|
||||
[4] 게임 루프 (실시간 동기화)
|
||||
│
|
||||
├─ TRANSFORM_PLAYER [Unreliable]
|
||||
│ └─ 위치(X,Y,Z) + 회전(RotY) → 채널 내 브로드캐스트
|
||||
│
|
||||
├─ ACTION_PLAYER [ReliableOrdered]
|
||||
│ └─ 공격/스킬/회피 → 채널 내 브로드캐스트
|
||||
│
|
||||
├─ STATE_PLAYER [ReliableOrdered]
|
||||
│ └─ HP/MP 상태 변경 → 채널 내 브로드캐스트
|
||||
│
|
||||
▼
|
||||
[5] 연결 종료
|
||||
│
|
||||
├─ 클라이언트 → 서버: EXIT_CHANNEL
|
||||
├─ 서버: 채널에서 플레이어 제거
|
||||
├─ 서버 → 채널 전체: UPDATE_CHANNEL_USER (퇴장 알림)
|
||||
└─ 세션 정리 및 Peer 해제
|
||||
```
|
||||
|
||||
### 3.2 패킷 처리 파이프라인
|
||||
|
||||
```
|
||||
수신 (OnNetworkReceive)
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 4바이트 헤더 파싱 │
|
||||
│ ├─ type: ushort (2B) → 패킷 종류 │
|
||||
│ └─ size: ushort (2B) → 페이로드 크기 │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Protobuf 역직렬화 │
|
||||
│ └─ byte[] → 패킷 객체 │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 타입별 분기 │
|
||||
│ ├─ ECHO (1000) → HandleEcho() │
|
||||
│ ├─ AUTH (1/1001) → HandleAuth() │
|
||||
│ └─ GAME (2~8) → HandlePacket() │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 게임 로직 처리 │
|
||||
│ └─ 채널 내 브로드캐스트 │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
송신 (SendTo / BroadcastToChannel)
|
||||
```
|
||||
|
||||
### 3.3 세션 관리 파이프라인
|
||||
|
||||
```
|
||||
PendingPeers (미인증) Sessions (인증 완료)
|
||||
Dictionary<int, NetPeer> Dictionary<long, NetPeer>
|
||||
|
||||
접속 시 등록 ──────────────→ 인증 성공 시 이동
|
||||
(peerId 기반) (hashKey 기반)
|
||||
|
||||
TokenHash (토큰 매핑)
|
||||
Dictionary<string, long>
|
||||
token → hashKey 조회
|
||||
```
|
||||
|
||||
- **재접속 처리**: 동일 hashKey로 재접속 시 기존 Peer 강제 해제 → 새 Peer로 교체
|
||||
- **채널 유지**: 재접속 시 기존 채널 멤버십 유지 (채널 선택 스킵)
|
||||
|
||||
### 3.4 빌드 및 배포 파이프라인
|
||||
|
||||
```
|
||||
소스 코드
|
||||
↓
|
||||
dotnet build (MMOserver.sln)
|
||||
├─ ServerLib.csproj → ServerLib.dll
|
||||
└─ MMOserver.csproj → MMOserver.exe
|
||||
↓
|
||||
Docker Build (Dockerfile)
|
||||
↓
|
||||
Docker Compose (compose.yaml)
|
||||
├─ MySQL 컨테이너
|
||||
└─ MMOServer 컨테이너
|
||||
└─ UDP 포트: 9050, 9500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 문제점 - 해결
|
||||
|
||||
### 4.1 해결된 문제
|
||||
|
||||
#### [P1] 이동 패킷 재전송 문제
|
||||
- **문제**: 이동(Transform) 패킷이 `ReliableOrdered`로 전송되어 패킷 손실 시 재전송 발생 → 위치 동기화 지연, Head-of-Line 블로킹
|
||||
- **해결**: 이동 패킷을 `Unreliable` 전송으로 변경 (`commit: 42f0ef1`)
|
||||
- **효과**: 실시간성 향상, 네트워크 지연 최소화 (오래된 위치 데이터는 폐기)
|
||||
|
||||
#### [P2] 로그인 인증 실패 시 재시도 부재
|
||||
- **문제**: JWT 토큰 검증 API 호출이 일시적 네트워크 오류로 실패 시 즉시 인증 실패 처리
|
||||
- **해결**: 3회 재시도 로직 추가 (1초 간격), 401 응답은 즉시 실패 (`commit: c8ce36a`)
|
||||
- **효과**: 일시적 API 장애에 대한 내결함성 확보
|
||||
|
||||
#### [P3] 토큰 → HashKey 매핑 구조
|
||||
- **문제**: 토큰과 hashKey 간의 매핑이 명확하지 않아 재접속/중복 접속 처리 어려움
|
||||
- **해결**: `TokenHash` 딕셔너리 도입, 토큰 관리 로직 구조화 (`commit: bfa3394`)
|
||||
- **효과**: 동일 토큰 재접속 시 기존 세션 정리 및 hashKey 재활용
|
||||
|
||||
#### [P4] 에코 클라이언트 수신 버그
|
||||
- **문제**: 에코 테스트 클라이언트에서 수신 패킷 처리 오류
|
||||
- **해결**: 수신 로직 버그 수정 (`commit: 18fd8a0`)
|
||||
|
||||
#### [P5] 서버 크래시 시 디버깅 정보 부족
|
||||
- **문제**: 서버 비정상 종료 시 원인 파악 불가
|
||||
- **해결**: `CrashDumpHandler` 구현 (`commit: 2be1302`)
|
||||
- Debug 빌드: `.log` 파일 (스택 트레이스)
|
||||
- Release 빌드: `.log` + `.dmp` 파일 (힙 덤프)
|
||||
- UnhandledException, UnobservedTaskException 모두 캡처
|
||||
- **효과**: 프로덕션 환경 크래시 사후 분석 가능
|
||||
|
||||
#### [P6] 패킷 레이트 리미팅 부재
|
||||
- **문제**: 클라이언트의 패킷 전송 빈도 제한 없음 → 패킷 플러딩 공격에 취약
|
||||
- **해결**: `Session` 클래스에 슬라이딩 윈도우 방식 레이트 리미터 구현
|
||||
- 초당 최대 120 패킷 허용 (`MaxPacketsPerSecond`)
|
||||
- 초과 시 해당 패킷 드롭 + 경고 로그
|
||||
- 3회 연속 초과 시 강제 연결 해제
|
||||
- `ServerBase.OnNetworkReceive()`에서 인증된 패킷 처리 전 체크
|
||||
- **적용 파일**: `Session.cs`, `ServerBase.cs`
|
||||
- **효과**: 악의적 패킷 플러딩 방어, 서버 안정성 향상
|
||||
|
||||
#### [P7] 부하 테스트 도구 부재
|
||||
- **문제**: 대규모 동시접속 시나리오 검증 도구 없음, 기존 더미 클라이언트는 10명 고정
|
||||
- **해결**: `StressTest` 모듈 신규 구현 (`ClientTester/EchoClientTester/StressTest/`)
|
||||
- `StressTestClient.cs`: 이동 + Echo RTT 동시 측정 클라이언트
|
||||
- `StressTestService.cs`: 점진적 Ramp-up, 퍼센타일 레이턴시(P50/P95/P99), CSV 내보내기
|
||||
- `Program.cs`: CLI 인자 지원 (`stress -c 100 -d 60 --ip ...`) + 대화형 모드 3번 메뉴
|
||||
- **효과**: N명 동시접속 시나리오 자동 검증, 성능 병목 지점 파악
|
||||
|
||||
### 4.2 현재 남아 있는 문제
|
||||
|
||||
#### [I1] 서버 사이드 위치 검증 미구현
|
||||
- **현상**: 클라이언트가 전송하는 위치를 서버에서 검증하지 않음
|
||||
- **영향**: 스피드핵, 텔레포트 등 치트 방어 불가
|
||||
- **권장**: `MapBounds` 로직을 서버 사이드에 적용, 비정상 이동 감지 시 보정
|
||||
|
||||
#### [I2] 플레이어 데이터 DB 연동 미완성
|
||||
- **현상**: 플레이어 데이터가 하드코딩된 기본값 사용 (TODO 주석 존재)
|
||||
- **영향**: 캐릭터 정보 저장/불러오기 불가
|
||||
- **권장**: Player 모델 DB 테이블 생성, 로그인 시 DB 조회
|
||||
|
||||
#### [I3] NPC / 파티 시스템 미구현
|
||||
- **현상**: 패킷 코드(TRANSFORM_NPC, ACTION_NPC, UPDATE_PARTY 등)는 정의되어 있으나 처리 로직 없음
|
||||
- **영향**: 게임 콘텐츠 부족
|
||||
- **권장**: NPC AI 시스템, 파티 매칭 로직 순차 구현
|
||||
|
||||
---
|
||||
|
||||
## 5. 정리
|
||||
|
||||
### 5.1 아키텍처 요약
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ 클라이언트 (Unity) │
|
||||
└──────────────┬───────────────────────────┬────────────┘
|
||||
│ UDP (LiteNetLib) │
|
||||
┌──────────────▼───────────────────────────▼────────────┐
|
||||
│ ServerBase (네트워크 코어) │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌─────────────────┐ │
|
||||
│ │ 연결 관리 │ │ 패킷 직렬화 │ │ 세션 관리 │ │
|
||||
│ └────────────┘ └────────────┘ └─────────────────┘ │
|
||||
└──────────────┬───────────────────────────┬────────────┘
|
||||
│ │
|
||||
┌──────────────▼───────────────┐ ┌────────▼────────────┐
|
||||
│ GameServer (게임 로직) │ │ RestApi (인증) │
|
||||
│ ┌─────────┐ ┌────────────┐ │ │ JWT 토큰 검증 │
|
||||
│ │ Channel │ │ Player │ │ │ (외부 API 호출) │
|
||||
│ │ Manager │ │ State │ │ └─────────────────────┘
|
||||
│ └─────────┘ └────────────┘ │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
┌──────────────▼───────────────┐
|
||||
│ RDB Layer (데이터베이스) │
|
||||
│ Handler → Service → Repo │
|
||||
│ → MySQL │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 기술적 강점
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| UDP 기반 통신 | LiteNetLib으로 TCP 오버헤드 없이 실시간 통신 |
|
||||
| Protobuf 직렬화 | 컴팩트 바이너리 인코딩으로 대역폭 절약 |
|
||||
| 이동 패킷 Unreliable | HOL 블로킹 방지, 최신 위치만 유효 |
|
||||
| 싱글 스레드 이벤트 루프 | Lock 불필요, 동시성 이슈 최소화 |
|
||||
| 비동기 DB/API | async/await로 네트워크 루프 블로킹 방지 |
|
||||
| 크래시 덤프 | 프로덕션 환경 사후 분석 지원 |
|
||||
| 계층화된 DB 구조 | Handler-Service-Repository 패턴 |
|
||||
| Docker 배포 | 컨테이너화된 일관된 배포 환경 |
|
||||
|
||||
### 5.3 향후 작업 (Roadmap)
|
||||
|
||||
| 우선순위 | 작업 | 설명 |
|
||||
|---------|------|------|
|
||||
| **높음** | 플레이어 DB 연동 | 캐릭터 정보 저장/불러오기 완성 |
|
||||
| **높음** | 서버 사이드 위치 검증 | 치트 방어를 위한 이동 유효성 검사 |
|
||||
| **중간** | NPC 시스템 | AI 기반 NPC 이동/전투 로직 |
|
||||
| **낮음** | 파티 시스템 | 파티 생성/참여/매칭 |
|
||||
| **낮음** | 모니터링 대시보드 | 서버 상태/접속자 수 실시간 모니터링 |
|
||||
|
||||
> **완료된 항목**: 토큰 캐시 정리 (기 구현 확인), 패킷 레이트 리미팅 (적용 완료), 부하 테스트 도구 (구현 완료)
|
||||
|
||||
### 5.4 부하 테스트 도구 (StressTest)
|
||||
|
||||
`ClientTester/EchoClientTester/StressTest/` 에 구현됨.
|
||||
|
||||
#### 구성 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `StressTestClient.cs` | 개별 스트레스 클라이언트 (이동 + Echo RTT 동시 측정) |
|
||||
| `StressTestService.cs` | 테스트 오케스트레이터 (Ramp-up, 통계, CSV 내보내기) |
|
||||
|
||||
#### 기능
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 점진적 Ramp-up | N초 간격으로 클라이언트 추가 (서버 부하 단계적 증가) |
|
||||
| 지속시간 제한 | 테스트 자동 종료 (초 단위, 0 = 무제한) |
|
||||
| 실시간 통계 | 10초마다 접속수, 전송/수신 rate, AvgRTT 출력 |
|
||||
| 퍼센타일 레이턴시 | P50, P95, P99, Min, Max, Avg RTT 계산 |
|
||||
| 최악 클라이언트 분석 | RTT 상위 5명 상세 리포트 |
|
||||
| CSV 내보내기 | `results/stress_{N}clients_{timestamp}.csv` 자동 저장 |
|
||||
|
||||
#### 사용법
|
||||
|
||||
**CLI 모드:**
|
||||
```bash
|
||||
# 기본 (50명, 60초)
|
||||
dotnet run -- stress
|
||||
|
||||
# 커스텀
|
||||
dotnet run -- stress -c 100 -d 120 --ip 192.168.0.10 --port 9500
|
||||
|
||||
# 전체 옵션
|
||||
dotnet run -- stress \
|
||||
-c 200 # 클라이언트 수
|
||||
-d 60 # 테스트 시간 (초, 0=무제한)
|
||||
-i 100 # 전송 주기 (ms)
|
||||
-r 1000 # Ramp-up 간격 (ms)
|
||||
-b 20 # Ramp-up 당 추가 수
|
||||
--ip localhost # 서버 IP
|
||||
--port 9500 # 서버 포트
|
||||
```
|
||||
|
||||
**대화형 모드:**
|
||||
```
|
||||
메뉴에서 3번 선택 → 클라이언트 수, 테스트 시간 입력
|
||||
```
|
||||
|
||||
#### 출력 예시
|
||||
|
||||
```
|
||||
╔═══════════════════════════════════════════════╗
|
||||
║ STRESS TEST 최종 리포트 ║
|
||||
╠═══════════════════════════════════════════════╣
|
||||
║ 테스트 시간 : 60.0초
|
||||
║ 클라이언트 : 98/100 접속 유지
|
||||
╠═══════════════════════════════════════════════╣
|
||||
║ [처리량]
|
||||
║ 총 전송 : 118,200 패킷
|
||||
║ 총 수신 : 117,850 패킷
|
||||
║ 처리량 : 1,970.0 패킷/초
|
||||
╠═══════════════════════════════════════════════╣
|
||||
║ [레이턴시] (RTT 샘플: 59,100개)
|
||||
║ Min : 0.15 ms
|
||||
║ Avg : 1.23 ms
|
||||
║ P50 : 0.89 ms
|
||||
║ P95 : 3.45 ms
|
||||
║ P99 : 8.12 ms
|
||||
║ Max : 25.67 ms
|
||||
╚═══════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
### 5.5 최근 개발 이력
|
||||
|
||||
| 커밋 | 내용 |
|
||||
|------|------|
|
||||
| `2be1302` | 크래시 덤프 기능 추가 (Release: 힙 덤프) |
|
||||
| `42f0ef1` | 이동 패킷 Unreliable 전송으로 변경 |
|
||||
| `bfa3394` | 토큰 → HashKey 생성 로직 구조 변경 |
|
||||
| `c8ce36a` | 로그인 실패 시 재시도 로직 추가 |
|
||||
| `18fd8a0` | 에코 클라이언트 수신 버그 수정 |
|
||||
|
||||
---
|
||||
|
||||
> **작성일**: 2026-03-05
|
||||
> **프로젝트 상태**: 핵심 네트워크 인프라 완성, 게임 콘텐츠 확장 단계
|
||||
Reference in New Issue
Block a user