6.주간 C# 서버 챌린지: MemoryPack으로 패킷 처리 시스템 구축하기
지난주 고성능 TCP 서버의 기초를 다지셨습니다. 이번 주에는 그 위에 실질적인 '살'을 붙이는 작업을 진행하겠습니다. 클라이언트로부터 받은 순수한 바이트(Byte) 덩어리를 어떻게 의미 있는 '명령'으로 해석하고, 지난 5주차에 만들었던 PlayerAgent
에게 정확히 전달할 수 있을까요? 그 연결고리를 만들어 보겠습니다.
주간 C# 서버 챌린지: 실전 패킷 처리 및 핸들러 디스패치
안녕하세요! 스튜디오 비를긋다입니다. 이번 챌린지의 주제는 '패킷 프로토콜 처리 및 핸들러 디스패치 시스템 구현' 입니다. 지난주에 구축한 TCP 서버 위에서, 클라이언트와 서버가 약속된 형식(프로토콜)에 따라 데이터를 주고받고, 수신된 패킷의 종류에 따라 적절한 로직을 실행하도록 연결하는, 서버의 중추 신경계를 만드는 과정입니다.
C#으로 고성능 TCP 서버를 개발 중이신가요? 순수한 바이트 데이터를 의미 있는 게임 명령으로 변환하는 패킷 처리 및 핸들러 디스패치 시스템 구축 방법을 MemoryPack 예제 코드로 자세히 알아보세요.
문제: 네트워크 계층과 애플리케이션 계층 연결
상황 시나리오:
'아르카디아의 그림자'의 새로운 네트워크 엔진(GameServer
)은 이제 클라이언트 접속을 받고 바이트 스트림을 수신할 수 있게 되었습니다. 하지만 이 데이터는 아직 아무 의미 없는 0과 1의 나열일 뿐입니다. 당신의 임무는 이 바이트 스트림을 '이동 요청', '채팅 메시지' 등과 같은 구체적인 게임 액션으로 변환하고, 해당 요청을 보낸 플레이어의 PlayerAgent
에게 전달하는 파이프라인을 구축하는 것입니다.
핵심 요구사항:
- 바이너리 직렬화: 클라이언트와 서버 간에 데이터를 주고받기 위한 DTO(Data Transfer Object) 클래스들을 정의합니다. 성능을 위해 JSON이 아닌,
MemoryPack
이나MessagePack
과 같은 고성DEN 성능 바이너리 직렬화 라이브러리를 사용해야 합니다. - 패킷 핸들러 등록 및 관리: 패킷 ID를 키(Key)로, 해당 패킷을 처리하는 로직(핸들러)을 값(Value)으로 가지는 중앙 관리 시스템을 구현해야 합니다.
- 세션과 플레이어 에이전트 연동: 클라이언트가 접속하여 인증을 통과하면, 해당 네트워크 세션(
ClientSession
)은 특정PlayerId
와 연결되어야 합니다. - 패킷 디스패치:
ClientSession
이 완전한 패킷을 수신하면, 패킷 ID에 맞는 핸들러를 찾아 실행해야 합니다. 이 핸들러는 패킷 데이터를 역직렬화하고, 세션에 연결된PlayerAgent
를 찾아 적절한IPlayerAction
을 메일박스에 넣어주어야 합니다.
샘플 솔루션 (C# 12 / .NET 8)
이 솔루션은 지난주 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
과 같은 고성능 직렬화 라이브러리를 사용하여 네트워크 효율성을 극대화하는 방법을 학습합니다. - 프로토콜 설계: 패킷의 구조(헤더, 페이로드)를 정의하고, 클라이언트-서버 간의 통신 규약을 설계하는 방법을 익힙니다.
- 디스패치 패턴: 패킷 ID를 기반으로 적절한 처리 로직을 동적으로 연결해주는 효율적인 디스패치 시스템을 구현하는 방법을 배웁니다.
- 계층 간 연동: 지금까지 분리되어 있던 여러 시스템(네트워킹, 상태 관리, 영속성)을 하나로 통합하여 완전한 애플리케이션 파이프라인을 구축하는 경험을 합니다.
- 버퍼링 및 파싱: TCP 스트림의 특성을 이해하고, 조각나거나 합쳐져서 도착하는 데이터를 안정적으로 파싱하여 완전한 메시지로 조립하는 방법을 학습합니다. (샘플에서는 간단한
RingBuffer
를 가정)
솔루션 품질 평가 기준
- 성능: 직렬화/역직렬화 과정이 최소한의 오버헤드와 메모리 할당으로 빠르게 수행되는가?
- 설계: 각 계층(네트워크, 디스패치, 로직)의 책임이 명확하게 분리되어 있는가? 새로운 패킷 타입을 추가하는 과정이 간단하고 직관적인가?
- 안정성: 비정상적이거나 변조된 패킷(예: 정의되지 않은 ID, 잘못된 길이 값)을 수신했을 때 서버가 비정상 종료되지 않고 안정적으로 대처하는가?
- 정확성: 클라이언트로부터 온 요청이 정확히 의도한 플레이어의
PlayerAgent
에게 전달되어 처리되는가?
보너스 목표
현재 핸들러 시스템은 간단하지만, 기능이 많아지면 공통적으로 처리해야 할 로직(인증 체크, 로깅 등)이 중복될 수 있습니다. 이를 개선해 보세요.
과제: 패킷 핸들러에 '미들웨어(Middleware)' 파이프라인 패턴 도입
요구사항:
- 패킷 핸들러가 실행되기 전에 먼저 실행될 수 있는 '미들웨어' 개념을 도입합니다.
- 예를 들어,
[AuthRequired]
와 같은 어트리뷰트(Attribute)를 DTO 클래스나 핸들러 메서드에 붙일 수 있도록 설계합니다. - 패킷 디스패처는 핸들러를 실행하기 전에, 이 어트리뷰트가 있는지 확인하고, 만약 있다면 해당 세션이
Authenticate
되었는지 먼저 검사합니다. 인증되지 않은 세션이라면 핸들러를 실행하지 않고 연결을 끊는 등의 처리를 합니다. - 이를 확장하여 로깅 미들웨어, 유효성 검사 미들웨어 등을 추가할 수 있는 유연한 구조를 만들어 보세요. (ASP.NET Core의 미들웨어 파이프라인과 유사한 개념입니다.)
이번 챌린지를 통해 드디어 여러분의 서버가 클라이언트와 '대화'를 시작하게 됩니다. 서버 개발의 꽃이라 할 수 있는 이 과정을 즐겨보시기 바랍니다.
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