✅ 주제

이번 강의의 주제는 Command 패턴을 통한 서버 구조 개선입니다.
기존 채팅 서버는 클라이언트의 요청을 서버가 바로 처리하는 구조였지만,
이는 lock 병목스레드 과다 생성 문제를 야기했습니다.

이 문제를 해결하기 위해, 이번 시간에는 Command 패턴을 기반으로 한 JobQueue 구조를 도입하고,
실제 GameRoom의 BroadCast, Enter, Leave 함수 호출 방식을 전면 개선하여
일감 생성과 처리의 책임을 분리하는 구조로 바꿉니다.

  • Command 패턴을 적용한 서버 구조의 최종 실행 흐름(Flush) 구현
  • lock 병목을 없애기 위한 GameRoom의 구조 개선
  • 멀티스레드 환경에서 안전한 작업 큐 기반 동작 구조 설계

📚 개념

  • 기존 구조의 문제점:
    클라이언트가 보낸 요청을 서버가 바로 처리하려 하면, 서로 다른 스레드들이 동시에 GameRoom의 lock을 기다리게 되어 병목이 발생합니다.
    스레드 수가 많아질수록 이 문제는 심각해지고, 쓰레드풀까지 소모되며 서버 전체 퍼포먼스를 떨어뜨립니다.

  • Command 패턴 개념:
    요청을 처리할 책임을 즉시 수행하지 않고, 일단 일감(Job)으로 저장해둔 후, 하나의 스레드만 해당 작업들을 순차 처리하는 방식입니다.
    즉, 클라이언트는 “요청서”만 제출하고 빠지고, 처리는 별도의 담당자가 수행하는 구조입니다.

  • JobQueue 설계 목적:

    • 일감은 Action 델리게이트로 표현됩니다.
    • 요청서가 Queue에 저장되고, 순차적으로 Flush()를 통해 일감이 실행됩니다.
    • GameRoom 내부에서 처리되기 때문에 lock이 제거 가능해지고, 동기화 문제가 사라집니다.
  • Flush 메서드는 JobQueue에 쌓인 작업들을 순서대로 꺼내 실행하는 메서드입니다.

  • 처음 Push된 쓰레드가 모든 작업을 처리하고 나가도록 하여 스레드 충돌을 방지합니다.

  • GameRoom은 이제 직접 동기화(lock)를 하지 않고, 오직 JobQueue 안에서만 일 처리를 합니다.

  • JobQueue가 일감 처리의 중심이 되며, GameRoom은 더이상 lock을 갖지 않아도 됩니다.


🧩 용어정리

용어설명
Command Pattern요청을 객체로 캡슐화하여 실행 시점과 로직을 분리하는 디자인 패턴
Job처리할 작업을 의미하며, C#에서는 Action으로 표현
JobQueueAction을 저장하고 순차적으로 처리하는 큐 구조
Push외부에서 Job을 넣는 함수
FlushJobQueue에 쌓인 일감을 꺼내 순차 실행하는 함수
lock멀티스레드 환경에서 공유 자원을 보호하는 메커니즘
IJobQueueJobQueue의 추상 인터페이스. Push()만 제공
GameRoom채팅방 역할을 하는 클래스. JobQueue 구조 내장
BroadCast같은 방에 있는 클라이언트들에게 메시지를 전파하는 행위
용어설명
------
Flush()JobQueue 내부의 모든 작업을 순차 실행하는 메서드
_flush현재 작업 중인지를 판단하는 플래그 (진입 여부 판단)
InvokeAction 대리자를 호출하는 메서드
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)이 걸리기 때문에, 동시 접근이 많은 경우 스레드 병목이 발생합니다.

🔹 해결 구조 - JobQueue 도입

1) IJobQueue 인터페이스 생성

public interface IJobQueue
{
    void Push(Action action);
}
  • 외부에서는 Push()만 호출하여 작업을 등록할 수 있도록 인터페이스 분리
  • Action 델리게이트로 작업을 저장 (함수를 인자로 넘김)

2) JobQueue 클래스 정의

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 사용

3) GameRoom 클래스에 JobQueue 통합

class GameRoom : IJobQueue
{
    List<ClientSession> _sessions = new List<ClientSession>();
    JobQueue _jobQueue = new JobQueue();

    public void Push(Action job)
    {
        _jobQueue.Push(job);
    }
}
  • GameRoom이 IJobQueue를 상속받고 내부에 JobQueue 인스턴스를 보유
  • Push()는 GameRoom 외부에서 호출되며, 내부 JobQueue로 위임

4) BroadCast, Enter, Leave 호출 방식 변경

기존 방식:

clientSession.Room.Broadcast(clientSession, chatPacket.chat);

Command 패턴 적용 방식:

