KoreanFoodie's Study

[C++ 게임 서버] 5-7. Protobuf 본문

Game Dev/Game Server

[C++ 게임 서버] 5-7. Protobuf

GoldGiver 2023. 12. 16. 17:43

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

[C++ 게임 서버] 5-7. Protobuf

핵심 :

1. protobuf 는 구조화된 데이터를 직렬화하는 방식으로, 구글에서 제공하는 툴을 의미하기도 한다.

2. protobuf 를 사용하면 임시 객체에 대한 복사는 발생할 수도 있으나, 직렬화와 역직렬화 과정이 매우 편리해진다는 장점이 있다.

3. protobuf 는 인터페이스가 간단하고 명확하며, 다른 언어 및 OS 를 사용하는 타 서버와의 연동이 쉬워 협업에도 매우 유리하다.

이번 시간에는 구글에서 만든 데이터 직렬화 툴을 지금까지 만든 샘플 프로젝트에 적용해 볼 것이다. 이번에는 결과만 보여주는 것이 아니라, 설치 및 세팅하는 과정을 자세히 기록할 것이니, 추후 세팅에 어려움을 겪을 때 이 글이 도움이 되었으면 좋겠다. 🤗

 

먼저, protobuf 를 다운받아 보자. 일단 이 링크 에서, protobuf 바이너리 및 소스를 받을 수 있다. 압축을 풀고 내용을 보면...

음. 매우 단촐하게 세팅이 되어 있고, bin 을 열면 아래 녀석 하나가 있다.

이 녀석이, 추후 실질적으로 우리가 만들 .proto 파일을 변환하는 역할을 수행하게 된다.

우리는 일단 그 위치에 Protocol.proto 라는 파일을 하나 만들고, 비주얼 스튜디오에서 열어 편집을 해 볼 것이다!

흠.. 그럼 proto 에는 무엇을 채워 주어야 할까?

일단, protocol buffers 공식 문서를 보면, syntax 에 대한 내용이 나와 있어 문서를 따라가면 되긴 한다. protobuff 2 와 3 은 사용하는 데 있어 차이가 큰데, 일단 여기서는 protobuff 3 버전을 사용할 것이다.

방금 만든 Protocol.proto 는, 이전에 만든 S_TEST 패킷의 xml 형식과 동일한 데이터 필드를 가지도록 구성하고 싶다. 참고로 xml 은 이렇게 생겼었는데...

<?xml version="1.0" encoding="utf-8"?>
<PDL>
	<Packet name="S_TEST" desc="테스트 용도">
		<Field name="id" type="uint64" desc=""/>
		<Field name="hp" type="uint32" desc=""/>
		<Field name="attack" type="uint16" desc=""/>
		<List name="buffs" desc="">
			<Field name="buffId" type="uint64" desc=""/>
			<Field name="remainTime" type="float" desc=""/>
			<List name="victims">
				<Field name="userId" type="uint64"/>
			</List>
		</List>
	</Packet>
</PDL>

id, hp, attack 및 가변 데이터인 buffs 를 담고 있음을 알 수 있다. 이에 맞게, Protocol.proto 는 아래처럼 채워주자.

syntax = "proto3";
package Protocol;

message BuffData
{
	uint64 buffId = 1;
	float remainTime = 2;
	repeated uint64 victims = 3;
}

message S_TEST
{
	uint64 id = 1; // 1 은 초기값이 아니라 순서에 대한 표기
	uint64 hp = 2; 
	uint16 attack = 3;
	repeated BuffData buffs = 4;
}

잘 보면, 가변 데이터 표시를 위해 'repeated' 라는 키워드를 사용하고 있는 것을 알 수 있다. 😁

자, 이제 대충 구조를 잡았으면, 이제 우리가 사용할 언어에 대해 C++ 버전의 코드를 만들어 주어야 하는데...

공식 문서에서 Generating Your Classes 부분을 보면 어떻게 하는지에 대한 가이드가 잘 나와 있다.

일단 여기서는, .bat 파일을 만들어서 변환을 해 줄 예정인데, 이름은 그냥 GenPackets.bat 으로 만들어 보자.

protoc.exe -I=./ --cpp_out=./ ./Protocol.proto
IF ERRORLEVEL 1 PAUSE

내용은 위처럼 채워준다. 일단, protoc.exe 를 이용해 변환을 해 줄 것이고, import 하는 옵션에서 파일 경로는 자기 자신이므로, './' 로 만들어 주었다. 현재는 cpp 파일로만 변환하면 되니 나머지는 지워주고, 대상은 ./Protocol.proto 로 지정했다.

만약 문제가 있으면, 멈춰주도록 PAUSE 문을 추가했다. 이제 이걸 돌려보면...

