Study/C++ & C#

[C++] Buffer Helpers

BVM 2023. 7. 7. 23:57

2023.07.08 수정
rValue Ref와 Universal Ref가 혼동된 부분 수정.

 


 

패킷 직렬화 공부 과정의 첫 삽을 뜨게 됐다.

Protobuf를 사용하면 어느 정도는 그냥 넘어갈 수도 있겠으나,

동작 원리와 그 장단점에 대해 아는 것이 중요하다는 그 말이 가슴에 남는다.

결국 알아야 더 잘 사용할 수 있는 게 아닐까.

 

 

1. 본격적으로 들어가기 전에

기존에 구현한 버퍼를 사용하는 방법에 대해 돌아보자.

 

SendBufferRef sendBuffer = GSendBufferManager->Open(4096);

BYTE* buffer = sendBuffer->Buffer();
((PacketHeader*)buffer)->size = (sizeof(sendData) + sizeof(PacketHeader));
((PacketHeader*)buffer)->id = 1; // 1 : Hello Msg
::memcpy(&buffer[4], sendData, sizeof(sendData));
sendBuffer->Close((sizeof(sendData) + sizeof(PacketHeader)));

GSessionManager.Broadcast(sendBuffer);

this_thread::sleep_for(250ms);

한눈에 보기에 딱 들어오는 코드라고는 할 수 없겠다. 

그리고 작성하는 데 있어서 번거롭기도 하다.

이 과정을 보다 가독성이 좋게, 작성하는 데에 있는 불편함을 줄이기 위해 헬퍼 클래스를 만드는 것이 목적이다.

 

 

2. Buffer Reader

BufferReader는 Recv한 버퍼를 분해해서 우리가 활용할 수 있는 형태로 만들어 줄 헬퍼 클래스이다.

먼저 전체적인 모습을 살펴보자.

 

#pragma once

class BufferReader
{
public:
	BufferReader();
	BufferReader(BYTE* buffer, uint32 size, uint32 pos = 0);
	~BufferReader();

	BYTE*			Buffer() { return _buffer; }
	uint32			Size() { return _size; }
	uint32			ReadSize() { return _pos; }
	uint32			FreeSize() { return _size - _pos; }

	template<typename T>
	bool			Peek(T* dest) { return Peek(dest, sizeof(T)); }
	bool			Peek(void* dest, uint32 len);

	template<typename T>
	bool			Read(T* dest) { return Read(dest, sizeof(T)); }
	bool			Read(void* dest, uint32 len);

	template<typename T>
	BufferReader&	operator>>(OUT T& dest);

private:
	BYTE*			_buffer = nullptr;
	uint32			_size = 0;
	uint32			_pos = 0;
};

template<typename T>
inline BufferReader& BufferReader::operator>>(OUT T& dest)
{
	dest = *reinterpret_cast<T*>(&_buffer[_pos]);
	_pos += sizeof(T); // 데이터를 읽을 때 마다 커서의 위치를 옮겨야 함
	return *this;
}

이 클래스는 기본적으로 3개의 변수를 가진다.

  1. _buffer
      - 넘겨받은 버퍼의 포인터를 갖고 있을 것이다.
  2. _size
      - 버퍼의 크기를 가질 것이고,
  3. _pos
      - 현재 커서의 위치를 나타낸다.

나머지 함수들도 정의를 보면 역할들이 예상이 간다.

중요한 아래의 템플릿과 오퍼레이터 오버로딩에 대해서 알아보자.

 

 

2-1. 템플릿

Peek은 커서를 옮기지 않고 현재 위치의 데이터를 보기 위해 사용한다.

bool BufferReader::Peek(void* dest, uint32 len)
{
	if (FreeSize() < len)
		return false;

	::memcpy(dest, &_buffer[_pos], len);
	return true;
}

읽으려는 길이가 여유공간보다 크다면 false를 리턴한다.

이건 Read에도 그대로 들어간다.

 

dest에 해당 길이만큼의 데이터를 복사해 주고 true를 리턴한다.

Read는 복사를 해 주고 그만큼 커서의 위치를 옮긴다.

Read의 구현은 아래와 같다.

 

bool BufferReader::Read(void* dest, uint32 len)
{
	if (Peek(dest, len) == false)
		return false;

	_pos += len;
	return true;
}

 

템플릿 인자를 받는 부분을 보면 길이를 sizeof(T)로 넘겨주는데,

딱 그 자료형만큼의 크기만 뽑아올 때의 번거로움을 줄이기 위함이다.

 

 