clientSession.Room.Push(
    () => clientSession.Room.Broadcast(clientSession, chatPacket.chat)
);
  • 직접 호출 대신 람다식으로 감싼 함수Push()로 전달
  • 이로써 실행 시점을 즉시 → 나중(Flush 시점) 으로 변경

5) 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;

    GameRoom room = clientSession.Room;

    room.Push(() =>
        room.Broadcast(clientSession, chatPacket.chat)
    );
}
  • clientSession.Room 참조를 미리 room 변수에 저장한 이유는,
    Flush가 동작할 때까지 Room이 null로 바뀔 수 있기 때문입니다.
    즉, 늦은 실행 타이밍을 고려한 안전 장치입니다.

6) ClientSession 입장 시 처리 방식 변경

기존:

Program.Room.Enter(this);

변경:

Program.Room.Push(() => Program.Room.Enter(this));
  • 입장 처리 역시 JobQueue를 통해 늦게 실행되도록 변경

7) ClientSession 퇴장 처리 변경

if (Room != null)
{
    GameRoom room = Room;
    room.Push(() => room.Leave(this));
    Room = null;
}
  • Leave()도 직접 호출이 아닌, Push로 일감화
  • Room을 null로 만들기 전에 참조 주소 복사

이로써 1부에서는 Command 패턴의 핵심 개념과 구조를 바탕으로, 기존의 즉시 실행 구조를
JobQueue 기반 지연 실행 방식으로 바꾸는 전체 과정을 설명했습니다.

🔹 1. Push + Flush 구조 구현

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을 사용하는 이유는 다음과 같습니다:

❗ 왜 bool 2개를 쓰는가?

  • _flush만 쓴다면 다른 쓰레드도 동시에 Push()를 실행할 수 있게 되며, Flush를 중복으로 여러 쓰레드가 실행할 가능성이 생김
  • flush = true인 쓰레드만 Flush()를 실행하게 하여 단일 스레드 실행 보장

🔹 2. Flush() 구현

void Flush()
{
    while (true)
    {
        Action action = Pop();
        if (action == null)
        {
            lock (_lock)
            {
                _flush = false;
                return;
            }
        }

        action.Invoke();
    }
}
  • 작업이 남아있다면 계속 꺼내어 실행
  • 더 이상 작업이 없을 경우 _flush = false로 초기화 → 다시 새로운 Push가 들어오면 Flush 가능
  • Pop() 도중에도 Push()가 발생할 수 있기 때문에, Pop에도 lock이 필요

🔹 3. GameRoom에서 lock 제거

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);
        }
    }
}
  • Enter / Leave / Broadcast 모두에서 lock을 제거했습니다.
  • 이 메서드들은 이제 JobQueue의 Flush 내부에서만 실행되므로 동기화는 보장됩니다.

🔹 4. Null 예외 대응

문제 상황:

clientSession.Room.Push(() => clientSession.Room.Broadcast(...));
  • 위처럼 람다에서 Room을 직접 참조할 경우,
  • Flush 실행 시점에 Room = null이라면 NullReferenceException 발생

해결책:

GameRoom room = clientSession.Room;

room.Push(() =>
{
    room.Broadcast(clientSession, chatPacket.chat);
});
  • room 참조를 지역 변수로 미리 복사 → 이후 null이 되어도 참조 유효
  • 퇴장 시도(Leave)에서도 동일하게 적용

🔹 5. 퇴장 처리에서도 동일하게 처리

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;
    }
}
  • Room이 null 되기 전에 참조 복사 → 이후 Push 내부에서 안전하게 처리 가능

🔹 6. 테스트 결과

  • BroadCast마다 스레드가 생겼던 기존 구조와 달리,
  • JobQueue 구조에서는 대부분 하나의 스레드가 Flush를 실행
  • Visual Studio에서 스레드 수가 대폭 줄어든 것이 확인됨

🎯 핵심

  • Command 패턴은 요청을 즉시 처리하지 않고 저장함으로써,
    서버 구조를 단순, 안정, 병목 없는 방향으로 개선할 수 있습니다.
  • JobQueue의 핵심은 Push()Flush()의 분리이며, 작업은 안전하게 Queue에 넣고, 처리는 오직 하나의 스레드에서 수행하도록 보장합니다.
  • GameRoom은 더 이상 lock을 사용하지 않으며, 모든 비즈니스 로직은 JobQueue의 Flush 안에서 순차 처리됩니다.
  • 클라이언트가 방에 입장하거나 퇴장하고, 채팅을 보내는 과정이 모두 일감의 형태로 지연 실행되며 안정성을 확보합니다.
  • Null 참조 예외도 처리 타이밍 문제로 발생하므로, 반드시 참조 객체를 지역 변수로 복사해서 사용해야 합니다.
profile
李家네_공부방

0개의 댓글