상세 컨텐츠

본문 제목

6.주간 C# 서버 챌린지: MemoryPack으로 패킷 처리 시스템 구축하기

개발 & 프로그래밍/C# 서버 챌린지

by Jiung. 2025. 10. 11. 00:53

본문

반응형

지난주 고성능 TCP 서버의 기초를 다지셨습니다. 이번 주에는 그 위에 실질적인 '살'을 붙이는 작업을 진행하겠습니다. 클라이언트로부터 받은 순수한 바이트(Byte) 덩어리를 어떻게 의미 있는 '명령'으로 해석하고, 지난 5주차에 만들었던 PlayerAgent에게 정확히 전달할 수 있을까요? 그 연결고리를 만들어 보겠습니다.


주간 C# 서버 챌린지: 실전 패킷 처리 및 핸들러 디스패치

안녕하세요! 스튜디오 비를긋다입니다. 이번 챌린지의 주제는 '패킷 프로토콜 처리 및 핸들러 디스패치 시스템 구현' 입니다. 지난주에 구축한 TCP 서버 위에서, 클라이언트와 서버가 약속된 형식(프로토콜)에 따라 데이터를 주고받고, 수신된 패킷의 종류에 따라 적절한 로직을 실행하도록 연결하는, 서버의 중추 신경계를 만드는 과정입니다.

 

C#으로 고성능 TCP 서버를 개발 중이신가요? 순수한 바이트 데이터를 의미 있는 게임 명령으로 변환하는 패킷 처리 및 핸들러 디스패치 시스템 구축 방법을 MemoryPack 예제 코드로 자세히 알아보세요.

문제: 네트워크 계층과 애플리케이션 계층 연결

상황 시나리오:

'아르카디아의 그림자'의 새로운 네트워크 엔진(GameServer)은 이제 클라이언트 접속을 받고 바이트 스트림을 수신할 수 있게 되었습니다. 하지만 이 데이터는 아직 아무 의미 없는 0과 1의 나열일 뿐입니다. 당신의 임무는 이 바이트 스트림을 '이동 요청', '채팅 메시지' 등과 같은 구체적인 게임 액션으로 변환하고, 해당 요청을 보낸 플레이어의 PlayerAgent에게 전달하는 파이프라인을 구축하는 것입니다.

 

핵심 요구사항:

  1. 바이너리 직렬화: 클라이언트와 서버 간에 데이터를 주고받기 위한 DTO(Data Transfer Object) 클래스들을 정의합니다. 성능을 위해 JSON이 아닌, MemoryPack이나 MessagePack과 같은 고성DEN 성능 바이너리 직렬화 라이브러리를 사용해야 합니다.
  2. 패킷 핸들러 등록 및 관리: 패킷 ID를 키(Key)로, 해당 패킷을 처리하는 로직(핸들러)을 값(Value)으로 가지는 중앙 관리 시스템을 구현해야 합니다.
  3. 세션과 플레이어 에이전트 연동: 클라이언트가 접속하여 인증을 통과하면, 해당 네트워크 세션(ClientSession)은 특정 PlayerId와 연결되어야 합니다.
  4. 패킷 디스패치: 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)'와 같습니다.

  1. 클라이언트 접속: 플레이어가 게임을 실행하면 GameServer에 TCP 연결을 맺습니다.
  2. 로그인: 클라이언트는 PlayerLoginRequest DTO를 직렬화하여 서버로 전송합니다. 서버의 ClientSession은 이 바이트 스트림을 받아 패킷을 조립하고, PacketId.C_PlayerLogin을 확인하여 로그인 핸들러를 호출합니다. 핸들러는 토큰을 검증하고 session.Authenticate(playerId)를 호출하여 세션을 '인증된 상태'로 만듭니다. 동시에 PlayerManager를 통해 해당 PlayerAgent를 메모리에 로딩시킵니다.
  3. 게임 플레이: 이제 플레이어가 게임 월드에서 움직이면, 클라이언트는 PlayerMoveRequest 패킷을 계속해서 서버로 보냅니다. 서버는 이동 핸들러를 통해 이 요청을 MoveAction으로 변환하고, 해당 PlayerAgent의 메일박스로 전달합니다. PlayerAgent는 자신의 처리 루프에서 이 MoveAction을 순차적으로 처리하여 위치를 업데이트합니다.

