수업


✅ 주제

C# 기반 MMO 서버 아키텍처에서 기초적인 채팅 시스템을 설계하고 구현하는 과정을 다룹니다.
단순한 메시지 송수신을 넘어서, 다수의 클라이언트가 동시에 접속하고, 채팅 패킷을 주고받으며, 서버가 이를 관리하는 구조까지 확장합니다.


📌 구현 목표

  • 서버 코어의 안정성 개선 및 소켓 예외 처리
  • 클라이언트 접속 시 자동으로 채팅방 입장 처리
  • 클라이언트와 서버 간 채팅 패킷 설계 (PDL 기반)
  • GameRoom을 통한 유저의 입장, 퇴장, 메시지 전송 처리
  • SessionManager 도입을 통한 세션 생성/관리 구조 설계
  • PacketSession 기반 통신 구조 도입 및 PacketHandler 분기 처리

📌 클라이언트 확장 및 테스트 목표

  • DummyClient에서 다수의 ServerSession을 생성해 서버에 동시 접속
  • SessionManager를 통한 클라이언트 세션 관리
  • Connector 클래스 개선으로 다중 접속 구현
  • SendForEach()로 클라이언트 전체에서 동일 패킷 전송 테스트 수행

📌 구조적 문제 발견 및 개선 방향

  • 많은 수의 클라이언트가 주기적으로 메시지를 전송할 때
    서버 Broadcast() 내부의 lock 처리로 인한 병목 현상 발생
  • 구조적으로 O(n²)에 가까운 성능 저하 발생 확인
  • 해결 방향으로 JobQueue 도입 필요성 제시:
    즉시 처리 대신 작업 큐에 등록 후 단일 스레드 처리 방식으로 리팩토링

📚 개념

📌 채팅 시스템의 핵심 구성 요소

  1. Session / ClientSession / ServerSession

    • Session은 서버와 연결된 클라이언트를 나타내는 객체입니다.
    • 서버 측에서는 ClientSession, 클라이언트 측에서는 ServerSession으로 구분되어 각각의 역할을 수행합니다.
    • 클라이언트는 PacketSession을 상속받아 OnRecvPacket() 메서드를 통해 패킷을 수신합니다.
  2. SessionManager

    • 서버와 클라이언트 모두에 존재하며, 연결된 세션을 생성·관리·제거합니다.
    • 싱글톤 패턴을 적용하여 전역에서 접근할 수 있고, ID 발급 또는 다중 세션 관리가 가능합니다.
  3. GameRoom

    • 다수의 세션이 입장하는 공간이며, 채팅 메시지를 같은 방에 있는 유저들에게 전파하는 역할을 합니다.
    • 입장(Enter), 퇴장(Leave), 메시지 전송(Broadcast)의 기능을 제공하며, 내부 공유 리스트는 lock을 통해 멀티스레드 환경에서도 안전하게 보호합니다.
  4. PDL (Packet Definition Language)

    • XML 기반으로, 클라이언트와 서버가 주고받는 패킷 구조를 선언적으로 정의합니다.
    • 예: C_Chat, S_Chat 등. 이를 바탕으로 자동 코드 생성(GenPackets.bat)이 가능합니다.

📌 네트워크 처리의 구조와 흐름

  1. PacketManager & 패킷 핸들러

    • 수신된 패킷을 적절한 핸들러로 분기시켜 동작을 수행합니다.
    • PacketManager.Instance.OnRecvPacket()을 통해 분기되며, 패킷 종류별로 메서드가 지정됩니다.
  2. Connector

    • 클라이언트에서 서버에 연결을 시도하는 모듈입니다.
    • count 매개변수를 통해 수십~수백 개의 클라이언트를 동시에 생성하여 부하 테스트가 가능합니다.
  3. DummyClient

    • 테스트용 클라이언트 애플리케이션이며, 내부적으로 SessionManager, Connector, ServerSession을 이용해 실제 MMO 환경의 시뮬레이션을 수행합니다.
  4. SendForEach vs Broadcast

    • SendForEach()는 클라이언트 측에서 모든 세션을 순회하며 서버로 패킷을 전송하는 함수입니다.
    • Broadcast()는 서버 측에서 받은 메시지를 같은 GameRoom에 있는 모든 세션에게 전파하는 구조입니다.

