From ea3f64a40d489388642d7ffd27f72e202ab9c89b Mon Sep 17 00:00:00 2001 From: qornwh1 Date: Thu, 5 Mar 2026 10:58:49 +0900 Subject: [PATCH] =?UTF-8?q?feat=20:=20=EC=8A=A4=ED=8A=B8=EB=A0=88=EC=8A=A4?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20/=20=ED=8C=A8=ED=82=B7=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=9F=89=20=EC=A0=9C=ED=95=9C=20/=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=83=81=ED=99=A9=20=EB=A6=AC=EB=93=9C?= =?UTF-8?q?=EB=AF=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EchoClientTester/EchoClientTester.csproj | 2 - ClientTester/EchoClientTester/Program.cs | 165 +++++- .../StressTest/StressTestClient.cs | 203 ++++++++ .../StressTest/StressTestService.cs | 398 ++++++++++++++ .../Utils/CrashDumpHandler.cs | 37 +- ClientTester/README.md | 304 +++++++++++ MMOTestServer/ServerLib/ServerLib.csproj | 2 - MMOTestServer/ServerLib/Service/ServerBase.cs | 16 + MMOTestServer/ServerLib/Service/Session.cs | 47 +- .../ServerLib/Utils/CrashDumpHandler.cs | 36 +- PROJECT_REPORT.md | 489 ++++++++++++++++++ 11 files changed, 1668 insertions(+), 31 deletions(-) create mode 100644 ClientTester/EchoClientTester/StressTest/StressTestClient.cs create mode 100644 ClientTester/EchoClientTester/StressTest/StressTestService.cs create mode 100644 ClientTester/README.md create mode 100644 PROJECT_REPORT.md diff --git a/ClientTester/EchoClientTester/EchoClientTester.csproj b/ClientTester/EchoClientTester/EchoClientTester.csproj index ae21309..6c44a04 100644 --- a/ClientTester/EchoClientTester/EchoClientTester.csproj +++ b/ClientTester/EchoClientTester/EchoClientTester.csproj @@ -9,8 +9,6 @@ - - diff --git a/ClientTester/EchoClientTester/Program.cs b/ClientTester/EchoClientTester/Program.cs index 7f11316..773fac1 100644 --- a/ClientTester/EchoClientTester/Program.cs +++ b/ClientTester/EchoClientTester/Program.cs @@ -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 전송 주기 (기본: 100)"); + Console.WriteLine(" -r, --ramp 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); + } } } diff --git a/ClientTester/EchoClientTester/StressTest/StressTestClient.cs b/ClientTester/EchoClientTester/StressTest/StressTestClient.cs new file mode 100644 index 0000000..78a48fa --- /dev/null +++ b/ClientTester/EchoClientTester/StressTest/StressTestClient.cs @@ -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; + +/// +/// 스트레스 테스트용 클라이언트. +/// Echo RTT 측정 + 이동 패킷 전송을 동시에 수행. +/// +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 pendingPings = new(); + private int seqNumber; + private const int MAX_PENDING = 500; + + /// 개별 RTT 기록 (퍼센타일 계산용) + public ConcurrentBag 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(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(); + } +} diff --git a/ClientTester/EchoClientTester/StressTest/StressTestService.cs b/ClientTester/EchoClientTester/StressTest/StressTestService.cs new file mode 100644 index 0000000..9002b99 --- /dev/null +++ b/ClientTester/EchoClientTester/StressTest/StressTestService.cs @@ -0,0 +1,398 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using LiteNetLib; +using Serilog; + +namespace ClientTester.StressTest; + +/// +/// 스트레스/부하 테스트 서비스. +/// +/// 기능: +/// - 점진적 Ramp-up (N초 간격으로 클라이언트 추가) +/// - 테스트 지속시간 제한 +/// - 실시간 처리량 (packets/sec) 측정 +/// - 퍼센타일 레이턴시 (P50, P95, P99) 계산 +/// - CSV 결과 내보내기 +/// +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 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; + + /// + /// 스트레스 테스트 서비스 생성. + /// + /// 총 클라이언트 수 + /// 서버 IP + /// 서버 포트 + /// 연결 키 + /// 테스트 지속 시간 (초). 0 = 무제한 + /// 패킷 전송 주기 (ms) + /// Ramp-up 간격 (ms). 0 = 모두 즉시 접속 + /// Ramp-up 당 추가할 클라이언트 수 + 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(); + } + + /// 점진적으로 클라이언트 추가 + 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 snapshot; + lock (clientsLock) + { + snapshot = new List(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 snapshot; + lock (clientsLock) + { + snapshot = new List(clients); + } + + foreach (StressTestClient c in snapshot) + { + c.UpdateAndSendTransform(); + c.SendPing(); + } + + await Task.Delay(sendIntervalMs, ct).ConfigureAwait(false); + } + } + + /// 10초마다 실시간 통계 출력 + 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 snapshot; + lock (clientsLock) + { + snapshot = new List(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); + } + + /// 최종 결과 리포트 출력 + public void PrintFinalReport() + { + List snapshot; + lock (clientsLock) + { + snapshot = new List(clients); + } + + int totalSent = 0, totalRecv = 0, connected = 0; + List 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 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("╚═══════════════════════════════════════════════╝"); + } + + /// 결과를 CSV 파일로 내보내기 + public void ExportCsv(string path) + { + List snapshot; + lock (clientsLock) + { + snapshot = new List(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 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 snapshot; + lock (clientsLock) + { + snapshot = new List(clients); + } + + foreach (StressTestClient c in snapshot) + { + c.Stop(); + } + + Log.Information("[STRESS] 모든 클라이언트 종료됨."); + } + + private static double Percentile(List 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; + } +} diff --git a/ClientTester/EchoClientTester/Utils/CrashDumpHandler.cs b/ClientTester/EchoClientTester/Utils/CrashDumpHandler.cs index 37fa618..3e0b9af 100644 --- a/ClientTester/EchoClientTester/Utils/CrashDumpHandler.cs +++ b/ClientTester/EchoClientTester/Utils/CrashDumpHandler.cs @@ -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; /// @@ -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 } diff --git a/ClientTester/README.md b/ClientTester/README.md new file mode 100644 index 0000000..0d03165 --- /dev/null +++ b/ClientTester/README.md @@ -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 | 메모리 누수 방지 | diff --git a/MMOTestServer/ServerLib/ServerLib.csproj b/MMOTestServer/ServerLib/ServerLib.csproj index fba7671..c8ed13a 100644 --- a/MMOTestServer/ServerLib/ServerLib.csproj +++ b/MMOTestServer/ServerLib/ServerLib.csproj @@ -8,8 +8,6 @@ - - diff --git a/MMOTestServer/ServerLib/Service/ServerBase.cs b/MMOTestServer/ServerLib/Service/ServerBase.cs index 7e52256..a34ea84 100644 --- a/MMOTestServer/ServerLib/Service/ServerBase.cs +++ b/MMOTestServer/ServerLib/Service/ServerBase.cs @@ -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) diff --git a/MMOTestServer/ServerLib/Service/Session.cs b/MMOTestServer/ServerLib/Service/Session.cs index 969b672..2f4575d 100644 --- a/MMOTestServer/ServerLib/Service/Session.cs +++ b/MMOTestServer/ServerLib/Service/Session.cs @@ -22,10 +22,55 @@ public class Session set; } - public Session(long hashKey, NetPeer peer) + // ─── 패킷 레이트 리미팅 ─────────────────────────── + private int packetCount; + private long windowStartTicks; + + /// 초당 허용 패킷 수 + public int MaxPacketsPerSecond { get; set; } + + /// 연속 초과 횟수 + public int RateLimitViolations { get; private set; } + + /// + /// 패킷 수신 시 호출. 초당 제한 초과 시 true 반환. + /// + 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; + } + + /// 위반 카운트 초기화 + 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; } } diff --git a/MMOTestServer/ServerLib/Utils/CrashDumpHandler.cs b/MMOTestServer/ServerLib/Utils/CrashDumpHandler.cs index d6353e4..8001246 100644 --- a/MMOTestServer/ServerLib/Utils/CrashDumpHandler.cs +++ b/MMOTestServer/ServerLib/Utils/CrashDumpHandler.cs @@ -3,10 +3,6 @@ using System.Runtime.InteropServices; using System.Text; using Serilog; -#if !DEBUG -using Microsoft.Diagnostics.NETCore.Client; -#endif - namespace ServerLib.Utils; /// @@ -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 } diff --git a/PROJECT_REPORT.md b/PROJECT_REPORT.md new file mode 100644 index 0000000..3598bfb --- /dev/null +++ b/PROJECT_REPORT.md @@ -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 Dictionary + + 접속 시 등록 ──────────────→ 인증 성공 시 이동 + (peerId 기반) (hashKey 기반) + + TokenHash (토큰 매핑) + Dictionary + 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 +> **프로젝트 상태**: 핵심 네트워크 인프라 완성, 게임 콘텐츠 확장 단계