이처럼 **네트워크 계층(ClientSession)**은 바이트 처리와 패킷 조립만 담당하고, **디스패치 계층(PacketHandler)**은 패킷 라우팅만, **애플리케이션 계층(PlayerAgent)**은 실제 게임 로직만 처리하도록 역할이 명확하게 분리되어 매우 깔끔하고 확장 가능한 구조가 됩니다.


학습 목표

  • 바이너리 직렬화: MemoryPack과 같은 고성능 직렬화 라이브러리를 사용하여 네트워크 효율성을 극대화하는 방법을 학습합니다.
  • 프로토콜 설계: 패킷의 구조(헤더, 페이로드)를 정의하고, 클라이언트-서버 간의 통신 규약을 설계하는 방법을 익힙니다.
  • 디스패치 패턴: 패킷 ID를 기반으로 적절한 처리 로직을 동적으로 연결해주는 효율적인 디스패치 시스템을 구현하는 방법을 배웁니다.
  • 계층 간 연동: 지금까지 분리되어 있던 여러 시스템(네트워킹, 상태 관리, 영속성)을 하나로 통합하여 완전한 애플리케이션 파이프라인을 구축하는 경험을 합니다.
  • 버퍼링 및 파싱: TCP 스트림의 특성을 이해하고, 조각나거나 합쳐져서 도착하는 데이터를 안정적으로 파싱하여 완전한 메시지로 조립하는 방법을 학습합니다. (샘플에서는 간단한 RingBuffer를 가정)

솔루션 품질 평가 기준

  • 성능: 직렬화/역직렬화 과정이 최소한의 오버헤드와 메모리 할당으로 빠르게 수행되는가?
  • 설계: 각 계층(네트워크, 디스패치, 로직)의 책임이 명확하게 분리되어 있는가? 새로운 패킷 타입을 추가하는 과정이 간단하고 직관적인가?
  • 안정성: 비정상적이거나 변조된 패킷(예: 정의되지 않은 ID, 잘못된 길이 값)을 수신했을 때 서버가 비정상 종료되지 않고 안정적으로 대처하는가?
  • 정확성: 클라이언트로부터 온 요청이 정확히 의도한 플레이어의 PlayerAgent에게 전달되어 처리되는가?

보너스 목표

현재 핸들러 시스템은 간단하지만, 기능이 많아지면 공통적으로 처리해야 할 로직(인증 체크, 로깅 등)이 중복될 수 있습니다. 이를 개선해 보세요.

 

과제: 패킷 핸들러에 '미들웨어(Middleware)' 파이프라인 패턴 도입

요구사항:

  1. 패킷 핸들러가 실행되기 에 먼저 실행될 수 있는 '미들웨어' 개념을 도입합니다.
  2. 예를 들어, [AuthRequired]와 같은 어트리뷰트(Attribute)를 DTO 클래스나 핸들러 메서드에 붙일 수 있도록 설계합니다.
  3. 패킷 디스패처는 핸들러를 실행하기 전에, 이 어트리뷰트가 있는지 확인하고, 만약 있다면 해당 세션이 Authenticate 되었는지 먼저 검사합니다. 인증되지 않은 세션이라면 핸들러를 실행하지 않고 연결을 끊는 등의 처리를 합니다.
  4. 이를 확장하여 로깅 미들웨어, 유효성 검사 미들웨어 등을 추가할 수 있는 유연한 구조를 만들어 보세요. (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

 

반응형

관련글 더보기