상세 컨텐츠

본문 제목

5.주간 C# 서버 챌린지: MMORPG 서버 개발의 핵심, C# 비동기 소켓 프로그래밍

카테고리 없음

by Jiung. 2025. 10. 2. 14:18

본문

반응형

 

데이터베이스 연동까지 성공적으로 마치셨으니, 이제는 서버의 '입구'를 만드는 작업에 도전할 시간입니다. 지금까지의 모든 로직은 결국 외부 클라이언트의 요청을 받아 처리하기 위한 것이었죠.

 

이번 주에는 C#의 저수준(Low-level) 네트워킹 API를 사용하여 수천 개의 동시 접속을 처리할 수 있는 고성능 TCP 소켓 서버의 뼈대를 직접 만들어 보겠습니다.


주간 C# 서버 챌린지: TCP 소켓 서버 구축 완벽 가이드

안녕하세요! 스튜디오 비를긋다 입니다. 이번 챌린지의 주제는 '고성능 비동기 TCP 소켓 서버 구축' 입니다. .NET이 제공하는 가장 강력하고 효율적인 소켓 프로그래밍 모델인 SocketAsyncEventArgs(SAEA) 패턴을 사용하여, MMORPG 서버의 기본이 되는 네트워크 리스너를 구현합니다. 이는 서버 개발의 가장 근본적인 기술 중 하나입니다.

C#으로 수천 개의 동시 접속을 처리하는 고성능 비동기 TCP 서버를 구축하는 방법을 배우세요. SocketAsyncEventArgs(SAEA) 패턴, 객체 풀링, 세션 관리까지, MMORPG 서버 개발의 핵심 기술을 예제 코드와 함께 완벽히 마스터할 수 있습니다.

문제: SAEA 패턴을 이용한 확장 가능한 TCP 서버 프레임워크 구현

상황 시나리오:

'아르카디아의 그림자'의 아키텍처를 여러 전문 서버(월드 서버, 채팅 서버, 경매장 서버 등)로 분리하는 대규모 프로젝트가 시작되었습니다. 이 분산 환경의 기반이 될 공통 네트워크 엔진이 필요하게 되었습니다. 당신의 임무는 새로운 서버들이 상속하거나 사용할 수 있는, 수천 개의 클라이언트 연결을 동시에 효율적으로 관리하고 최소한의 메모리 할당으로 데이터를 주고받는 재사용 가능한 TCP 서버 모듈을 만드는 것입니다.

핵심 요구사항:

  1. 비동기 접속 처리: 서버는 여러 클라이언트의 접속 요청을 비동기적으로 동시에 수락해야 합니다.
  2. SAEA 패턴 적용: 데이터 수신 및 발신 작업에 SocketAsyncEventArgs 패턴을 사용하여 성능을 극대화해야 합니다. 이 패턴은 각 I/O 작업에 대한 메모리 할당을 최소화하여 가비지 컬렉션(GC)으로 인한 서버 지연(stutter)을 방지합니다.
  3. 객체 풀링(Object Pooling): SocketAsyncEventArgs 객체는 비싸므로, 재사용을 위해 객체 풀을 구현해야 합니다.
  4. 세션 관리: 각 클라이언트 연결은 고유한 Session 객체로 캡슐화되어야 하며, 서버는 모든 활성 세션을 추적하고 관리해야 합니다. 클라이언트 연결이 끊어지면 관련 리소스가 즉시 정리되어야 합니다.

