KoreanFoodie's Study

[C++ 게임 서버] 6-2. JobQueue #1 본문

Game Dev/Game Server

[C++ 게임 서버] 6-2. JobQueue #1

GoldGiver 2023. 12. 19. 18:49

Rookiss 님의 '[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 를 들으며 배운 내용을 정리하고 있습니다. 관심이 있으신 분은 꼭 한 번 들어보시기를 추천합니다!

[C++ 게임 서버] 6-2. JobQueue #1

핵심 :

1. Command 패턴은, 실행될 동작에 대한 인터페이스를 만들어 구체적인 동작은 해당 인터페이스를 상속 받은 객체에서 결정하도록 하는 설계 기법이다.

2. Command 패턴과 JobQueue 를 이용하면, 이전에 Broadcast 로 인한 동작의 병목 현상(LOCK 으로 인한)을 줄일 수 있다.

이전 시간에 간단한 채팅 서버를 만들어 보았다. 다만 이전 방식은 Broadcast 를 하는 부분에서 병목 현상이 심각할 수 있다는 단점이 있었는데...

이를 해결하기 위해, 우리는 JobQueue 를 만들어 순차적으로 이 문제를 해결해볼 것이다. 일단 이번 시간에는 Command 패턴을 이용해 조악하게나마 JobQueue 를 구축해 보자.

 

Command 패턴이란?

: Command 패턴은, 실행될 동작에 대한 인터페이스를 만들어 구체적인 동작은 해당 인터페이스를 상속 받은 객체에서 결정하도록 하는 설계 기법이다. 더 자세한 설명은 이 글을 읽어보자. 대박이다!

예를 들어, 우리가 식당에서 음식을 주문한다고 해 보자. 그럼 우리는 주문할 요리를 주문서에 적어 전달할 것이고, 요리사는 그 주문서를 보고 요리를 한 다음, 완성된 요리를 전달할 것이다.

여기서 종이에 적힌 주문은 커맨드 역할을 하고, 주문서가 담긴 목록은 JobQueue 처럼 취급될 것이다.

이제, 본격적으로 예제 코드를 보자.

 

일단, 이제 클라이언트/서버가 하는 행동은 'JOB' 이라는 단위로 이해할 것이다. 이를 위해, IJob 이라는 인터페이스를 만든다.

class IJob
{
public:
	virtual void Execute() { }
};

using JobRef = shared_ptr<IJob>;

이제 예를 들어, HP 를 회복하는 동작은 아래와 같이 정의될 것이다.

class HealJob : public IJob
{
public:
	virtual void Execute() override
	{
		// _target은 찾아서
		// _target->AddHP(_healValue);
		cout << _target << "한테 힐" << _healValue << " 만큼 줌";
	}

public:
	uint64 _target = 0;
	uint32 _healValue = 0;
};

 

이어서 Job 이 담기는 JobQueue 클래스를 만들어 보자.

class JobQueue
{
public:
	void Push(JobRef job)
	{
		WRITE_LOCK;
		_jobs.push(job);		
	}

	JobRef Pop()
	{
		WRITE_LOCK;
		if (_jobs.empty())
			return nullptr;

		JobRef ret = _jobs.front();
		_jobs.pop();
		return ret;
	}

private:
	USE_LOCK;
	queue<JobRef> _jobs;
};

JobQueue 는 Job 을 담는 큐 역할을 할 뿐이다. 😅

 

그리고, Room 에서 JobQueue 를 추가한다.

#pragma once
#include "Job.h"

class Room
{
	friend class EnterJob;
	friend class LeaveJob;
	friend class BroadcastJob;

private:
	// 싱글쓰레드 환경인마냥 코딩
	void Enter(PlayerRef player);
	void Leave(PlayerRef player);
	void Broadcast(SendBufferRef sendBuffer);

public:
	// 멀티쓰레드 환경에서는 일감으로 접근
	void PushJob(JobRef job) { _jobs.Push(job); }
	void FlushJob();

private:
	map<uint64, PlayerRef> _players;

	JobQueue _jobs;
};

잘 보면, Enter, Leave, Broadcast 가 이제 private 으로 바뀐 것을 알 수 있다. 그 이유는, 우리는 이제 '구체적인 동작' 을 외부에서 호출하지 않고, Execute 를 통해 간접적으로 처리할 것이기 때문이다.

따라서 PushJob 을 통해 주문을 넣고, FlushJob 을 통해 쌓인 Job 들이 각 Job 의 특성에 맞게 순차적으로 처리될 것임을 유추할 수 있다. 😉

 

이제 Enter, Leave, Broadcast 에 대한 Job 들을 아래와 같이 정의해 주자.

// Room Jobs
class EnterJob : public IJob
{
public:
	EnterJob(Room& room, PlayerRef player) : _room(room), _player(player)
	{
	}

	virtual void Execute() override
	{
		_room.Enter(_player);
	}
	
public:
	Room& _room;
	PlayerRef _player;
};

class LeaveJob : public IJob
{
public:
	LeaveJob(Room& room, PlayerRef player) : _room(room), _player(player)
	{
	}

	virtual void Execute() override
	{
		_room.Leave(_player);
	}

public:
	Room& _room;
	PlayerRef _player;
};

class BroadcastJob : public IJob
{
public:
	BroadcastJob(Room& room, SendBufferRef sendBuffer) : _room(room), _sendBuffer(sendBuffer)
	{
	}

	virtual void Execute() override
	{
		_room.Broadcast(_sendBuffer);
	}

public:
	Room& _room;
	SendBufferRef _sendBuffer;
};

 

그리고 Room 의 구현부는 아래와 같이 변한다 :

Room GRoom;

void Room::Enter(PlayerRef player)
{
	_players[player->playerId] = player;
}

void Room::Leave(PlayerRef player)
{
	_players.erase(player->playerId);
}

void Room::Broadcast(SendBufferRef sendBuffer)
{
	for (auto& p : _players)
	{
		p.second->ownerSession->Send(sendBuffer);
	}
}

void Room::FlushJob()
{
	while (true)
	{
		JobRef job = _jobs.Pop();
		if (job == nullptr)
			break;

		job->Execute();
	}
}

잘 보면, Broadcast 를 하는 부분에는 더 이상 LOCK 이 없는 것을 확인할 수 있다.

물론 JobQueue 로 부터 Pop 을 할 때는 LOCK 이 잠깐 걸리긴 하지만, 각 Job 이 Execute 되는 과정에서는 LOCK 이 걸리지 않고 있다! 😮

위와 같이 Command 패턴을 사용하면, 각 Job 에 대한 동작을 다르게 만들어주면서, 동시에 싱글 쓰레드처럼 Job 들이 알아서 처리될 것이다. 다만, FlushJob 을 여러 곳에서 하면 안되고, 반드시 한 곳에서만 해 줘야 될 것이다.

 

실제로 ClientPacketHandler 에서, 클라이언트가 보낸 채팅 패킷을 서버가 어떻게 처리하는지를 보자.

bool Handle_C_CHAT(PacketSessionRef& session, Protocol::C_CHAT& pkt)
{
	std::cout << pkt.msg() << endl;

	Protocol::S_CHAT chatPkt;
	chatPkt.set_msg(pkt.msg());
	auto sendBuffer = ClientPacketHandler::MakeSendBuffer(chatPkt);

	GRoom.PushJob(MakeShared<BroadcastJob>(GRoom, sendBuffer));

	return true;
}

이제 직접적으로 Broadcast 하지 않고, 그냥 JobQueue 에 알맞은 타입의 Job 을 넣어주기만 하고 있다! 😁

 
Comments