KoreanFoodie's Study

[C++ 게임 서버] 6-1. 채팅 실습 본문

Game Dev/Game Server

[C++ 게임 서버] 6-1. 채팅 실습

GoldGiver 2023. 12. 19. 14:34

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

[C++ 게임 서버] 6-1. 채팅 실습

핵심 :

1. Chat 서버를 만들어보자. 간단하게는 채팅이 전파될 Room 과 채팅 패킷만 만들어 주면 된다.

2. 채팅을 전파할 때마다, Room 에서 WRITE_LOCK 을 잡으면 병목현상이 생길 수 있다. 이를 해결하기 위해, 추후 JobQueue 를 도입할 것이다. 

이번에는 채팅 패킷을 만들어 보면서, 간단하게 실제로 패킷을 추가하는 작업을 어떻게 진행하는지 살펴볼 것이다.

일단, 우리가 작업했던 Protocol.proto 를 다음과 같이 수정해 주자.

syntax = "proto3";
package Protocol;

import "Enum.proto";
import "Struct.proto";

message C_LOGIN
{

}

message S_LOGIN
{
	bool success = 1;
	repeated Player players = 2; // 아이디 발급 전
}

message C_ENTER_GAME
{
	uint64 playerIndex = 1;
}

message S_ENTER_GAME
{
	bool success = 1;
}

message C_CHAT
{
	string msg = 1;
}

message S_CHAT
{
	uint64 playerId = 1;
	string msg = 2;
}

지금은 S_CHAT 패킷에 플레이어 Id 와 msg 에 대한 정보만 있을 것이다.

이제 빌드를 하면, 이전에 만들었던 자동화 스크립트가 돌아가 아래와 같이 PacketHandler 부분의 코드가 자동 생성될 것이다(아래 코드는 ClientPakcetHandler.h 예시이다).

#pragma once
#include "Protocol.pb.h"

using PacketHandlerFunc = std::function<bool(PacketSessionRef&, BYTE*, int32)>;
extern PacketHandlerFunc GPacketHandler[UINT16_MAX];

enum : uint16
{
	PKT_C_LOGIN = 1000,
	PKT_S_LOGIN = 1001,
	PKT_C_ENTER_GAME = 1002,
	PKT_S_ENTER_GAME = 1003,
	PKT_C_CHAT = 1004,
	PKT_S_CHAT = 1005,
};

// Custom Handlers
bool Handle_INVALID(PacketSessionRef& session, BYTE* buffer, int32 len);
bool Handle_C_LOGIN(PacketSessionRef& session, Protocol::C_LOGIN& pkt);
bool Handle_C_ENTER_GAME(PacketSessionRef& session, Protocol::C_ENTER_GAME& pkt);
bool Handle_C_CHAT(PacketSessionRef& session, Protocol::C_CHAT& pkt);

class ClientPacketHandler
{
public:
	static void Init()
	{
		for (int32 i = 0; i < UINT16_MAX; i++)
			GPacketHandler[i] = Handle_INVALID;
		GPacketHandler[PKT_C_LOGIN] = [](PacketSessionRef& session, BYTE* buffer, int32 len) { return HandlePacket<Protocol::C_LOGIN>(Handle_C_LOGIN, session, buffer, len); };
		GPacketHandler[PKT_C_ENTER_GAME] = [](PacketSessionRef& session, BYTE* buffer, int32 len) { return HandlePacket<Protocol::C_ENTER_GAME>(Handle_C_ENTER_GAME, session, buffer, len); };
		GPacketHandler[PKT_C_CHAT] = [](PacketSessionRef& session, BYTE* buffer, int32 len) { return HandlePacket<Protocol::C_CHAT>(Handle_C_CHAT, session, buffer, len); };
	}

	static bool HandlePacket(PacketSessionRef& session, BYTE* buffer, int32 len)
	{
		PacketHeader* header = reinterpret_cast<PacketHeader*>(buffer);
		return GPacketHandler[header->id](session, buffer, len);
	}
	static SendBufferRef MakeSendBuffer(Protocol::S_LOGIN& pkt) { return MakeSendBuffer(pkt, PKT_S_LOGIN); }
	static SendBufferRef MakeSendBuffer(Protocol::S_ENTER_GAME& pkt) { return MakeSendBuffer(pkt, PKT_S_ENTER_GAME); }
	static SendBufferRef MakeSendBuffer(Protocol::S_CHAT& pkt) { return MakeSendBuffer(pkt, PKT_S_CHAT); }

private:
	template<typename PacketType, typename ProcessFunc>
	static bool HandlePacket(ProcessFunc func, PacketSessionRef& session, BYTE* buffer, int32 len)
	{
		PacketType pkt;
		if (pkt.ParseFromArray(buffer + sizeof(PacketHeader), len - sizeof(PacketHeader)) == false)
			return false;

		return func(session, pkt);
	}

