나만의 작은 도서관
[TIL][C++] 250604 MMO 서버 개발 32일차: Job과 JobQueue의 운영 방식, 그렇다면 JobQueue는 어떻게 운영해야할까? 본문
Today I Learn
[TIL][C++] 250604 MMO 서버 개발 32일차: Job과 JobQueue의 운영 방식, 그렇다면 JobQueue는 어떻게 운영해야할까?
pledge24 2025. 6. 4. 22:10주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다.
Job과 JobQueue의 운영 방식
Job이란?
- Job은 서버에서 인게임 로직을 래핑 한 함수 객체(functor)로, 여러 가지 장점에 의해 사용한다.
- Job과 JobQueue를 이용한 인게임 로직 처리의 장점은 여러 가지 있다.
Job과 JobQueue를 사용하는 방식의 장점
- 우선, 네트워크 이벤트와 인게임 로직 처리의 시점을 분리할 수 있다는 것인데, 분리가 가능해지면서 Job을 생성한 스레드가 Job을 처리하지 않고, 다른 스레드한테 떠넘길 수 있다는 선택지가 생기게 된다.
- 이러한 방식을 디자인 패턴에서 “커맨드 패턴”이라고 부르는데, 적용 시 일감을 생성하는 생성자(Producer)와 소비자(Consumer) 역할을 구분하여 처리할 수 있다는 장점이 있다고 한다.(위와 같은 내용)
- 두 번째 장점은 첫 번째와 연관이 있는데, 바로 락에 의한 병목 현상이 줄어든다는 것이다.
- 기존의 방식인 네트워크 이벤트 처리 → 인게임 로직 처리 방식은 하나의 스레드가 선택지 없이 반드시 인게임 로직을 처리해야 했었다. 이 방식의 문제점은 여러 스레드가 같은 세션에 들어가서 일을 처리할 때 같은 락을 잡으려고 할 수 있다는 것이다.
- 예를 들어, 스레드 A가 연결된 유저 세션 X에 접근하여 락을 걸고 작업을 하고 있을 때, 스레드 B가 동일한 유저 세션 X에 접근하여 다른 작업을 하지만 같은 락을 점유하려고 한다고 가정하자. 그렇다면 스레드 B는 락을 점유할 때까지(스레드 A가 락을 놓아줄 때까지) 대기하게 될 거고, 이러한 병목 현상은 서버의 전체적인 처리 능력을 저하시키는 결과를 가져온다.
- 위 예시에선 하나의 스레드(B)만 병목 되었지만, 병목 되는 스레드는 10개가 될 수도, 100개가 될 수도 있다. 즉, 운영 중인 모든 스레드가 병목 될 가능성을 가지고 운영된다는 것이다.
- Job을 사용하면 이러한 문제가 해결되는데, 그 이유는 분리된 Job을 분류해서 각각의 JobQueue에 넣어놓고, 하나의 스레드가 처리하도록 할 수 있기 때문이다.
- 병목 현상의 발생 원인은 같은 락을 여러 스레드가 잡는 상황이 발생해서인데, 같은 락을 잡는 Job들을 JobQueue에 넣어놓고 접근하는 스레드를 하나로 고정하면 병목 현상이 발생하지 않게 된다.
- 기존의 방식인 네트워크 이벤트 처리 → 인게임 로직 처리 방식은 하나의 스레드가 선택지 없이 반드시 인게임 로직을 처리해야 했었다. 이 방식의 문제점은 여러 스레드가 같은 세션에 들어가서 일을 처리할 때 같은 락을 잡으려고 할 수 있다는 것이다.
- 마지막으로, 공평함이 올라간다는 것이다. 어느 게임이건 서버는 맵 안에 존재하는 모든 캐릭터에게 공평한 환경을 제공해야 할 의무가 있다. 이때 각 맵에 유일한 JobQueue를 두고, 하나의 스레드가 Consume 하게 한다면, 어느 캐릭터는 반응이 느리고, 어느 캐릭터는 반응이 빠른 불공평한 상황이 크게 줄어들 것이다.
- 물론, 이 이야기는 어디까지나 한 스레드가 여유롭게 JobQueue를 consume 할 수 있는 상황을 상정한 것이다. 이 부분은 기획이나 정책, 그리고 라이브 경험과 크게 묶이는 부분이니 크게 신경 쓸 필요는 없다(개인이 알기는 어렵다…)
그렇다면 JobQueue는 어떻게 운영해야 할까?
- 당연하게도 JobQueue에 Job을 밀어 넣는 대상도, Job을 꺼내 쓰는 대상도 스레드이다. 다만, 각각이 같은 스레드가 아닐 수 있을 뿐이다. 그렇다면 시점도 분리되었겠다, Job을 밀어 넣는 스레드 따로, Job을 꺼내 쓰는 스레드 따로 두는 것이 맞을까?
- 고정된 역할을 가진 스레드를 배치하고, 실행하도록 하는 것도 좋은 방법이다. 구조가 간단하고, 충분히 효율적이기 때문.
- 하지만 고정된 역할을 가지도록 정책을 설정하는 건 조금 아쉬운 부분이 있을 수 있다. 첫 번째로, 스레드의 효율적인 분배 비율을 알기 어렵다는 것이고, 두 번째로 유동적으로 변하는 요구사항에 대처하기 어렵다는 것이다.
- 명확하게 Job을 Produce 하는 역할과 Consume 하는 역할을 구분 지어 스레드를 운영한다면, 한쪽으로 일감이 몰렸을 때 해당 역할을 부여받은 스레드는 엄청난 부하를 받고, Consume 하는 스레드는 쾌적한 상황이 발생하게 된다. 이는 균등하게 스레드가 일을 하도록 만들자는 멀티 스레드 철학(?)에 어긋나는 상황이므로 그리 좋지 못하다.
- 따라서, 급격한 트래픽 증가와 같은 상황에 대처하기 위해 모든 스레드들이 양쪽의 역할을 할 수 있도록 여지를 남겨두는 것이 좋다.
결론
while (true)
{
LEndTickCount = ::GetTickCount64() + WORKER_TICK;
// 네트워크 입출력 처리 -> 인게임 로직까지 (패킷 핸들러에 의해)
service->GetIocpCore()->Dispatch(10);
// 시간이 된 TimerJob 각 JQ에 밀어넣기(하나만 통과)
ThreadManager::DistributeReservedJobs();
// 글로벌 큐(떠넘겨진 JQ 들어있음)
ThreadManager::DoGlobalQueueWork();
}
- 모든 워커 스레드들에게 각 역할을 할 수 있도록 이벤트 루프와 같은 정책을 적용한다. 위 코드는 이벤트 루프와 같이 짠 워커 스레드의 while문이며, 네트워크 이벤트 처리 → 인게임 로직 → TimerJob 처리 → 글로벌 Job처리 순으로 역할을 부여받아 처리한다.
- 물론, 역할을 부여받았다고 반드시 일을 해야 하는 것은 아니다. 해당 스레드가 여러 가지 이유로 여건이 여의치 않다면 작업을 하지 않고 바로 빠져나와 다음 역할로 넘어갈 수도 있다. 즉, 각 스레드가 맡은 모든 역할에 대한 수행은 “선택적이다”.
부여받은 역할은 정해진 시간 내로만 작업한다.
- 위 결론에서 하나 빼먹은 사실이 있는데, 역할을 부여받고 작업을 했다고 해서, 작업이 없을 때까지 해야 하는 것은 아니다. 만약 그렇게 적용했다면, 결국 특정 스레드만 일을 하는 경우가 다시 발생할 것이다.
- 따라서, 일할 시간을 정해두고, 그 시간을 초과한 일은 하지 않되, 남은 일이 있을 경우 GlobalQueue에 해당 JQ를 집어넣는 것이다.