귀신 같이 에러가 뜨면서 PAUSE 가 되는 것을 확인할 수 있다. 🤣

사실 뭔가 잘못 쓴 것은 아니고, 문서의 Scalar Types 쪽을 잘 읽어보면...

uint16 은 지원하지 않음을 알 수 있다. 그런데 우리는 이전에 attack 을 uint16 으로 정의했으므로, 이 녀석을 uint32 로 바꿔주기만 하면 된다! 😉 참고로, int32 쪽 설명을 보면 가변 길이 인코딩을 사용한다고 되어 있는데, 이는 만약 숫자가 그리 크지 않을 경우 꼭 4바이트를 사용하지 않고 압축해서 표현하기도 한다... 정도로 받아들이면 된다.

 

그럼 이제 실행을 하면, 다음과 같이 파일이 생성된다 :

우리는 생성된 두 pb.cc 와 .pb.h 파일을 일단은 복사하여 GameServer 와 DummyClient 프로젝트가 있는 곳에 넣어주도록 하겠다! (나중에는 배치파일이 자동으로 각 프로젝트로 복사되도록 만들 것이지만, 일단 이번 글에서는 그냥 수동으로 복사할 것임)

복사를 하면 슬프게도 아래처럼 빨간 줄이 마구 뜨게 된다. 😕

하지만 이것은 당연한 게, 아직 라이브러리를 연결해주지 않아서 그렇다. 😂 일단은, 에러들 중에 미리 pch 와 관련된 에러만 먼저 간단히 수정해 주자.

 

이제 라이브러리를 프로젝트에게 제공해 주자. 일단 기존에 사용하던 프로젝트의 Libraries 폴더를 조금 세분화시킬 것이다.

예전에는 사용하던 라이브러리가 ServerCore 밖에 없었지만, 추후 여러 가지가 추가될 수 있으므로 아래와 같이 바꿔주자 :

Libraries
 - include
 - Libs
   - Protobuf
     - Debug
     - Release
   - ServerCore
     - Debug
     - Release

참고로, ServerCore 안의 Debug 폴더 안에는, 우리가 이전에 만들어 생성되었던 lib 파일 등이 존재할 것이다.

이제 실제로 라이브러리 디렉터리를 편집해 보자.

위처럼 수정해 주면 된다!

아, 포함 디렉터리도 비슷하게 아래처럼 수정해 주어야 한다! (DummyClient 도 동일하게 진행해 주자)

참고로, GameServer 의 pch.h 를 보면, 예전에는 아래처럼 라이브러리를 읽는 곳을 명시해 주곤 했다 :

#pragma once

#define WIN32_LEAN_AND_MEAN // 거의 사용되지 않는 내용을 Windows 헤더에서 제외합니다.

#ifdef _DEBUG
#pragma comment(lib, "Debug\\ServerCore.lib")
#else
#pragma comment(lib, "Release\\ServerCore.lib")
#endif

#include "CorePch.h"

이제 이 부분도 바꾸어 주어야 할 것이다. 😅

#pragma once

#define WIN32_LEAN_AND_MEAN // 거의 사용되지 않는 내용을 Windows 헤더에서 제외합니다.

#ifdef _DEBUG
#pragma comment(lib, "ServerCore\\Debug\\ServerCore.lib")
#else
#pragma comment(lib, "ServerCore\\Release\\ServerCore.lib")
#endif

#include "CorePch.h"

위 작업은 DummyClient 에서도 똑같이 해 주어야 한다.

 

이제 실제 라이브러리를 다운로드해 보자. 구글에 google protobuf c++ library 라고 치면 바로 깃헙 링크가 나온다. zip 파일을 다운받으면... 이제 CMake 를 이용해 빌드를 해 주어야 한다. (CMake 는 알아서 다운)

경로는 다음처럼... CMake 폴더를 넣어준다(예시임) : C:/Downloads/protobuf-main/protobuf-main

그리고, binaries 가 나오는 위치는 cmake 대신 solution 이라고 적어서 해당 위치에 .sln 파일을 만들도록 하겠다.

Generate 를 누르고, VS2019 로 처음 실행하면 에러가 뜨는데, 너무 당황하지 말자. 😂

어차피 필요한 건 위 두 놈이라, 두 놈만 체크하고 다시 돌려보자. 그럼 잘 Generate 가 된다 😉

그럼 solution 폴더에 위처럼 프로젝트가 잘 생성된 것을 확인할 수 있다.이제 protobuf.sln 을 열어서, 

ALL_BUILD 에 대해 솔루션 다시 빌드를 해 주면 된다! 이 작업은 Debuf/Release 에 대해 각각 1 번씩 해 주자.

... 그런데, 최신 버전의 경우, 아래와 같은 에러가 발생한다 :

