
지난주 고성능 TCP 서버의 기초를 다지셨습니다. 이번 주에는 그 위에 실질적인 '살'을 붙이는 작업을 진행하겠습니다. 클라이언트로부터 받은 순수한 바이트(Byte) 덩어리를 어떻게 의미 있는 '명령'으로 해석하고, 지난 5주차에 만들었던 PlayerAgent에게 정확히 전달할 수 있을까요? 그 연결고리를 만들어 보겠습니다.
안녕하세요! 스튜디오 비를긋다입니다. 이번 챌린지의 주제는 '패킷 프로토콜 처리 및 핸들러 디스패치 시스템 구현' 입니다. 지난주에 구축한 TCP 서버 위에서, 클라이언트와 서버가 약속된 형식(프로토콜)에 따라 데이터를 주고받고, 수신된 패킷의 종류에 따라 적절한 로직을 실행하도록 연결하는, 서버의 중추 신경계를 만드는 과정입니다.
C#으로 고성능 TCP 서버를 개발 중이신가요? 순수한 바이트 데이터를 의미 있는 게임 명령으로 변환하는 패킷 처리 및 핸들러 디스패치 시스템 구축 방법을 MemoryPack 예제 코드로 자세히 알아보세요.
상황 시나리오:
'아르카디아의 그림자'의 새로운 네트워크 엔진(GameServer)은 이제 클라이언트 접속을 받고 바이트 스트림을 수신할 수 있게 되었습니다. 하지만 이 데이터는 아직 아무 의미 없는 0과 1의 나열일 뿐입니다. 당신의 임무는 이 바이트 스트림을 '이동 요청', '채팅 메시지' 등과 같은 구체적인 게임 액션으로 변환하고, 해당 요청을 보낸 플레이어의 PlayerAgent에게 전달하는 파이프라인을 구축하는 것입니다.
핵심 요구사항:
MemoryPack이나 MessagePack과 같은 고성DEN 성능 바이너리 직렬화 라이브러리를 사용해야 합니다.ClientSession)은 특정 PlayerId와 연결되어야 합니다.ClientSession이 완전한 패킷을 수신하면, 패킷 ID에 맞는 핸들러를 찾아 실행해야 합니다. 이 핸들러는 패킷 데이터를 역직렬화하고, 세션에 연결된 PlayerAgent를 찾아 적절한 IPlayerAction을 메일박스에 넣어주어야 합니다.이 솔루션은 지난주 SAEA 서버 코드에, 고성능 바이너리 직렬화 라이브러리인 MemoryPack을 사용하여 패킷 처리 계층을 추가하는 방법을 보여줍니다.
사전 준비:dotnet add package MemoryPack NuGet 패키지를 프로젝트에 추가해야 합니다.
using MemoryPack;
using System.Buffers;
using System.Collections.Concurrent;
using System.Net.Sockets; // (지난주 코드에서 이어짐)
// --- 프로토콜 정의 ---
// 클라이언트 -> 서버 패킷 ID
public enum PacketId : ushort
{
C_PlayerLogin = 101,
C_PlayerMove = 102,
}
// MemoryPack으로 직렬화할 DTO 정의. [MemoryPackable] 특성은 필수입니다.
[MemoryPackable]
public partial class PlayerLoginRequest { public string Token { get; set; } = ""; }
[MemoryPackable]
public partial class PlayerMoveRequest { public float X { get; set; } public float Y { get; set; } }
// --- 패킷 핸들러 및 디스패처 ---
// 패킷 핸들러의 시그니처를 정의하는 델리게이트. 세션과 역직렬화된 패킷 본문을 받습니다.
public delegate void PacketHandlerDelegate<T>(ClientSession session, T packet) where T : class;
// 모든 패킷 핸들러를 등록하고 관리하는 정적 클래스
public static class PacketHandler
{
// 패킷 ID와 실제 처리 로직을 매핑하는 딕셔너리
private static readonly ConcurrentDictionary<ushort, Action<ClientSession, ReadOnlyMemory<byte>>> _handlers = new();
// PlayerManager와 같은 핵심 서비스에 대한 참조
public static PlayerManager? PlayerManager { get; set; }
// 제네릭 메서드를 사용하여 타입-세이프하게 핸들러를 등록합니다.
public static void Register<T>(PacketId id, PacketHandlerDelegate<T> handler) where T : class
{
var packetId = (ushort)id;
_handlers[packetId] = (session, payload) =>
{
// MemoryPack을 사용해 바이트 배열을 실제 DTO 객체로 역직렬화합니다.
var packet = MemoryPackSerializer.Deserialize<T>(payload.Span);
if (packet != null)
{
handler(session, packet);
}
};
}
// 수신된 패킷 ID에 따라 적절한 핸들러를 호출합니다.
public static void Dispatch(ClientSession session, ushort packetId, ReadOnlyMemory<byte> payload)
{
if (_handlers.TryGetValue(packetId, out var handler))
{
handler(session, payload);
}
}
}
// --- ClientSession 수정 ---
public class ClientSession
{
public ulong PlayerId { get; private set; } // 인증된 플레이어 ID 저장
private readonly RingBuffer _receiveBuffer = new(4096); // 패킷 조립을 위한 링 버퍼
// OnReceiveCompleted 메서드 수정
private void OnReceiveCompleted(object? sender, SocketAsyncEventArgs e)
{
if (e.BytesTransferred > 0 && e.SocketError == SocketError.Success)
{
// 수신된 데이터를 링 버퍼에 씁니다.
_receiveBuffer.Write(e.MemoryBuffer.Slice(0, e.BytesTransferred).Span);
// 버퍼에서 완전한 패킷을 계속해서 파싱합니다.
while (true)
{
// 패킷 구조: [전체 길이(2바이트)][ID(2바이트)][Payload(...)]
if (_receiveBuffer.ReadableSize < 4) break; // 최소 헤더 크기보다 작으면 중단
var header = _receiveBuffer.Peek(4);
ushort packetSize = BitConverter.ToUInt16(header.Span);
if (_receiveBuffer.ReadableSize < packetSize) break; // 패킷 전체가 아직 도착하지 않았으면 중단
// 완전한 패킷 하나를 버퍼에서 읽어옵니다.
var packetData = _receiveBuffer.Read(packetSize);
ushort packetId = BitConverter.ToUInt16(packetData.Span[2..]);
var payload = packetData[4..];
// 디스패처에 처리를 위임합니다.
PacketHandler.Dispatch(this, packetId, payload);
}
// 다음 데이터 수신 재개
if (!Socket.ReceiveAsync(e)) OnReceiveCompleted(this, e);
}
else
{
Disconnect();
}
}
// 로그인 성공 시 세션에 플레이어 ID를 할당하는 메서드
public void Authenticate(ulong playerId) => PlayerId = playerId;
}
// --- Main 진입점 또는 서버 초기화 로직 ---
public class Program
{
static void Main(string[] args)
{
// 1. 핵심 서비스(PlayerManager 등) 초기화
var repository = new SqlitePlayerRepository("game.db");
PacketHandler.PlayerManager = new PlayerManager(repository);
// 2. 패킷 핸들러 등록
PacketHandler.Register<PlayerLoginRequest>(PacketId.C_PlayerLogin, (session, req) =>
{
// 실제로는 토큰을 검증하여 PlayerId를 얻어와야 합니다.
ulong playerId = 12345; // 임시 ID
session.Authenticate(playerId);
// 해당 플레이어의 PlayerAgent를 활성화 (로딩)
PacketHandler.PlayerManager.GetOrCreateAgent(playerId);
Console.WriteLine($"Player {playerId} logged in.");
});
PacketHandler.Register<PlayerMoveRequest>(PacketId.C_PlayerMove, (session, req) =>
{
if (session.PlayerId == 0) return; // 미인증 세션은 무시
// 에이전트를 찾아 이동 액션을 메일박스에 넣습니다.
var agent = PacketHandler.PlayerManager.GetAgent(session.PlayerId);
agent?.PostActionAsync(new MoveAction(req.X, req.Y));
});
// 3. 서버 시작
var server = new GameServer(7777, 100, 1000);
server.Start();
// 서버가 종료되지 않도록 대기
Console.ReadLine();
}
}
이 아키텍처는 게임 서버의 '중앙 처리 장치(CPU)'와 같습니다.
GameServer에 TCP 연결을 맺습니다.PlayerLoginRequest DTO를 직렬화하여 서버로 전송합니다. 서버의 ClientSession은 이 바이트 스트림을 받아 패킷을 조립하고, PacketId.C_PlayerLogin을 확인하여 로그인 핸들러를 호출합니다. 핸들러는 토큰을 검증하고 session.Authenticate(playerId)를 호출하여 세션을 '인증된 상태'로 만듭니다. 동시에 PlayerManager를 통해 해당 PlayerAgent를 메모리에 로딩시킵니다.PlayerMoveRequest 패킷을 계속해서 서버로 보냅니다. 서버는 이동 핸들러를 통해 이 요청을 MoveAction으로 변환하고, 해당 PlayerAgent의 메일박스로 전달합니다. PlayerAgent는 자신의 처리 루프에서 이 MoveAction을 순차적으로 처리하여 위치를 업데이트합니다.이처럼 **네트워크 계층(ClientSession)**은 바이트 처리와 패킷 조립만 담당하고, **디스패치 계층(PacketHandler)**은 패킷 라우팅만, **애플리케이션 계층(PlayerAgent)**은 실제 게임 로직만 처리하도록 역할이 명확하게 분리되어 매우 깔끔하고 확장 가능한 구조가 됩니다.
MemoryPack과 같은 고성능 직렬화 라이브러리를 사용하여 네트워크 효율성을 극대화하는 방법을 학습합니다.RingBuffer를 가정)PlayerAgent에게 전달되어 처리되는가?현재 핸들러 시스템은 간단하지만, 기능이 많아지면 공통적으로 처리해야 할 로직(인증 체크, 로깅 등)이 중복될 수 있습니다. 이를 개선해 보세요.
과제: 패킷 핸들러에 '미들웨어(Middleware)' 파이프라인 패턴 도입
요구사항:
[AuthRequired]와 같은 어트리뷰트(Attribute)를 DTO 클래스나 핸들러 메서드에 붙일 수 있도록 설계합니다.Authenticate 되었는지 먼저 검사합니다. 인증되지 않은 세션이라면 핸들러를 실행하지 않고 연결을 끊는 등의 처리를 합니다.이번 챌린지를 통해 드디어 여러분의 서버가 클라이언트와 '대화'를 시작하게 됩니다. 서버 개발의 꽃이라 할 수 있는 이 과정을 즐겨보시기 바랍니다.
GitHub - Cysharp/MemoryPack: Zero encoding extreme performance binary serializer for C# and Unity.
Zero encoding extreme performance binary serializer for C# and Unity. - Cysharp/MemoryPack
github.com
MemoryPack - 가장 빠른 .NET 시리얼라이저
C#용 MessagePack의 주요 기여자인 카와이 요시후미님이 만든 .NET 시리얼라이저인 MemoryPack을 소개 합니다. MemoryPack은 .NET 증분 소스 생성기를 이용해 AOT 환경에 친화적이며, 메모리 사용에 최적화 되
forum.dotnetdev.kr
| 8.주간 C# 서버 챌린지: 유닛 테스트 및 통합 테스트 (1) | 2025.11.01 |
|---|---|
| 7.주간 C# 서버 챌린지: 분산 마이크로서비스 (0) | 2025.10.22 |
| 5.주간 C# 서버 챌린지: MMORPG 서버 개발의 핵심, C# 비동기 소켓 프로그래밍 (0) | 2025.10.02 |
| 4.주간 C# 서버 챌린지: 비동기 I/O와 리포지토리 패턴으로 플레이어 데이터 저장하기 (0) | 2025.09.24 |
| 3.주간 C# 서버 챌린지: '락(Lock)' 없는 플레이어 상태 관리 (0) | 2025.09.17 |