KoreanFoodie's Study

[C++ 게임 서버] 5-8. 패킷 자동화 #1 본문

Game Dev/Game Server

[C++ 게임 서버] 5-8. 패킷 자동화 #1

GoldGiver 2023. 12. 18. 19:41

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

[C++ 게임 서버] 5-8. 패킷 자동화 #1

핵심 :

1. 효율적인 작업을 위해, 패킷이 변동될 때 빌드를 하면 관련 protoc 파일 등이 자동으로 업뎃되게 만들면 좋다. 배치파일과 .vcxproj 를 잘 수정해 보자.

2. 초기화 단계에서 각 패킷 별로 호출되어야 하는 함수를 Functor 로 만들어 각 패킷에 대응시켜 놓으면, 각 컨텐츠 담당자는 패킷이 추가될 때 해당 패킷에 대한 구현만 신경쓰면 된다.

저번 시간에는 Protobuf 를 세팅하면서, 패킷 작업을 어떻게 하면 되는지에 대해 배웠다.

그런데 사실 돌이켜 보면 불편한 점이 이곳 저곳에 산재한다는 느낌이 든다. 왜냐하면 결국 우리가 패킷을 만들거나 수정하면, .protoc 파일을 매번 새로 생성해 줘야 하는데, 심지어 이 작업을 GameServer 와 DummyClient 모두에게 지원해 줘야 했었다.

사실 배치 파일이나 .vcvproj 파일을 일부 수정함으로써, 프로젝트 빌드를 하면 배치파일이 알아서 돌아가 protobuf 와 관련된 추가 처리들을 신경 쓰지 않도록 만들 수도 있다.

사실 위 내용들을 하나하나 이 포스팅에 넣는 것은 글의 취지와 맞지 않으니, 세팅에 대한 부분은 구글링을 통해 해결하거나 필요할 때 강의를 다시 참고하도록 하자. 😅 절대 귀찮아서 말로 때우는 것이다. 😛

예를 들어, .vcxproj 에 다음 내용을 넣으면...

  <ItemGroup>
    <UpToDateCheckInput Include="..\Common\Protobuf\bin\Enum.proto" />
    <UpToDateCheckInput Include="..\Common\Protobuf\bin\Protocol.proto" />
    <UpToDateCheckInput Include="..\Common\Protobuf\bin\Struct.proto" />
  </ItemGroup>

해당 파일이 바뀌는 것에 맞게 업데이트가 일어난다.

참고로, 강의에서는 GenPackets.bat 파일에 다음과 같은 스크립트를 넣었다 :

pushd %~dp0
protoc.exe -I=./ --cpp_out=./ ./Enum.proto
protoc.exe -I=./ --cpp_out=./ ./Struct.proto
protoc.exe -I=./ --cpp_out=./ ./Protocol.proto
IF ERRORLEVEL 1 PAUSE

XCOPY /Y Enum.pb.h "../../../GameServer"
XCOPY /Y Enum.pb.cc "../../../GameServer"
XCOPY /Y Struct.pb.h "../../../GameServer"
XCOPY /Y Struct.pb.cc "../../../GameServer"
XCOPY /Y Protocol.pb.h "../../../GameServer"
XCOPY /Y Protocol.pb.cc "../../../GameServer"

XCOPY /Y Enum.pb.h "../../../DummyClient"
XCOPY /Y Enum.pb.cc "../../../DummyClient"
XCOPY /Y Struct.pb.h "../../../DummyClient"
XCOPY /Y Struct.pb.cc "../../../DummyClient"
XCOPY /Y Protocol.pb.h "../../../DummyClient"
XCOPY /Y Protocol.pb.cc "../../../DummyClient"

 

흠! 어쨌든, 이러쿵 저러쿵 해서 패킷 자동화 처리는 잘 되었다(?). 그럼 이제 이에 맞춰서 PacketHandler 부분이 조금 변화되게 되는데... 먼저 ServerPacketHandler 가 어떻게 변했는지를 통해 이를 알아보자.

#include "Protocol.pb.h"

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

// TODO : 자동화
enum : uint16
{
	PKT_S_TEST = 1,
	PKT_S_LOGIN = 2,
};

일단 PacketHandlerFunc 라는, 패킷 종류별로 Handling 을 해주는 Functor 타입을 정의하고, UINT16_MAX 갯수 만큼 이를 선언해 준다.

지금은 예제 프로젝트라 패킷이 2 개(PKT_S_TEST, PKT_S_LOGIN) 두 개 밖에 없는데, 실제 프로젝트에서는 갯수가 수천, 수만개 까지 늘어날 수 있다. 😮

 

