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

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

View File

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

View File

@@ -1,14 +1,15 @@
using ClientTester.DummyService; using ClientTester.DummyService;
using ClientTester.EchoDummyService; using ClientTester.EchoDummyService;
using ClientTester.StressTest;
using ClientTester.Utils; using ClientTester.Utils;
using Serilog; using Serilog;
class EcoClientTester class EcoClientTester
{ {
public static readonly string SERVER_IP = "localhost"; public static string SERVER_IP = "localhost";
public static readonly int SERVER_PORT = 9500; public static int SERVER_PORT = 9500;
public static readonly string CONNECTION_KEY = "test"; public static readonly string CONNECTION_KEY = "test";
public static readonly int CLIENT_COUNT = 10; public static int CLIENT_COUNT = 100;
private async Task StartEchoDummyTest() 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) private static async Task Main(string[] args)
{ {
// 유니코드 문자(═, ║ 등) 콘솔 깨짐 방지
Console.OutputEncoding = System.Text.Encoding.UTF8;
// 크래시 덤프 핸들러 (Release: .log + .dmp / Debug: .log) // 크래시 덤프 핸들러 (Release: .log + .dmp / Debug: .log)
CrashDumpHandler.Register(); CrashDumpHandler.Register();
@@ -92,29 +163,95 @@ class EcoClientTester
.WriteTo.File($"logs2/log_{timestamp}.txt") .WriteTo.File($"logs2/log_{timestamp}.txt")
.CreateLogger(); .CreateLogger();
Log.Information("========== 더미 클라 테스터 =========="); EcoClientTester tester = new EcoClientTester();
Log.Information("1. 에코 서버");
Log.Information("2. 더미 클라(이동만)");
Log.Information("====================================");
Log.Information("1 / 2 : ");
string? input = Console.ReadLine(); // CLI 모드: stress 명령
if (!int.TryParse(input, out int choice) || (choice != 1 && choice != 2)) 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; return;
} }
EcoClientTester tester = new EcoClientTester();
if (choice == 1) if (choice == 1)
{ {
// 에코 서버 실행
await tester.StartEchoDummyTest(); await tester.StartEchoDummyTest();
} }
else if (choice == 2) else if (choice == 2)
{ {
// 더미 클라 실행
await tester.StartDummyTest(); await tester.StartDummyTest();
} }
else if (choice == 3)
{
// 대화형 스트레스 테스트 설정
Log.Information("클라이언트 수 (기본 50): ");
string? countInput = Console.ReadLine();
CLIENT_COUNT = string.IsNullOrEmpty(countInput) ? 50 : int.Parse(countInput);
Log.Information("테스트 시간(초) (기본 60, 0=무제한): ");
string? durInput = Console.ReadLine();
int dur = string.IsNullOrEmpty(durInput) ? 60 : int.Parse(durInput);
await tester.StartStressTest(dur, 100, 1000, 10);
}
} }
} }

View File

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

View File

