KoreanFoodie's Study
[C++ 게임 서버] 6-1. 채팅 실습 본문
[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) 을 이용해 병목현상을 해결할 것인데... 이 부분은 다음 글부터 차근히 다루도록 하겠다 😉
'Game Dev > Game Server' 카테고리의 다른 글
[C++ 게임 서버] 6-3. JobQueue #2 (0) | 2023.12.20 |
---|---|
[C++ 게임 서버] 6-2. JobQueue #1 (1) | 2023.12.19 |
[C++ 게임 서버] 5-9. 패킷 자동화 #2 (0) | 2023.12.18 |
[C++ 게임 서버] 5-8. 패킷 자동화 #1 (0) | 2023.12.18 |
[C++ 게임 서버] 5-7. Protobuf (0) | 2023.12.16 |