2-2. 오퍼레이터

이번에 오버로딩한 오퍼레이터는 「>>」이다.

cin을 생각하면 왜 저 오퍼레이터가 당첨됐는지 감이 올 것이다.

다시 구현을 살펴보자.

 

template<typename T>
inline BufferReader& BufferReader::operator>>(OUT T& dest)
{
	dest = *reinterpret_cast<T*>(&_buffer[_pos]);
	_pos += sizeof(T); // 데이터를 읽을 때 마다 커서의 위치를 옮겨야 함
	return *this;
}

버퍼의 내용물을 캐스팅 해 넘겨준 후(꼭 reinterpret일 필요는 없다),

데이터를 읽었으므로 커서를 옮기고 this 포인터를 넘겨줘 연달아 사용할 수 있게 했다.

 

 

3. BufferWriter

이번엔 버퍼를 만들고 그 내용물을 채워줄 클래스이다.

 

#pragma once

class BufferWriter
{
public:
	BufferWriter();
	BufferWriter(BYTE* buffer, uint32 size, uint32 pos = 0);
	~BufferWriter();

	BYTE*			Buffer() { return _buffer; }
	uint32			Size() { return _size; }
	uint32			WriteSize() { return _pos; }
	uint32			FreeSize() { return _size - _pos; }

	template<typename T>
	bool			Write(T* src) { return Write(src, sizeof(T)); }
	bool			Write(void* src, uint32 len);

	template<typename T>
	T*			Reserve();

	//lValue Reference
	template<typename T>
	BufferWriter&	operator<<(const T& src);

	//rValue Reference
	template<typename T>
	BufferWriter&	operator<<(T&& src);

private:
	BYTE*			_buffer = nullptr;
	uint32			_size = 0;
	uint32			_pos = 0;
};

가지는 변수는 BufferRead와 동일하다.

BufferWrite가 가지는 함수들에 대해 알아보자.

 

  1. Write()
      - Read와의 차이는 버퍼에 쓰냐, 버퍼에서 가져오냐의 차이다.
  2. Reserver()
      - 넘겨준 데이터의 자료형의 크기만큼 버퍼에 공간을 예약한다.
      - 예약된 위치의 시작 포인터를 리턴하기 때문에 자유로운 시점에 활용할 수 있다.
  3. <<
      - cout의 「<<」를 생각하면 된다.
      - 무조건 lValue만 쓴다는 보장이 없으므로 rValue도 활용할 수 있게 했다.
      - 역시 this 포인터를 리턴하기 때문에 연달아 데이터를 넣을 수 있다.

 

구현은 아래와 같다.

template<typename T>
T* BufferWriter::Reserve()
{
	if (FreeSize() < sizeof(T))
		return nullptr;

	T* ret = reinterpret_cast<T*>(&_buffer[_pos]);
	_pos += sizeof(T);
	return ret;
}


// 오퍼레이터의 동작에 여유공간 체크가 없어 리스키 할 수 있으나
// 조금이라도 체크를 줄이는 것이 성능 향상이 도움이 됨
template<typename T>
BufferWriter& BufferWriter::operator<<(const T& src)
{
	// 크기가 작은 데이터를 복사할 때 memcpy()보다 조금 더 성능이 좋음
	*reinterpret_cast<T*>(&_buffer[_pos]) = src;
	_pos += sizeof(T);
	return *this;
}

/*
template<typename T>
BufferWriter& BufferWriter::operator<<(T&& src)
{
	// 오른값 참조기 때문에 move() 사용
	*reinterpret_cast<T*>(&_buffer[_pos]) = std::move(src);
	_pos += sizeof(T);
	return *this;
}
*/

template<typename T>
BufferWriter& BufferWriter::operator<<(T&& src)
{
	using DataType = std::remove_reference_t<T>;
	*reinterpret_cast<DataType*>(&_buffer[_pos]) = std::forward<DataType>(src);
	_pos += sizeof(T);
	return *this;
}

잦은 체크는 성능에 영향을 미칠 수 있기 때문에 오퍼레이터 동작에 대해선 체크를 생략한다.

 

rValue Ref와 Universal Ref는 쉽게 혼동될 수 있으니 주의해야 한다.

오퍼레이터의 uint64의 왼값이 들어왔다고 가정하자.

그럼 코드는 아래와 같은 형태라고 볼 수 있다.

template<typename T>
BufferWriter& BufferWriter::operator<<(T&& src)
{
	*reinterpret_cast<const uint64&*>(&_buffer[_pos]) = std::move(src);
    	// ...
}