이제 본격적으로 각 패킷을 어떻게 Handling 하는지, 또 어떤 식으로 자동화를 하는지 보자.

// TODO : 자동화
// Custom Handlers
bool Handle_INVALID(PacketSessionRef& session, BYTE* buffer, int32 len);
bool Handle_S_TEST(PacketSessionRef& session, Protocol::S_TEST& pkt);

class ServerPacketHandler
{
public:
	// TODO : 자동화
	static void Init()
	{
		for (int32 i = 0; i < UINT16_MAX; i++)
			GPacketHandler[i] = Handle_INVALID;

		GPacketHandler[PKT_S_TEST] = [](PacketSessionRef& session, BYTE* buffer, int32 len) { return HandlePacket<Protocol::S_TEST>(Handle_S_TEST, 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);
	}

	// TODO : 자동화
	static SendBufferRef MakeSendBuffer(Protocol::S_TEST& pkt) { return MakeSendBuffer(pkt, PKT_S_TEST); }

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;
	}
};

Init 부분을 보면, 일단 패킷 Handler 함수들을 기본적으로 Handle_INVALID 로 만들어 놓고, 우리가 실제로 처리하길 원하는 함수에 대해서는 패킷에 맞는 실제 Handler 를 넣어 주고 있다.

실제로 PKT_S_TEST 의 경우, HandlePacket 함수가 PacketType 과 ProcessFunc 를 받아 Custom 하게 작성된 함수 Handle_S_TEST 가 돌아가게 될 것임을 예측할 수 있다.

조금 더 쉽게 풀어 설명하자면, PacketType 에 대한 부분은 enum 클래스를 통해 공통적으로 처리(ParseFromArray)되고, 파싱된 패킷에 대한 실제 처리는 프로그래머가 직접 작성한 패킷 핸들링 함수(Handle_S_TEST)를 통해 이뤄진다.

만약 PKT_S_LOGIN 패킷에 대한 처리가 필요하다면, Handle_S_LOGIN 함수를 하나 만들어 주고, 아래 코드를 Init 내부에 추가해 주기만 하면 될 것이다! 😉

GPacketHandler[PKT_S_LOGIN] = [](PacketSessionRef& session, BYTE* buffer, int32 len) 
{ return HandlePacket<Protocol::S_LOGIN>(Handle_S_LOGIN, session, buffer, len); };

참고로 non-template version 의 HandlePacket 함수도 있는데, 이 녀석은 Init 에서 저장한 functor 를 호출해 주는 역할을 해 줄 것이다.

그럼 ServerPakcetHandler.cpp 에선, 아래와 같이 컨텐츠 작업자가 각 패킷에 맞는 동작을 구현해 주면 된다.

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_TEST(PacketSessionRef& session, Protocol::S_TEST& pkt)
{
	// TODO

	return true;
}

 

ClientPacketHandler 에서의 작업도 매우 비슷하다. 사실 거의 똑같은 수준이니, 아래와 같이 헤더 파일의 코드를 쓱 보고 넘어가도록 하자.

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

// TODO : 자동화
enum : uint16
{
	PKT_S_TEST = 1,
	PKT_S_LOGIN = 2,
};


// TODO : 자동화
// Custom Handlers
bool Handle_INVALID(PacketSessionRef& session, BYTE* buffer, int32 len);
bool Handle_S_TEST(PacketSessionRef& session, Protocol::S_TEST& pkt);

class ClientPacketHandler
{
public:
	// TODO : 자동화
	static void Init()
	{
		for (int32 i = 0; i < UINT16_MAX; i++)
			GPacketHandler[i] = Handle_INVALID;

		GPacketHandler[PKT_S_TEST] = [](PacketSessionRef& session, BYTE* buffer, int32 len) { return HandlePacket<Protocol::S_TEST>(Handle_S_TEST, 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);
	}

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

이제 구현부인 .cpp 파일에서는, 아래와 같이 로그를 찍어주는 기능 등을 구현해 주면 된다. 😁

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_TEST(PacketSessionRef& session, Protocol::S_TEST& pkt)
{
	// TODO
	cout << pkt.id() << " " << pkt.hp() << " " << pkt.attack() << endl;

	cout << "BUFSIZE : " << pkt.buffs_size() << endl;

	for (auto& buf : pkt.buffs())
	{
		cout << "BUFINFO : " << buf.buffid() << " " << buf.remaintime() << endl;
		cout << "VICTIMS : " << buf.victims_size() << endl;
		for (auto& vic : buf.victims())
		{
			cout << vic << " ";
		}

		cout << endl;
	}

	return true;
}
Comments