@@ -0,0 +1,398 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using LiteNetLib;
using Serilog;
namespace ClientTester.StressTest;
/// <summary>
/// 스트레스/부하 테스트 서비스.
///
/// 기능:
/// - 점진적 Ramp-up (N초 간격으로 클라이언트 추가)
/// - 테스트 지속시간 제한
/// - 실시간 처리량 (packets/sec) 측정
/// - 퍼센타일 레이턴시 (P50, P95, P99) 계산
/// - CSV 결과 내보내기
/// </summary>
public class StressTestService
{
private readonly string ip;
private readonly int port;
private readonly string key;
private readonly int totalClients;
private readonly int rampUpIntervalMs;
private readonly int clientsPerRamp;
private readonly int sendIntervalMs;
private readonly int durationSec;
private readonly List<StressTestClient> clients = new();
private readonly object clientsLock = new();
private readonly Stopwatch testClock = new();
// 실시간 처리량 추적
private int prevTotalSent;
private int prevTotalRecv;
private long prevStatsTick;
public event Action? OnTestCompleted;
/// <summary>
/// 스트레스 테스트 서비스 생성.
/// </summary>
/// <param name="totalClients">총 클라이언트 수</param>
/// <param name="ip">서버 IP</param>
/// <param name="port">서버 포트</param>
/// <param name="key">연결 키</param>
/// <param name="durationSec">테스트 지속 시간 (초). 0 = 무제한</param>
/// <param name="sendIntervalMs">패킷 전송 주기 (ms)</param>
/// <param name="rampUpIntervalMs">Ramp-up 간격 (ms). 0 = 모두 즉시 접속</param>
/// <param name="clientsPerRamp">Ramp-up 당 추가할 클라이언트 수</param>
public StressTestService(
int totalClients, string ip, int port, string key,
int durationSec = 60, int sendIntervalMs = 100,
int rampUpIntervalMs = 1000, int clientsPerRamp = 10)
{
this.totalClients = totalClients;
this.ip = ip;
this.port = port;
this.key = key;
this.durationSec = durationSec;
this.sendIntervalMs = sendIntervalMs;
this.rampUpIntervalMs = rampUpIntervalMs;
this.clientsPerRamp = clientsPerRamp;
}
public async Task RunAsync(CancellationToken ct)
{
Log.Information("╔═══════════════════════════════════════════════╗");
Log.Information("║ STRESS TEST 시작 ║");
Log.Information("╠═══════════════════════════════════════════════╣");
Log.Information("║ 서버 : {Ip}:{Port}", ip, port);
Log.Information("║ 클라이언트 : {Count}명", totalClients);
Log.Information("║ 지속시간 : {Dur}초", durationSec == 0 ? "무제한" : durationSec);
Log.Information("║ 전송주기 : {Int}ms", sendIntervalMs);
Log.Information("║ Ramp-up : {Per}명 / {Int}ms", clientsPerRamp, rampUpIntervalMs);
Log.Information("╚═══════════════════════════════════════════════╝");
testClock.Start();
prevStatsTick = Stopwatch.GetTimestamp();
using CancellationTokenSource durationCts = durationSec > 0
? new CancellationTokenSource(TimeSpan.FromSeconds(durationSec))
: new CancellationTokenSource();
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, durationCts.Token);
await Task.WhenAll(
RampUpLoopAsync(linked.Token),
PollLoopAsync(linked.Token),
SendLoopAsync(linked.Token),
StatsLoopAsync(linked.Token)
);
testClock.Stop();
OnTestCompleted?.Invoke();
}
/// <summary>점진적으로 클라이언트 추가</summary>
private async Task RampUpLoopAsync(CancellationToken ct)
{
int created = 0;
while (created < totalClients && !ct.IsCancellationRequested)
{
int batch = Math.Min(clientsPerRamp, totalClients - created);
for (int i = 0; i < batch; i++)
{
long id = created + 1; // 1-based (더미 범위)
StressTestClient client = new StressTestClient(id, ip, port, key);
lock (clientsLock)
{
clients.Add(client);
}
created++;
}
Log.Information("[RAMP-UP] {Created}/{Total} 클라이언트 생성됨 ({Elapsed:F1}s)",
created, totalClients, testClock.Elapsed.TotalSeconds);
if (created < totalClients && rampUpIntervalMs > 0)
{
await Task.Delay(rampUpIntervalMs, ct).ConfigureAwait(false);
}
}
Log.Information("[RAMP-UP] 완료 - 총 {Count}명 접속", created);
}
private async Task PollLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
List<StressTestClient> snapshot;
lock (clientsLock)
{
snapshot = new List<StressTestClient>(clients);
}
foreach (StressTestClient c in snapshot)
{
try
{
c.PollEvents();
}
catch
{
/* 무시 */
}
}
await Task.Delay(10, ct).ConfigureAwait(false);
}
}
private async Task SendLoopAsync(CancellationToken ct)
{
await Task.Delay(500, ct).ConfigureAwait(false);
while (!ct.IsCancellationRequested)
{
List<StressTestClient> snapshot;
lock (clientsLock)
{
snapshot = new List<StressTestClient>(clients);
}
foreach (StressTestClient c in snapshot)
{
c.UpdateAndSendTransform();
c.SendPing();
}
await Task.Delay(sendIntervalMs, ct).ConfigureAwait(false);
}
}
/// <summary>10초마다 실시간 통계 출력</summary>
private async Task StatsLoopAsync(CancellationToken ct)
{
await Task.Delay(5000, ct).ConfigureAwait(false);
while (!ct.IsCancellationRequested)
{
PrintLiveStats();
await Task.Delay(10000, ct).ConfigureAwait(false);
}
}
private void PrintLiveStats()
{
List<StressTestClient> snapshot;
lock (clientsLock)
{
snapshot = new List<StressTestClient>(clients);
}
int totalSent = snapshot.Sum(c => c.SentCount);
int totalRecv = snapshot.Sum(c => c.ReceivedCount);
int connected = snapshot.Count(c => c.IsConnected);
long now = Stopwatch.GetTimestamp();
double elapsedSec = (now - prevStatsTick) * 1.0 / Stopwatch.Frequency;
double sendRate = elapsedSec > 0 ? (totalSent - prevTotalSent) / elapsedSec : 0;
double recvRate = elapsedSec > 0 ? (totalRecv - prevTotalRecv) / elapsedSec : 0;
prevTotalSent = totalSent;
prevTotalRecv = totalRecv;
prevStatsTick = now;
double avgRtt = 0;
int rttClients = 0;
foreach (StressTestClient c in snapshot)
{
if (c.RttCount > 0)
{
avgRtt += c.AvgRttMs;
rttClients++;
}
}
if (rttClients > 0)
{
avgRtt /= rttClients;
}
Log.Information(
"[LIVE {Elapsed:F0}s] 접속={Conn}/{Total} | 전송={SendRate:F0}/s 수신={RecvRate:F0}/s | AvgRTT={Rtt:F2}ms",
testClock.Elapsed.TotalSeconds, connected, clients.Count, sendRate, recvRate, avgRtt);
}
/// <summary>최종 결과 리포트 출력</summary>
public void PrintFinalReport()
{
List<StressTestClient> snapshot;
lock (clientsLock)
{
snapshot = new List<StressTestClient>(clients);
}
int totalSent = 0, totalRecv = 0, connected = 0;
List<double> allRtt = new();
foreach (StressTestClient c in snapshot)
{
totalSent += c.SentCount;
totalRecv += c.ReceivedCount;
if (c.IsConnected)
{
connected++;
}
foreach (double r in c.RttSamples)
{
allRtt.Add(r);
}
}
allRtt.Sort();
double p50 = Percentile(allRtt, 0.50);
double p95 = Percentile(allRtt, 0.95);
double p99 = Percentile(allRtt, 0.99);
double avg = allRtt.Count > 0 ? allRtt.Average() : 0;
double min = allRtt.Count > 0 ? allRtt[0] : 0;
double max = allRtt.Count > 0 ? allRtt[^1] : 0;
double durSec = testClock.Elapsed.TotalSeconds;
double throughput = durSec > 0 ? totalSent / durSec : 0;
Log.Information("");
Log.Information("╔═══════════════════════════════════════════════╗");
Log.Information("║ STRESS TEST 최종 리포트 ║");
Log.Information("╠═══════════════════════════════════════════════╣");
Log.Information("║ 테스트 시간 : {Dur:F1}초", durSec);
Log.Information("║ 클라이언트 : {Conn}/{Total} 접속 유지", connected, snapshot.Count);
Log.Information("╠═══════════════════════════════════════════════╣");
Log.Information("║ [처리량]");
Log.Information("║ 총 전송 : {Sent:N0} 패킷", totalSent);
Log.Information("║ 총 수신 : {Recv:N0} 패킷", totalRecv);
Log.Information("║ 처리량 : {Thr:F1} 패킷/초", throughput);
Log.Information("╠═══════════════════════════════════════════════╣");
Log.Information("║ [레이턴시] (RTT 샘플: {Count:N0}개)", allRtt.Count);
Log.Information("║ Min : {Min:F2} ms", min);
Log.Information("║ Avg : {Avg:F2} ms", avg);
Log.Information("║ P50 : {P50:F2} ms", p50);
Log.Information("║ P95 : {P95:F2} ms", p95);
Log.Information("║ P99 : {P99:F2} ms", p99);
Log.Information("║ Max : {Max:F2} ms", max);
Log.Information("╠═══════════════════════════════════════════════╣");
// 클라이언트별 요약 (상위 5명 최악 RTT)
List<StressTestClient> worstClients = snapshot
.Where(c => c.RttCount > 0)
.OrderByDescending(c => c.AvgRttMs)
.Take(5)
.ToList();
if (worstClients.Count > 0)
{
Log.Information("║ [최악 RTT 상위 5 클라이언트]");
foreach (StressTestClient c in worstClients)
{
NetStatistics? stats = c.peer?.Statistics;
float lossPct = stats?.PacketLossPercent ?? 0f;
Log.Information("║ Client {Id:000}: AvgRTT={Rtt:F2}ms Loss={Loss:F1}% Sent={S} Recv={R}",
c.clientId, c.AvgRttMs, lossPct, c.SentCount, c.ReceivedCount);
}
}
Log.Information("╚═══════════════════════════════════════════════╝");
}
/// <summary>결과를 CSV 파일로 내보내기</summary>
public void ExportCsv(string path)
{
List<StressTestClient> snapshot;
lock (clientsLock)
{
snapshot = new List<StressTestClient>(clients);
}
StringBuilder sb = new();
sb.AppendLine("ClientId,Sent,Received,AvgRttMs,RttCount,PacketLoss,PacketLossPct,Connected");
foreach (StressTestClient c in snapshot)
{
NetStatistics? stats = c.peer?.Statistics;
long loss = stats?.PacketLoss ?? 0;
float lossPct = stats?.PacketLossPercent ?? 0f;
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
"{0},{1},{2},{3:F3},{4},{5},{6:F1},{7}",
c.clientId, c.SentCount, c.ReceivedCount,
c.AvgRttMs, c.RttCount, loss, lossPct, c.IsConnected));
}
// 요약 행
List<double> allRtt = new();
foreach (StressTestClient c in snapshot)
foreach (double r in c.RttSamples)
{
allRtt.Add(r);
}
allRtt.Sort();
sb.AppendLine();
sb.AppendLine("# Summary");
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
"# Duration={0:F1}s,Clients={1},TotalSent={2},TotalRecv={3}",
testClock.Elapsed.TotalSeconds, snapshot.Count,
snapshot.Sum(c => c.SentCount), snapshot.Sum(c => c.ReceivedCount)));
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
"# P50={0:F2}ms,P95={1:F2}ms,P99={2:F2}ms",
Percentile(allRtt, 0.50), Percentile(allRtt, 0.95), Percentile(allRtt, 0.99)));
File.WriteAllText(path, sb.ToString());
Log.Information("[CSV] 결과 저장: {Path}", path);
}
public void Stop()
{
List<StressTestClient> snapshot;
lock (clientsLock)
{
snapshot = new List<StressTestClient>(clients);
}
foreach (StressTestClient c in snapshot)
{
c.Stop();
}
Log.Information("[STRESS] 모든 클라이언트 종료됨.");
}
private static double Percentile(List<double> sorted, double p)
{
if (sorted.Count == 0)
{
return 0;
}
double index = p * (sorted.Count - 1);
int lower = (int)Math.Floor(index);
int upper = (int)Math.Ceiling(index);
if (lower == upper)
{
return sorted[lower];
}
double frac = index - lower;
return sorted[lower] * (1 - frac) + sorted[upper] * frac;
}
}