	template<typename T>
	static SendBufferRef MakeSendBuffer(T& pkt, uint16 pktId)
	{
		const uint16 dataSize = static_cast<uint16>(pkt.ByteSizeLong());
		const uint16 packetSize = dataSize + sizeof(PacketHeader);

		SendBufferRef sendBuffer = GSendBufferManager->Open(packetSize);
		PacketHeader* header = reinterpret_cast<PacketHeader*>(sendBuffer->Buffer());
		header->size = packetSize;
		header->id = pktId;
		ASSERT_CRASH(pkt.SerializeToArray(&header[1], dataSize));
		sendBuffer->Close(packetSize);

		return sendBuffer;
	}
};

그럼 이제 ClientPacketHandler 와 ServerPacketHandler 각각에서 실제 동작을 구현해 주어야 하는데... 그 전에, 일단 Room 이라는 클래스를 하나 만들어 보자.

보통 채팅을 주고 받게 되면, 해당 Room 에 있는 모든 사람에게 동일한 메시지가 전달되어야 한다. 우리는 Room 이라는 개념을 다음과 같이 간단하게 구현할 것이다 :

Room.h

#pragma once

class Room
{
public:
	void Enter(PlayerRef player);
	void Leave(PlayerRef player);
	void Broadcast(SendBufferRef sendBuffer);

private:
	USE_LOCK;
	map<uint64, PlayerRef> _players;
};

extern Room GRoom;

 

Room.cpp

#include "pch.h"
#include "Room.h"
#include "Player.h"
#include "GameSession.h"

Room GRoom;

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

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

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

사실 구현은 매우 간단해 보인다. 그냥 map 에 플레이어 정보를 캐싱해 둔 다음, 메시지를 Room 에 있는 모든 플레이어에게 쏘는 방식이다. 참고로, Player 클래스는 아래처럼 생겼다 :

Player.h

class Player
{
public:

	uint64					playerId = 0;
	string					name;
	Protocol::PlayerType	type = Protocol::PLAYER_TYPE_NONE;
	GameSessionRef			ownerSession; // Cycle
};

보면, Player 가 GameSessionRef 를 갖고 있어서, 사이클이 발생할 수 있는데... 일단, 이건 나중에 해결하도록 하겠다 😅

간단하게 ClientPacketHandler 부터 구현을 살펴보자.

 

ClientPacketHandler.cpp

#include "pch.h"
#include "ClientPacketHandler.h"
#include "Player.h"
#include "Room.h"
#include "GameSession.h"

PacketHandlerFunc GPacketHandler[UINT16_MAX];

// 직접 컨텐츠 작업자

bool Handle_INVALID(PacketSessionRef& session, BYTE* buffer, int32 len)
{
	PacketHeader* header = reinterpret_cast<PacketHeader*>(buffer);
	// TODO : Log
	return false;
}

bool Handle_C_LOGIN(PacketSessionRef& session, Protocol::C_LOGIN& pkt)
{
	GameSessionRef gameSession = static_pointer_cast<GameSession>(session);

	// TODO : Validation 체크

	Protocol::S_LOGIN loginPkt;
	loginPkt.set_success(true);

	// DB에서 플레이 정보를 긁어온다
	// GameSession에 플레이 정보를 저장 (메모리)

	// ID 발급 (DB 아이디가 아니고, 인게임 아이디)
	static Atomic<uint64> idGenerator = 1;

	{
		auto player = loginPkt.add_players();
		player->set_name(u8"DB에서긁어온이름1");
		player->set_playertype(Protocol::PLAYER_TYPE_KNIGHT);

		PlayerRef playerRef = MakeShared<Player>();
		playerRef->playerId = idGenerator++;
		playerRef->name = player->name();
		playerRef->type = player->playertype();
		playerRef->ownerSession = gameSession;
		
		gameSession->_players.push_back(playerRef);
	}

	{
		auto player = loginPkt.add_players();
		player->set_name(u8"DB에서긁어온이름2");
		player->set_playertype(Protocol::PLAYER_TYPE_MAGE);

		PlayerRef playerRef = MakeShared<Player>();
		playerRef->playerId = idGenerator++;
		playerRef->name = player->name();
		playerRef->type = player->playertype();
		playerRef->ownerSession = gameSession;

		gameSession->_players.push_back(playerRef);
	}

	auto sendBuffer = ClientPacketHandler::MakeSendBuffer(loginPkt);
	session->Send(sendBuffer);

	return true;
}