따라서 실습에서는 이전 버전인 3.17 을 썼던 것 같은데...; 여기서 발목이 잡히는 건 솔직히 좀 시간 낭비이니, 3.17 버전으로 넘어가서 이어가도록 하겠다.

흠흠. 어쨌든, solution 의 Release 폴더에서, 만든 lib 파일들 중 dll 과 lib 파일을 뽑아내서 Libraries > Libs > Protobuf > Debug/Release 아래로 복붙을 해 주자.

그리고, Protobuf 소스의 include 에 보면 Google 이라는 폴더가 있는데...

이 녀석도 그대로 복사해서 우리 프로젝트의 include 폴더 안에 넣어주도록 하자.

그리고 나서 프로젝트를 실행하면, 링크 에러가 뜬다! 이는 우리가 방금 복사해 준 Protobuf 의 라이브러리를 찾을 수 없어서 그런 것인데... GameServer/DummyClient 의 pch.h 에서 protobuf 부분을 추가해주면 된다 :

#pragma once

#define WIN32_LEAN_AND_MEAN // 거의 사용되지 않는 내용을 Windows 헤더에서 제외합니다.

#ifdef _DEBUG
#pragma comment(lib, "ServerCore\\Debug\\ServerCore.lib")
#pragma comment(lib, "Protobuf\\Debug\\libprotobufd.lib")
#else
#pragma comment(lib, "ServerCore\\Release\\ServerCore.lib")
#pragma comment(lib, "Protobuf\\Release\\libprotobuf.lib")
#endif

#include "CorePch.h"

아, 그리고 dll 을 찾을 수 없다는 에러가 나온다면, libprotobufd.dll 파일을 Binary > Debug/Release 경로에 넣어 주어야 한다!

lib 는 static library 여서 상관없었지만, dll 은 dynamic 한 녀석이라, .exe 파일이 이 녀석을 필요로 하기 때문이다 😆

 

이제 세팅은 전부 끝났다. 그럼 '진짜로' Protobuf 를 사용해보자!

먼저, GameServer 에 아래 헤더를 추가한다.

#include "Protocol.pb.h"

그럼 실제로 버퍼를 만드는 부분은... 다음과 같이 간략화되게 된다 😉

while (true)
{
    Protocol::S_TEST pkt;
    pkt.set_id(1000);
    pkt.set_hp(100);
    pkt.set_attack(10);
    {
        Protocol::BuffData* data = pkt.add_buffs();
        data->set_buffid(100);
        data->set_remaintime(1.2f);
        data->add_victims(4000);
    }
    {
        Protocol::BuffData* data = pkt.add_buffs();
        data->set_buffid(200);
        data->set_remaintime(2.5f);
        data->add_victims(1000);
        data->add_victims(2000);
    }

    SendBufferRef sendBuffer = ServerPacketHandler::MakeSendBuffer(pkt);
    GSessionManager.Broadcast(sendBuffer);

    this_thread::sleep_for(250ms);
}

우리는 Protocol 이라는 이름의 Package 내부의 S_TEST 를 사용하여 패킷을 만들 것이다.

set_... 함수를 이용해 데이터를 세팅하면 되고, 가변 데이터의 경우에는 포인터를 사용하는 것처럼 간단하게 쓸 수 있음을 확인할 수 있다. (가변 데이터에 대해서는 add_... 를 쓰고 있다)

물론, 가변 데이터를 다룰 때, 위처럼 하나하나 추가할 필요 없이 mutable_buffs 같은 것을 사용할 수도 있는데... 일단 이번 글에서는 다루지 않겠다. 😊

protobuf 에는 파싱, 머지, Serialize... 등등등 API 가 많은데, 이것을 자주 사용할 예정이므로 ServerPacketHandler 쪽에 MakeSendBuffer 라는 이름으로 데이터를 파싱할 수 있는 함수를 만들었다.

class ServerPacketHandler
{
public:
	static void HandlePacket(BYTE* buffer, int32 len);

	static SendBufferRef MakeSendBuffer(Protocol::S_TEST& pkt);
};

template<typename T>
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;
}

잘 읽어보면 된다. 생소한 부분은 SerializeToArray 정도인데, 함수 명만 봐도 무슨 역할을 하는지 감이 온다! 😀

 

그럼 클라이언트에서 이것을 읽는 부분도 말들어 주자.

void ClientPacketHandler::Handle_S_TEST(BYTE* buffer, int32 len)
{
	Protocol::S_TEST pkt;

	ASSERT_CRASH(pkt.ParseFromArray(buffer + sizeof(PacketHeader), len - sizeof(PacketHeader)));

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

여기서는 ParseFromArray 를 이용해서 헤더를 제외한 데이터를 잘 읽어주고 있음을 알 수 있다. 😮

Comments