View File

@@ -1,11 +1,8 @@
using System.Diagnostics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
using Serilog; using Serilog;
#if !DEBUG
using Microsoft.Diagnostics.NETCore.Client;
#endif
namespace ClientTester.Utils; namespace ClientTester.Utils;
/// <summary> /// <summary>
@@ -162,10 +159,38 @@ public static class CrashDumpHandler
} }
#if !DEBUG #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) private static void WriteDumpFile(string path)
{ {
DiagnosticsClient client = new DiagnosticsClient(Environment.ProcessId); if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
client.WriteDump(DumpType.WithHeap, path, logDumpGeneration: false); {
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 #endif
} }

304
ClientTester/README.md Normal file
View 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 | 메모리 누수 방지 |

View File

@@ -8,8 +8,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <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" Version="2.1.66" />
<PackageReference Include="Dapper.Contrib" Version="2.0.78" /> <PackageReference Include="Dapper.Contrib" Version="2.0.78" />
<PackageReference Include="LiteNetLib" Version="2.0.2" /> <PackageReference Include="LiteNetLib" Version="2.0.2" />

View File

@@ -184,6 +184,22 @@ public abstract class ServerBase : INetEventListener
return; 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); HandlePacket(peer, session.HashKey, type, payload);
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -22,10 +22,55 @@ public class Session
set; 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; HashKey = hashKey;
Peer = peer; Peer = peer;
Token = null; Token = null;
MaxPacketsPerSecond = maxPacketsPerSecond;
packetCount = 0;
windowStartTicks = Environment.TickCount64;
RateLimitViolations = 0;
} }
} }

View File

@@ -3,10 +3,6 @@ using System.Runtime.InteropServices;
using System.Text; using System.Text;
using Serilog; using Serilog;
#if !DEBUG
using Microsoft.Diagnostics.NETCore.Client;
#endif
namespace ServerLib.Utils; namespace ServerLib.Utils;
/// <summary> /// <summary>
@@ -163,10 +159,38 @@ public static class CrashDumpHandler
} }
#if !DEBUG #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) private static void WriteDumpFile(string path)
{ {
DiagnosticsClient client = new DiagnosticsClient(Environment.ProcessId); if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
client.WriteDump(DumpType.WithHeap, path, logDumpGeneration: false); {
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 #endif
} }

489
PROJECT_REPORT.md Normal file
View 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
> **프로젝트 상태**: 핵심 네트워크 인프라 완성, 게임 콘텐츠 확장 단계