레퍼런스의 포인터를 뽑으려고 하는 형태가 된다.

이 문제를 해결하기 위해선 uint64&에서 참조를 빼서 uint64로 만들어 줘야 한다.

따라서 DataType을 두고 remove_reference_t를 통해 참조를 제거했고, DataType을 활용하도록 했다.

그리고 오른값 참조가 아니기 때문에 forward()로 넘겨줄 수 있게 했다.

형식 연역이 일어나는 것을 보면서도 알아차리지 못한 것은 전적으로 나의 공부 부족이 원인이다...

 

4. 추가 내용 반영

새로운 헬퍼 클래스도 생겼으니, 기존 코드에 반영해야 한다.

클라이언트의 수신처리와 서버의 송신처리 부분을 수정할 것이다.

먼저 송신부터 해결해 보자.

 

4-1. Write

char sendData[1000] = "Hello World";

while (true)
{
	SendBufferRef sendBuffer = GSendBufferManager->Open(4096);

	BufferWriter bw(sendBuffer->Buffer(), 4096);

	PacketHeader* header = bw.Reserve<PacketHeader>();
	// id(uint64), 체력(uint32), 공격력(uint16)
	bw << (uint64)1001 << (uint32)100 << (uint16)10;
	bw.Write(sendData, sizeof(sendData));

	header->size = bw.WriteSize();
	header->id = 1; // 1 : Test Msg

	sendBuffer->Close(bw.WriteSize());

	GSessionManager.Broadcast(sendBuffer);

	this_thread::sleep_for(250ms);
}

흐름을 보면 아래와 같다.

 

  1. 버퍼를 연다
  2. BufferWriter를 만든다.
  3. 패킷 헤더의 사이즈만큼 예약한다.
      - 가변 데이터를 전송해야 할 경우, 전체 패킷의 사이즈를 알 수 없기 때문에 예약만 해 두고 나중에 채운다.
  4. 오퍼레이터를 사용하여 rValue 값들을 넣는다.
  5. Write()를 사용하여 미리 준비한 데이터를 넣는다.
  6. 모든 데이터를 다 넣었으므로 예약해 둔 공간에 최종 패킷 사이즈와 ID를 넣는다.
  7. 모든 사전 작업이 끝났으므로 버퍼를 닫는다.
  8. Broadcast
  9. 그리고 반복.

그리고 여기서 데이터를 넣은 순서를 잘 기억하자.

받아서 분해할 때 데이터를 넣어준 순서대로 읽어야 문제가 없다.

 

 

4-2. Read

virtual int32 OnRecvPacket(BYTE* buffer, int32 len) override
{
	BufferReader br(buffer, len);

	PacketHeader header;
	br >> header;

	// 보내준 순서 그대로 읽어야 함
	uint64 id;
	uint32 hp;
	uint16 attack;
	br >> id >> hp >> attack;

	cout << "ID: " << id << " HP : " << hp << " ATT : " << attack << endl;

	// 가변길이 처리에 대한 고민 필요
	// 나중에 패킷을 보날 때 가변길이의 데이터가 몇개 있는지 보내주게 됨
	char recvBuffer[4096];
	br.Read(recvBuffer, header.size - sizeof(PacketHeader) - 8 - 4 - 2);
	cout << recvBuffer << endl;

	return len;
}

보냈던 순서대로 오퍼레이터를 활용하여 데이터를 뽑아먹는 것을 확인할 수 있다.

그리고 아까 정수형 데이터 뿐만 아니라 문자열도 추가로 넣어 보냈다.

아까 앞의 3개의 데이터를 읽었으니 그 크기만큼 빼줘야 정상적으로 문자열을 읽어올 수 있을 것이다.

패킷 사이즈만큼은 단순하게 sizeof()를 사용해 제할 수 있었지만 나머지는 어떻게 해야 할까?

여기선 무식하게 8, 4, 2를 빼줬는데, 계속 이럴 수는 없는 노릇이다.

이를 해결하는 아이디어에 관해서는 추후에 공부할 내용으로 보충할 수 있으니 지금은 일단 이렇게 처리하자.

 

 

5. 잘 되는지 볼까

의도한 대로 데이터를 잘 가져와준다는 것을 확인할 수 있다.

 

패킷 직렬화에 관한 기본적 공부가 마무리되는 대로

이전에 작업했던 Unity 프로젝트에 서버를 붙여볼까 한다.

먼저 채팅서버부터 시작해 보는 걸로...