C# 기반 MMO 서버 아키텍처에서 기초적인 채팅 시스템을 설계하고 구현하는 과정을 다룹니다.
단순한 메시지 송수신을 넘어서, 다수의 클라이언트가 동시에 접속하고, 채팅 패킷을 주고받으며, 서버가 이를 관리하는 구조까지 확장합니다.
PacketSession 기반 통신 구조 도입 및 PacketHandler 분기 처리SessionManager를 통한 클라이언트 세션 관리Connector 클래스 개선으로 다중 접속 구현SendForEach()로 클라이언트 전체에서 동일 패킷 전송 테스트 수행Broadcast() 내부의 lock 처리로 인한 병목 현상 발생Session / ClientSession / ServerSession
Session은 서버와 연결된 클라이언트를 나타내는 객체입니다.ClientSession, 클라이언트 측에서는 ServerSession으로 구분되어 각각의 역할을 수행합니다.PacketSession을 상속받아 OnRecvPacket() 메서드를 통해 패킷을 수신합니다.SessionManager
GameRoom
Enter), 퇴장(Leave), 메시지 전송(Broadcast)의 기능을 제공하며, 내부 공유 리스트는 lock을 통해 멀티스레드 환경에서도 안전하게 보호합니다.PDL (Packet Definition Language)
C_Chat, S_Chat 등. 이를 바탕으로 자동 코드 생성(GenPackets.bat)이 가능합니다.PacketManager & 패킷 핸들러
PacketManager.Instance.OnRecvPacket()을 통해 분기되며, 패킷 종류별로 메서드가 지정됩니다.Connector
count 매개변수를 통해 수십~수백 개의 클라이언트를 동시에 생성하여 부하 테스트가 가능합니다.DummyClient
SessionManager, Connector, ServerSession을 이용해 실제 MMO 환경의 시뮬레이션을 수행합니다.SendForEach vs Broadcast
SendForEach()는 클라이언트 측에서 모든 세션을 순회하며 서버로 패킷을 전송하는 함수입니다.Broadcast()는 서버 측에서 받은 메시지를 같은 GameRoom에 있는 모든 세션에게 전파하는 구조입니다.멀티스레드 환경의 위험성과 lock 병목
Broadcast()를 실행하면, 다수의 스레드가 동시에 lock을 요청하면서 병목이 발생합니다.O(n²) 복잡도 구조
| 용어 | 설명 |
|---|---|
| Session | 클라이언트와 서버 간의 연결을 추상화한 객체. 데이터 송수신의 기본 단위 |
| ClientSession / ServerSession | 서버 측에서 연결된 클라이언트를 나타내는 객체(ClientSession) / 클라이언트 측에서 서버 연결을 나타내는 객체(ServerSession) |
| PacketSession | 패킷 송수신 기능을 가진 세션의 기반 클래스 |
| GameRoom | 세션(Client)들이 입장하는 채팅 방. 메시지를 관리하고 브로드캐스트 처리 |
| SessionManager | 모든 세션의 생성, 저장, 조회, 제거 기능을 담당하는 싱글톤 관리자 클래스 |
| Connector | 클라이언트에서 서버로의 연결을 처리하는 모듈. 다수의 접속도 가능 |
| 용어 | 설명 |
|---|---|
| Packet | 서버와 클라이언트 간 송수신되는 데이터의 기본 단위 |
| PDL (Packet Definition Language) | XML 기반의 패킷 정의 방식. 패킷 구조를 선언적으로 정의 |
| GenPackets.bat | PDL 기반의 패킷 정의를 토대로 자동으로 패킷 코드 생성하는 빌드 스크립트 |
| PacketManager | 수신된 패킷을 분석하고 해당 핸들러에 분기하는 관리자 클래스 |
| Packet Handler | 특정 패킷에 대한 동작을 구현한 함수 (예: S_ChatHandler) |
| 용어 | 설명 |
|---|---|
| Broadcast | 서버가 같은 GameRoom에 있는 모든 세션에게 패킷을 전파하는 방식 |
| SendForEach | 클라이언트가 연결된 모든 세션에게 동일한 패킷을 전송하는 함수 |
| DummyClient | 클라이언트 기능을 테스트하기 위해 만들어진 테스트 클라이언트 프로그램 |
| 용어 | 설명 |
|---|---|
| lock() | C#에서 공유 자원에 대한 동기화를 위해 사용하는 기본 동기화 메커니즘 |
| Interlocked.Exchange | 멀티스레드 환경에서 안전하게 값을 변경하는 원자적 연산 함수 |
| _socket.Shutdown / Close | 소켓의 송수신을 종료하거나 연결을 닫는 함수 |
ArraySegment<byte> | byte 배열의 일부를 참조하는 객체로, 네트워크 전송 시 메모리 효율성을 위해 사용됨 |
| JobQueue | 멀티스레드 환경에서 작업을 큐에 쌓아두고, 하나의 스레드에서 순차적으로 처리하는 구조 |
| O(n²) 복잡도 | 유저 수가 증가할수록 처리량이 기하급수적으로 증가하는 문제 구조. Broadcast 시 발생 |
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 내부 버퍼도 안전하게 정리.void Clear()
{
lock (_lock)
{
_sendQueue.Clear();
_pendingList.Clear();
}
}
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에 누적된 버퍼를 한 번에 전송.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}");
}
}
<packet name="C_Chat">
<string name="chat"/>
</packet>
<packet name="S_Chat">
<int name="playerId"/>
<string name="chat"/>
</packet>
C_Chat: 클라이언트 → 서버S_Chat: 서버 → 클라이언트 (Broadcast)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) 적용.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를 통해 전체 클라이언트 목록에서도 제거.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;
}
}
}
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);
}
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) { }
}
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);
}
}
}
}
connector.Connect(endPoint, () => SessionManager.Instance.Generate(), 100);
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);
}
lock이 걸리므로 병목 발생lock을 기다리며 대기Recv → Broadcast() 즉시 실행 구조Session을 생성하며, 이 세션은 자동으로 GameRoom에 입장합니다.Broadcast()를 통해 같은 방(Room)에 있는 모든 세션에 전송합니다.PacketHandler는 패킷 종류별로 로직을 분리하여 유지보수와 확장성을 높입니다.SessionManager는 모든 세션을 생성, 관리, 제거하는 중앙 관리자이며, MMO 구조에선 캐릭터 상태, 유저 정보 등을 포함하는 핵심 역할을 합니다.GameRoom, SessionManager, Queue, List, Dictionary 등의 공유 자원은 반드시 lock으로 보호해야 합니다.Disconnect() 시 소켓만 닫는 게 아니라, 관련 큐/버퍼도 함께 정리해야 안정적인 종료가 가능합니다.Interlocked.Exchange()를 활용하면 중복 Disconnect 호출을 방지할 수 있습니다.SessionManager를 통해 ServerSession을 관리합니다.Connector에 count 매개변수를 넣어 다수의 ServerSession을 동시에 생성하고 연결할 수 있습니다.SendForEach()는 클라이언트 쪽에서 모든 세션에게 동일한 패킷을 보내는 방식입니다.Broadcast()를 통해 같은 방의 모든 유저에게 전송합니다.JobQueue 도입 필요성