📌 성능 이슈와 개선 방향

  1. 멀티스레드 환경의 위험성과 lock 병목

    • 패킷 수신 시 곧바로 Broadcast()를 실행하면, 다수의 스레드가 동시에 lock을 요청하면서 병목이 발생합니다.
    • 특히 유저 수가 많을수록 이 문제는 심각해지며, 시스템이 마비될 수 있습니다.
  2. O(n²) 복잡도 구조

  • 예를 들어 100명의 유저가 0.25초에 한 번씩 메시지를 보낸다면, 1초에 40,000번의 Broadcast가 일어날 수 있습니다.
  • 이 구조는 사용자 수에 따라 기하급수적인 처리량 증가를 야기합니다.
  1. JobQueue 도입의 필요성
  • 해결 방법은 모든 처리를 즉시 실행하지 않고, 패킷 수신 후 "작업(Job)"으로 감싸 큐에 등록하는 것입니다.
  • 단일 스레드가 해당 Job을 순차적으로 처리함으로써 lock 충돌 없이 성능을 안정화할 수 있습니다.

🧩 용어 정리

🔹 네트워크 및 세션 관련

용어설명
Session클라이언트와 서버 간의 연결을 추상화한 객체. 데이터 송수신의 기본 단위
ClientSession / ServerSession서버 측에서 연결된 클라이언트를 나타내는 객체(ClientSession) / 클라이언트 측에서 서버 연결을 나타내는 객체(ServerSession)
PacketSession패킷 송수신 기능을 가진 세션의 기반 클래스
GameRoom세션(Client)들이 입장하는 채팅 방. 메시지를 관리하고 브로드캐스트 처리
SessionManager모든 세션의 생성, 저장, 조회, 제거 기능을 담당하는 싱글톤 관리자 클래스
Connector클라이언트에서 서버로의 연결을 처리하는 모듈. 다수의 접속도 가능

🔹 패킷 구조 및 처리 관련

용어설명
Packet서버와 클라이언트 간 송수신되는 데이터의 기본 단위
PDL (Packet Definition Language)XML 기반의 패킷 정의 방식. 패킷 구조를 선언적으로 정의
GenPackets.batPDL 기반의 패킷 정의를 토대로 자동으로 패킷 코드 생성하는 빌드 스크립트
PacketManager수신된 패킷을 분석하고 해당 핸들러에 분기하는 관리자 클래스
Packet Handler특정 패킷에 대한 동작을 구현한 함수 (예: S_ChatHandler)

🔹 채팅 전송 및 테스트 관련

용어설명
Broadcast서버가 같은 GameRoom에 있는 모든 세션에게 패킷을 전파하는 방식
SendForEach클라이언트가 연결된 모든 세션에게 동일한 패킷을 전송하는 함수
DummyClient클라이언트 기능을 테스트하기 위해 만들어진 테스트 클라이언트 프로그램

🔹 동기화 및 성능 관련

용어설명
lock()C#에서 공유 자원에 대한 동기화를 위해 사용하는 기본 동기화 메커니즘
Interlocked.Exchange멀티스레드 환경에서 안전하게 값을 변경하는 원자적 연산 함수
_socket.Shutdown / Close소켓의 송수신을 종료하거나 연결을 닫는 함수
ArraySegment<byte>byte 배열의 일부를 참조하는 객체로, 네트워크 전송 시 메모리 효율성을 위해 사용됨
JobQueue멀티스레드 환경에서 작업을 큐에 쌓아두고, 하나의 스레드에서 순차적으로 처리하는 구조
O(n²) 복잡도유저 수가 증가할수록 처리량이 기하급수적으로 증가하는 문제 구조. Broadcast 시 발생