bool Handle_C_ENTER_GAME(PacketSessionRef& session, Protocol::C_ENTER_GAME& pkt)
{
	GameSessionRef gameSession = static_pointer_cast<GameSession>(session);

	uint64 index = pkt.playerindex();
	// TODO : Validation

	PlayerRef player = gameSession->_players[index]; // READ_ONLY?
	GRoom.Enter(player); // WRITE_LOCK

	Protocol::S_ENTER_GAME enterGamePkt;
	enterGamePkt.set_success(true);
	auto sendBuffer = ClientPacketHandler::MakeSendBuffer(enterGamePkt);
	player->ownerSession->Send(sendBuffer);

	return true;
}

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.Broadcast(sendBuffer); // WRITE_LOCK

	return true;
}

일단 Handle_INVALID 는 넘어가자. Handle_C_LOGIN 의 경우, GameSession 을 받아서 Validation 체크를 해 준 다음, DB에서 플레이 정보를 읽어 GameSession 에 넣어 줄 것이다.

DB에 정보가 있는데 굳이 메모리에 올리는 이유는, 추후 이 플레이 정보를 또 필요로 하는데, 그때마다 DB 를 조회하면 제법 큰 비용이 발생하기 때문이다. 아래 코드에서는 긁어온 정보를 각 플레이어에게 씌워주고 있다. Handle_C_ENTER_GAME 의 경우도 비슷하게 동작할 것이다.

그리고... 대망의 Handle_C_CHAT 을 보면, 그냥 chatPkt 에서 메시지를 뽑은 다음, GRoom 에 대해 Broadcast 만 해 주고 있다. 실제로는 이렇게까지 간단하진 않겠지만.. 일단은 이런 식으로 코드를 만들어 보자 😅

 

사실 클라이언트 쪽에서는 패킷 처리가 더 간단하다. 아래 예시를 보자.

ServerPacketHandler.cpp

#include "pch.h"
#include "ServerPacketHandler.h"

PacketHandlerFunc GPacketHandler[UINT16_MAX];

// 직접 컨텐츠 작업자

bool Handle_INVALID(PacketSessionRef& session, BYTE* buffer, int32 len)
{
	PacketHeader* header = reinterpret_cast<PacketHeader*>(buffer);
	// TODO : Log
	return false;
}

bool Handle_S_LOGIN(PacketSessionRef& session, Protocol::S_LOGIN& pkt)
{
	if (pkt.success() == false)
		return true;

	if (pkt.players().size() == 0)
	{
		// 캐릭터 생성창
	}

	// 입장 UI 버튼 눌러서 게임 입장
	Protocol::C_ENTER_GAME enterGamePkt;
	enterGamePkt.set_playerindex(0); // 첫번째 캐릭터로 입장
	auto sendBuffer = ServerPacketHandler::MakeSendBuffer(enterGamePkt);
	session->Send(sendBuffer);

	return true;
}

bool Handle_S_ENTER_GAME(PacketSessionRef& session, Protocol::S_ENTER_GAME& pkt)
{
	// TODO
	return true;
}

bool Handle_S_CHAT(PacketSessionRef& session, Protocol::S_CHAT& pkt)
{
	std::cout << pkt.msg() << endl;
	return true;
}

일단은 뭐... 별다른 UI 도 없으니, 로그만 찍어줘도 충분하다.

 

이렇게 간단한 채팅서버를 만들 수 있다. DummyClient 에서는 아래와 같이 채팅을 보낼 것이다!

Protocol::C_CHAT chatPkt;
chatPkt.set_msg(u8"Hello World !");
auto sendBuffer = ServerPacketHandler::MakeSendBuffer(chatPkt);

while (true)
{
    service->Broadcast(sendBuffer);
    this_thread::sleep_for(1s);
}

 

그런데 한 가지 문제가 있다... 실제로 Room 에서 채팅 메시지를 Broadcast 하는 부분을 보면 아래와 같은데,

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

사실 위 코드를 보면, 뭔가 딱 봐도 병목 현상이 매우 많이 발생할 수 있을 것 같은 생각이 든다.

채팅을 보내고 이를 전파할 때 WRITE_LOCK 을 걸기 때문에, 재수가 없으면 100 개의 쓰레드가 병합할 때, 나머지 99 개의 쓰레드는 손가락만 빨고 있을 수밖에 없다. 더군다나 ownerSession->Send 함수도 꽤 무거운 함수이므로, 위와 같은 구조는 실제 채팅을 빠르게 주고 받는 것에 있어 애로 사항이 많다.

이를 해결하기 위해, Job 의 개념(+ JobQueue) 을 이용해 병목현상을 해결할 것인데... 이 부분은 다음 글부터 차근히 다루도록 하겠다 😉

Comments