이번 강의의 주제는 Command 패턴을 통한 서버 구조 개선입니다.
기존 채팅 서버는 클라이언트의 요청을 서버가 바로 처리하는 구조였지만,
이는 lock 병목과 스레드 과다 생성 문제를 야기했습니다.
이 문제를 해결하기 위해, 이번 시간에는 Command 패턴을 기반으로 한 JobQueue 구조를 도입하고,
실제 GameRoom의 BroadCast, Enter, Leave 함수 호출 방식을 전면 개선하여
일감 생성과 처리의 책임을 분리하는 구조로 바꿉니다.
기존 구조의 문제점:
클라이언트가 보낸 요청을 서버가 바로 처리하려 하면, 서로 다른 스레드들이 동시에 GameRoom의 lock을 기다리게 되어 병목이 발생합니다.
스레드 수가 많아질수록 이 문제는 심각해지고, 쓰레드풀까지 소모되며 서버 전체 퍼포먼스를 떨어뜨립니다.
Command 패턴 개념:
요청을 처리할 책임을 즉시 수행하지 않고, 일단 일감(Job)으로 저장해둔 후, 하나의 스레드만 해당 작업들을 순차 처리하는 방식입니다.
즉, 클라이언트는 “요청서”만 제출하고 빠지고, 처리는 별도의 담당자가 수행하는 구조입니다.
JobQueue 설계 목적:
Flush 메서드는 JobQueue에 쌓인 작업들을 순서대로 꺼내 실행하는 메서드입니다.
처음 Push된 쓰레드가 모든 작업을 처리하고 나가도록 하여 스레드 충돌을 방지합니다.
GameRoom은 이제 직접 동기화(lock)를 하지 않고, 오직 JobQueue 안에서만 일 처리를 합니다.
JobQueue가 일감 처리의 중심이 되며, GameRoom은 더이상 lock을 갖지 않아도 됩니다.
| 용어 | 설명 |
|---|---|
| Command Pattern | 요청을 객체로 캡슐화하여 실행 시점과 로직을 분리하는 디자인 패턴 |
| Job | 처리할 작업을 의미하며, C#에서는 Action으로 표현 |
| JobQueue | Action을 저장하고 순차적으로 처리하는 큐 구조 |
| Push | 외부에서 Job을 넣는 함수 |
| Flush | JobQueue에 쌓인 일감을 꺼내 순차 실행하는 함수 |
| lock | 멀티스레드 환경에서 공유 자원을 보호하는 메커니즘 |
| IJobQueue | JobQueue의 추상 인터페이스. Push()만 제공 |
| GameRoom | 채팅방 역할을 하는 클래스. JobQueue 구조 내장 |
| BroadCast | 같은 방에 있는 클라이언트들에게 메시지를 전파하는 행위 |
| 용어 | 설명 |
| --- | --- |
| Flush() | JobQueue 내부의 모든 작업을 순차 실행하는 메서드 |
| _flush | 현재 작업 중인지를 판단하는 플래그 (진입 여부 판단) |
| Invoke | Action 대리자를 호출하는 메서드 |
| Null Crash | 객체가 null인 상태에서 멤버 접근 시 발생하는 예외 |
| 레이스 컨디션 | 여러 쓰레드가 동시에 값을 읽고 쓰면서 발생하는 타이밍 문제 |
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);
}
Broadcast()를 즉시 호출합니다.GameRoom 내부에서 lock(_lock)이 걸리기 때문에, 동시 접근이 많은 경우 스레드 병목이 발생합니다.IJobQueue 인터페이스 생성public interface IJobQueue
{
void Push(Action action);
}
Push()만 호출하여 작업을 등록할 수 있도록 인터페이스 분리class JobQueue : IJobQueue
{
Queue<Action> _jobQueue = new Queue<Action>();
object _lock = new object();
public void Push(Action action)
{
lock (_lock)
{
_jobQueue.Enqueue(action);
}
}
Action Pop()
{
lock (_lock)
{
if (_jobQueue.Count == 0)
return null;
return _jobQueue.Dequeue();
}
}
}
Queue<Action>: 일감들을 저장하는 큐Push(): 작업 등록Pop(): 작업 꺼내오기lock 사용class GameRoom : IJobQueue
{
List<ClientSession> _sessions = new List<ClientSession>();
JobQueue _jobQueue = new JobQueue();
public void Push(Action job)
{
_jobQueue.Push(job);
}
}
IJobQueue를 상속받고 내부에 JobQueue 인스턴스를 보유Push()는 GameRoom 외부에서 호출되며, 내부 JobQueue로 위임기존 방식:
clientSession.Room.Broadcast(clientSession, chatPacket.chat);
Command 패턴 적용 방식:
clientSession.Room.Push(
() => clientSession.Room.Broadcast(clientSession, chatPacket.chat)
);
Push()로 전달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;
GameRoom room = clientSession.Room;
room.Push(() =>
room.Broadcast(clientSession, chatPacket.chat)
);
}
room 변수에 저장한 이유는,Room이 null로 바뀔 수 있기 때문입니다.기존:
Program.Room.Enter(this);
변경:
Program.Room.Push(() => Program.Room.Enter(this));
if (Room != null)
{
GameRoom room = Room;
room.Push(() => room.Leave(this));
Room = null;
}
이로써 1부에서는 Command 패턴의 핵심 개념과 구조를 바탕으로, 기존의 즉시 실행 구조를
JobQueue 기반 지연 실행 방식으로 바꾸는 전체 과정을 설명했습니다.
class JobQueue : IJobQueue
{
Queue<Action> _jobQueue = new Queue<Action>();
object _lock = new object();
bool _flush = false;
public void Push(Action action)
{
bool flush = false;
lock (_lock)
{
_jobQueue.Enqueue(action);
if (_flush == false)
{
_flush = flush = true;
}
}
if (flush)
Flush();
}
}
Push()는 작업을 추가하고, 만약 현재 작업 중이 아니라면 Flush()를 호출합니다.flush와 외부 flush 이중 bool을 사용하는 이유는 다음과 같습니다:_flush만 쓴다면 다른 쓰레드도 동시에 Push()를 실행할 수 있게 되며, Flush를 중복으로 여러 쓰레드가 실행할 가능성이 생김flush = true인 쓰레드만 Flush()를 실행하게 하여 단일 스레드 실행 보장void Flush()
{
while (true)
{
Action action = Pop();
if (action == null)
{
lock (_lock)
{
_flush = false;
return;
}
}
action.Invoke();
}
}
_flush = false로 초기화 → 다시 새로운 Push가 들어오면 Flush 가능class GameRoom : IJobQueue
{
List<ClientSession> _sessions = new List<ClientSession>();
JobQueue _jobQueue = new JobQueue();
public void Push(Action job)
{
_jobQueue.Push(job);
}
public void Enter(ClientSession session)
{
_sessions.Add(session);
session.Room = this;
}
public void Leave(ClientSession session)
{
_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();
foreach (ClientSession s in _sessions)
{
s.Send(segment);
}
}
}
clientSession.Room.Push(() => clientSession.Room.Broadcast(...));
Room = null이라면 NullReferenceException 발생GameRoom room = clientSession.Room;
room.Push(() =>
{
room.Broadcast(clientSession, chatPacket.chat);
});
Leave)에서도 동일하게 적용public override void OnDisconnected(EndPoint endPoint)
{
SessionManager.instance.Remove(this);
Console.WriteLine($"OnDisconnected : {endPoint}");
if (Room != null)
{
GameRoom room = Room;
room.Push(() => room.Leave(this));
Room = null;
}
}
Push()와 Flush()의 분리이며, 작업은 안전하게 Queue에 넣고, 처리는 오직 하나의 스레드에서 수행하도록 보장합니다.