🔍 코드 분석


🔹 1. 서버 안정성 강화 – Disconnect, Send/Recv 보호 처리

Disconnect 처리

public void Disconnect()
{
    if (Interlocked.Exchange(ref _disconnect, 1) == 1)
        return;

    OnDisconnected(_socket.RemoteEndPoint);
    _socket.Shutdown(SocketShutdown.Both);
    _socket.Close();
    Clear();
}
  • Interlocked.Exchange()는 멀티스레드 환경에서 중복 Disconnect 방지를 위한 원자적 연산.
  • _disconnect == 1인 경우 이미 종료된 상태이므로 return.
  • Clear() 호출로 _sendQueue, _pendingList 내부 버퍼도 안전하게 정리.

Clear()

void Clear()
{
    lock (_lock)
    {
        _sendQueue.Clear();
        _pendingList.Clear();
    }
}

🔹 2. RegisterSend / RegisterRecv 예외처리

RegisterSend

void RegisterSend()
{
    if (_disconnect == 1)
        return;

    while (_sendQueue.Count > 0)
    {
        ArraySegment<byte> buff = _sendQueue.Dequeue();
        _pendingList.Add(buff);
    }

    _sendArgs.BufferList = _pendingList;

    try
    {
        bool pending = _socket.SendAsync(_sendArgs);
        if (pending == false)
            OnSendCompleted(null, _sendArgs);
    }
    catch (Exception e)
    {
        Console.WriteLine($"RegisterSend Failed {e}");
    }
}
  • _disconnect 체크로 종료된 소켓 보호.
  • 예외 발생 시 서버 전체 크래시 방지.
  • _pendingList에 누적된 버퍼를 한 번에 전송.

RegisterRecv

void RegisterRecv()
{
    if (_disconnect == 1)
        return;

    _recvBuffer.Clean();
    ArraySegment<byte> segment = _recvBuffer.WriteSegment;
    _recvArgs.SetBuffer(segment.Array, segment.Offset, segment.Count);

    try
    {
        bool pending = _socket.ReceiveAsync(_recvArgs);
        if (pending == false)
            OnRecvCompleted(null, _recvArgs);
    }
    catch (Exception e)
    {
        Console.WriteLine($"RegisterRecv Fail {e}");
    }
}

🔹 3. PDL 패킷 정의 (C_Chat / S_Chat)

<packet name="C_Chat">
  <string name="chat"/>
</packet>
<packet name="S_Chat">
  <int name="playerId"/>
  <string name="chat"/>
</packet>
  • C_Chat: 클라이언트 → 서버
  • S_Chat: 서버 → 클라이언트 (Broadcast)

🔹 4. GameRoom 클래스

class GameRoom
{
    List<ClientSession> _sessions = new List<ClientSession>();
    object _lock = new object();

    public void Enter(ClientSession session)
    {
        lock (_lock)
        {
            _sessions.Add(session);
            session.Room = this;
        }
    }

    public void Leave(ClientSession session)
    {
        lock (_lock)
        {
            _sessions.Remove(session);
        }
    }

    public void Broadcast(ClientSession session, string chat)
    {
        S_Chat packet = new S_Chat();
        packet.playerId = session.SessionId;
        packet.chat = $"{chat} I am {packet.playerId}";
        ArraySegment<byte> segment = packet.Write();

        lock (_lock)
        {
            foreach (ClientSession s in _sessions)
                s.Send(segment);
        }
    }
}
  • 멀티스레드 보호를 위해 모든 연산에 lock (_lock) 적용.
  • Broadcast는 방 안의 모든 세션에게 메시지 전송.

🔹 5. ClientSession 구현

public int SessionId { get; set; }
public GameRoom Room { get; set; }

public override void OnConnected(EndPoint endPoint)
{
    Console.WriteLine($"OnConnected : {endPoint}");
    Program.Room.Enter(this);
}