샘플 솔루션 (C# 12 / .NET 8)

이 솔루션은 C# 소켓 프로그래밍의 '끝판왕'이라 불리는 SAEA 패턴의 정석적인 구현을 보여줍니다. 복잡하지만 최고의 성능을 보장합니다.

using System.Net;
using System.Net.Sockets;
using System.Collections.Concurrent;

// 각 클라이언트 연결을 나타내는 세션 클래스입니다.
public class ClientSession
{
    public Socket Socket { get; }
    private readonly Action<ClientSession> _onDisconnected;

    public ClientSession(Socket socket, Action<ClientSession> onDisconnected)
    {
        Socket = socket;
        _onDisconnected = onDisconnected;
    }

    // 데이터 수신을 시작하는 메서드. SAEA 객체를 풀에서 빌려와 비동기 수신을 시작합니다.
    public void StartReceive(SocketAsyncEventArgs receiveArgs)
    {
        receiveArgs.Completed += OnReceiveCompleted; // IO 완료 시 호출될 콜백 등록
        if (!Socket.ReceiveAsync(receiveArgs))
        {
            // 동기적으로 완료된 경우, 즉시 콜백을 호출합니다.
            OnReceiveCompleted(this, receiveArgs);
        }
    }

    // 비동기 데이터 수신이 완료되었을 때 호출되는 콜백 메서드입니다.
    private void OnReceiveCompleted(object? sender, SocketAsyncEventArgs e)
    {
        // BytesTransferred: 수신된 데이터의 바이트 수, SocketError: 소켓 오류 상태
        if (e.BytesTransferred > 0 && e.SocketError == SocketError.Success)
        {
            // TODO: 수신된 데이터 처리 로직 (e.Buffer에서 e.Offset부터 e.BytesTransferred 만큼)
            // 예: Console.WriteLine($"Received {e.BytesTransferred} bytes from {Socket.RemoteEndPoint}");

            // 처리가 끝난 후, 다음 데이터 수신을 위해 다시 ReceiveAsync를 호출합니다.
            if (!Socket.ReceiveAsync(e))
            {
                OnReceiveCompleted(this, e);
            }
        }
        else
        {
            // 수신된 바이트가 0이거나 오류가 발생하면 연결이 끊어진 것으로 간주합니다.
            Disconnect();
        }
    }

    public void Disconnect()
    {
        _onDisconnected(this); // 서버에 연결 끊김을 알립니다.
        Socket.Shutdown(SocketShutdown.Both);
        Socket.Close();
    }
}

// 고성능 TCP 서버의 메인 클래스입니다.
public class GameServer
{
    private readonly Socket _listenSocket;
    private readonly ConcurrentStack<SocketAsyncEventArgs> _receiveArgsPool;
    private readonly ConcurrentDictionary<int, ClientSession> _sessions = new();
    private int _sessionIdCounter = 0;

    public GameServer(int port, int backlog, int poolSize)
    {
        _listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        _listenSocket.Bind(new IPEndPoint(IPAddress.Any, port));
        _listenSocket.Listen(backlog);

        // SAEA 객체를 미리 생성하여 풀에 넣어둡니다.
        _receiveArgsPool = new ConcurrentStack<SocketAsyncEventArgs>();
        for (int i = 0; i < poolSize; i++)
        {
            var args = new SocketAsyncEventArgs();
            // 각 SAEA 객체에 1KB 버퍼를 할당합니다.
            args.SetBuffer(new byte[1024], 0, 1024);
            _receiveArgsPool.Push(args);
        }
    }

    public void Start()
    {
        Console.WriteLine("Server started. Waiting for connections...");
        // 클라이언트 접속을 받기 위한 별도의 SAEA 객체와 비동기 루프를 시작합니다.
        var acceptArgs = new SocketAsyncEventArgs();
        acceptArgs.Completed += OnAcceptCompleted;
        StartAccept(acceptArgs);
    }

    private void StartAccept(SocketAsyncEventArgs acceptArgs)
    {
        acceptArgs.AcceptSocket = null; // 재사용을 위해 이전 소켓을 비웁니다.
        if (!_listenSocket.AcceptAsync(acceptArgs))
        {
            // 동기적으로 완료되면 즉시 콜백을 호출합니다.
            OnAcceptCompleted(this, acceptArgs);
        }
    }

    private void OnAcceptCompleted(object? sender, SocketAsyncEventArgs e)
    {
        if (e.SocketError == SocketError.Success)
        {
            // 클라이언트 접속 성공
            var clientSocket = e.AcceptSocket!;
            Console.WriteLine($"Client connected: {clientSocket.RemoteEndPoint}");

            // 새로운 세션을 생성하고, 수신 풀에서 SAEA 객체를 하나 빌려옵니다.
            var session = new ClientSession(clientSocket, OnSessionDisconnected);
            if (_receiveArgsPool.TryPop(out var receiveArgs))
            {
                // UserToken을 사용하여 SAEA 객체와 세션을 연결합니다.
                receiveArgs.UserToken = session;
                session.StartReceive(receiveArgs);
            }
            else
            {
                // 풀이 비어있으면 연결을 거부합니다 (서버 과부하 상태).
                Console.WriteLine("Connection refused: SAEA pool is empty.");
                clientSocket.Close();
            }
        }
        else
        {
            // 접속 오류 처리
            Console.WriteLine($"Accept error: {e.SocketError}");
        }

        // 다음 클라이언트 접속을 받기 위해 다시 AcceptAsync를 호출합니다.
        StartAccept(e);
    }

    // 세션 연결이 끊겼을 때 호출되는 콜백 메서드입니다.
    private void OnSessionDisconnected(ClientSession session)
    {
        Console.WriteLine($"Client disconnected: {session.Socket.RemoteEndPoint}");
        // 세션이 사용하던 SAEA 객체를 다시 풀에 반환해야 합니다.
        // (이 예제에서는 간단하게 하기 위해 생략. 실제로는 receiveArgs를 찾아 반납해야 함)
    }
}

실용적인 사용 시나리오

GameServer 클래스는 모든 TCP 기반 서버의 출발점입니다.

  • 로그인 서버: 클라이언트가 게임에 처음 접속할 때 이 서버에 연결하여 계정 인증을 받습니다.
  • 채팅 서버: 모든 플레이어의 채팅 메시지가 이 서버를 통해 중계됩니다.
  • 게임 월드/존 서버: 플레이어의 위치, 전투, 상호작용 등 실시간 게임 플레이 데이터가 이 서버의 TCP 연결을 통해 끊임없이 오고 갑니다.

SocketAsyncEventArgs 패턴의 핵심은 메모리 재사용입니다. new byte[1024]new SocketAsyncEventArgs() 같은 객체 생성을 서버 시작 시점에 '한 번만' 대량으로 해놓고, 이후 모든 네트워크 작업에서 이 객체들을 '빌려 쓰고 반납'하는 방식입니다. 이를 통해 서버 실행 중 GC의 부담을 극적으로 줄여, 게임 서버에서 치명적인 '순간 렉' 현상을 예방할 수 있습니다.


학습 목표

  • 저수준 소켓 프로그래밍: System.Net.Sockets 네임스페이스의 핵심 클래스들을 직접 다루는 방법을 학습합니다.
  • SocketAsyncEventArgs(SAEA) 패턴: .NET에서 가장 높은 성능을 내는 비동기 소켓 I/O 모델의 구조와 동작 방식을 깊이 이해합니다.
  • 객체 풀링(Object Pooling): 고성능 서버 환경에서 GC 압력을 줄이기 위한 필수적인 메모리 관리 기법을 구현하는 방법을 익힙니다.
  • 비동기 콜백 기반 프로그래밍: Completed 이벤트 핸들러를 사용하여 비동기 작업의 완료를 처리하는 프로그래밍 모델에 익숙해집니다.
  • 세션 관리 아키텍처: 다수의 클라이언트 상태를 관리하고 그들의 생명주기(Lifecycle)를 추적하는 방법을 설계합니다.

솔루션 품질 평가 기준

  • 성능: 대량의 클라이언트가 동시에 접속하고 데이터를 보낼 때, 서버의 CPU와 메모리 사용량이 안정적으로 유지되는가? GC 발생 빈도가 낮은가?
  • 자원 관리: 클라이언트 연결이 끊어졌을 때, SocketSocketAsyncEventArgs 객체가 누수 없이 정확하게 정리되고 풀에 반환되는가?
  • 견고성: 비정상적인 연결 종료나 네트워크 오류 발생 시 서버 전체가 다운되지 않고 해당 세션만 깔끔하게 정리되는가?
  • 설계: 서버, 세션, SAEA 풀의 역할과 책임이 명확하게 분리되어 있는가? 코드가 논리적으로 잘 구성되어 있는가?

보너스 목표

현재 샘플 코드는 단순히 바이트 스트림을 받을 뿐, 의미 있는 단위의 '메시지'나 '패킷'을 처리하지 못합니다. 실제 게임 서버가 되기 위한 다음 단계를 구현해 보세요.

과제: 패킷 기반 메시지 프레이밍(Framing) 계층 구현

요구사항:

  1. 간단한 바이너리 패킷 프로토콜을 정의합니다. 예: [전체 패킷 길이(2바이트 정수)] + [패킷 ID(2바이트 정수)] + [실제 데이터(가변 길이)]
  2. ClientSession의 데이터 수신 로직을 수정하여, TCP 스트림에서 이 패킷 구조에 따라 완전한 메시지를 조립(Assembly)하는 기능을 추가합니다.
    • 한 번의 Receive로 여러 패킷이 도착하는 경우, 패킷 일부만 도착하는 경우 모두 처리할 수 있어야 합니다. 이를 위해 각 세션은 자신만의 수신 버퍼를 가지고 있어야 합니다.
  3. 완전한 패킷이 하나 조립될 때마다, 해당 패킷의 ID와 데이터를 분석하여 적절한 핸들러에게 전달하는 디스패치(Dispatch) 로직을 구현합니다.

이 보너스 과제는 저수준 TCP 통신과 실제 게임 로직을 연결하는 '브릿지' 역할을 하는 매우 중요한 부분입니다.


이번 주는 다소 난이도가 높지만, 이 챌린지를 성공적으로 완수한다면 어떤 고성능 서버도 만들 수 있는 단단한 기초를 갖추게 될 것입니다!

 

 

SocketAsyncEventArgs 클래스 (System.Net.Sockets)

비동기 소켓 작업을 나타냅니다.

learn.microsoft.com

 

반응형