public override void OnDisconnected(EndPoint endPoint)
{
    SessionManager.Instance.Remove(this);

    if (Room != null)
    {
        Room.Leave(this);
        Room = null;
    }

    Console.WriteLine($"OnDisconnected : {endPoint}");
}
  • 접속 시 자동 입장, 해제 시 자동 퇴장.
  • SessionManager를 통해 전체 클라이언트 목록에서도 제거.

🔹 6. SessionManager (서버)

class SessionManager
{
    static SessionManager _session = new SessionManager();
    public static SessionManager Instance => _session;

    Dictionary<int, ClientSession> _sessions = new();
    int _sessionId = 0;
    object _lock = new object();

    public ClientSession Generate()
    {
        lock (_lock)
        {
            int sessionId = ++_sessionId;
            ClientSession session = new ClientSession();
            session.SessionId = sessionId;
            _sessions.Add(sessionId, session);
            Console.WriteLine($"Connected : {sessionId}");
            return session;
        }
    }

    public void Remove(ClientSession session)
    {
        lock (_lock)
        {
            _sessions.Remove(session.SessionId);
        }
    }

    public ClientSession Find(int id)
    {
        lock (_lock)
        {
            _sessions.TryGetValue(id, out var session);
            return session;
        }
    }
}
  • 세션의 생성, 제거, 조회를 제공하는 핵심 관리자.

🔹 7. PacketHandler (서버)

public static void C_ChatHandler(PacketSession session, IPacket packet)
{
    C_Chat chatPacket = packet as C_Chat;
    ClientSession clientSession = session as ClientSession;

    if (clientSession.Room == null)
        return;

    clientSession.Room.Broadcast(clientSession, chatPacket.chat);
}
  • 클라이언트가 채팅을 전송했을 때 호출됨.
  • Room을 통해 Broadcast 처리.

🔹 8. ServerSession (클라이언트)

class ServerSession : PacketSession
{
    public override void OnConnected(EndPoint end) => Console.WriteLine($"OnConnected : {end}");
    public override void OnDisconnected(EndPoint end) => Console.WriteLine($"OnDisconnected : {end}");

    public override void OnRecvPacket(ArraySegment<byte> buffer)
        => PacketManager.Instance.OnRecvPacket(this, buffer);

    public override void OnSend(int numOfBytes) { }
}
  • 클라이언트 측 세션 객체. 수신 시 패킷 분기 처리.

🔹 9. DummyClient의 SessionManager

class SessionManager
{
    static SessionManager _session = new SessionManager();
    public static SessionManager Instance => _session;

    List<ServerSession> _sessions = new List<ServerSession>();
    object _lock = new object();

    public ServerSession Generate()
    {
        lock (_lock)
        {
            ServerSession session = new ServerSession();
            _sessions.Add(session);
            return session;
        }
    }

    public void SendForEach()
    {
        lock (_lock)
        {
            foreach (ServerSession session in _sessions)
            {
                C_Chat chatPacket = new C_Chat();
                chatPacket.chat = $"Hello Server !";
                ArraySegment<byte> segment = chatPacket.Write();
                session.Send(segment);
            }
        }
    }
}
  • 클라이언트 세션 생성 및 패킷 송신 처리 담당.

🔹 10. Connector 개선 및 테스트

connector.Connect(endPoint, () => SessionManager.Instance.Generate(), 100);
  • 클라이언트 100개 생성 및 서버 연결.

🔹 11. PacketHandler (클라이언트)

public static void S_ChatHandler(PacketSession session, IPacket packet)
{
    S_Chat chatPacket = packet as S_Chat;
    ServerSession serverSession = session as ServerSession;

    Console.WriteLine(chatPacket.chat);
}
  • 서버에서 Broadcast된 메시지를 출력.

🔹 12. 병목 테스트 및 O(n²) 구조 확인

  • 클라이언트 100명, 0.25초 간격 Send → 서버는 Broadcast 4만 회/초 처리
  • Broadcast에 lock이 걸리므로 병목 발생
  • 디버거로 확인 시 수많은 스레드가 lock을 기다리며 대기
  • 근본 원인: Recv → Broadcast() 즉시 실행 구조
  • 해결 방향: JobQueue 도입 → 패킷을 큐에 등록 후, 한 스레드가 순차 실행

🎯 핵심 요약

🔹 서버 아키텍처 핵심 구조

  • 서버는 클라이언트마다 하나의 Session을 생성하며, 이 세션은 자동으로 GameRoom에 입장합니다.
  • 클라이언트가 채팅을 입력하면, 서버는 해당 메시지를 Broadcast()를 통해 같은 방(Room)에 있는 모든 세션에 전송합니다.
  • PacketHandler는 패킷 종류별로 로직을 분리하여 유지보수와 확장성을 높입니다.
  • SessionManager는 모든 세션을 생성, 관리, 제거하는 중앙 관리자이며, MMO 구조에선 캐릭터 상태, 유저 정보 등을 포함하는 핵심 역할을 합니다.

🔹 멀티스레드 환경 안정성 확보

  • 서버 개발 시 가장 중요한 것은 멀티스레드 환경에서의 안전한 동기화(lock) 처리입니다.
  • GameRoom, SessionManager, Queue, List, Dictionary 등의 공유 자원은 반드시 lock으로 보호해야 합니다.
  • Disconnect() 시 소켓만 닫는 게 아니라, 관련 큐/버퍼도 함께 정리해야 안정적인 종료가 가능합니다.
  • Interlocked.Exchange()를 활용하면 중복 Disconnect 호출을 방지할 수 있습니다.

🔹 클라이언트 구조 및 테스트 확장

  • 클라이언트에서도 SessionManager를 통해 ServerSession을 관리합니다.
  • Connectorcount 매개변수를 넣어 다수의 ServerSession을 동시에 생성하고 연결할 수 있습니다.
  • SendForEach()는 클라이언트 쪽에서 모든 세션에게 동일한 패킷을 보내는 방식입니다.

🔹 Broadcast 구조의 병목과 한계

  • 서버는 클라이언트로부터 받은 채팅을 Broadcast()를 통해 같은 방의 모든 유저에게 전송합니다.
  • 이 구조는 유저 수가 늘어날수록 O(n²)의 연산량이 발생합니다. (100명이 0.25초에 한 번씩 보내면 1초에 4만 번!)
  • Broadcast 내부에 lock이 존재하기 때문에 다수의 스레드가 동시에 진입 시 병목이 발생합니다.
  • Recv → 즉시 Broadcast → lock 충돌 → 처리 지연 → 스레드 급증 현상이 발생합니다.

🔹 해결책

JobQueue 도입 필요성

  • 구조적으로 즉시 처리하는 방식은 멀티스레드에서 병목과 충돌을 유발합니다.
  • 해결 방법은 패킷을 수신하자마자 바로 처리하지 않고, 이를 Job으로 감싸 JobQueue에 등록하는 방식입니다.
  • JobQueue는 하나의 스레드가 순차적으로 작업을 처리하기 때문에 lock 충돌 없이 고성능 처리가 가능합니다.
  • 이는 단순한 채팅 처리뿐 아니라, 모든 게임 로직(스킬 사용, 위치 갱신, 퀘스트 처리 등)에 적용 가능한 필수 아키텍처입니다.

🔹 결론

  • 채팅 시스템은 MMO 서버 구조의 축소판이며, 이를 통해 세션 생성, 입장, 메시지 송수신, 병목 처리, 확장 구조 등 핵심 구조를 학습할 수 있습니다.
  • 단순 채팅이지만 이를 제대로 설계하고 운용하는 능력은 곧 MMORPG 서버의 기반 설계 능력과 직결됩니다.

profile
李家네_공부방

0개의 댓글