패킷 직렬화와 마샬링
네트워크간 통신이 이루어지기 위해서는, 레이어 7계층에서부터 보낼 데이터를 아래 층으로 요청하여 결국 1계층의 비트 스트림으로 변환되고, 이후 수신 때에도 비트 스트림으로부터 레이어 7계층의 의미있는 데이터로 해석해내는 과정이 필요합니다.
이러한 일련의 과정에서 ‘데이터를 바이트 스트림으로 변환하는 과정’을 ‘직렬화’라고 하며, 이를 포함하여 데이터의 메모리 구조를 저장이나 전송을 위해 적당한 자료형태로 변형하는 과정을 ‘마샬링’이라고 부르고 있습니다.
바이트 오더
바이트 오더, 바이트 순서는 OS가 내부적으로 데이터를 표현하는 방식을 의미하기에, 현재는 CPU 아키텍처에 따라 두 종류의 바이트 오더가 존재합니다.
Little Endian이라고 불리는 방식은 데이터가 바이트 단위로 저장될 때, 하위 바이트부터 메모리에 적재되어 가장 최상위 바이트가 가장 높은 메모리 주소에 위치합니다.
이 때문에 코딩하며 메모리를 볼 일이 있을 때, 현 시점에서 대부분의 인텔, AMD 계열의 CPU가 리틀 엔디안을 사용하므로, 순차적으로 읽지 못하고 역순으로 읽으며 해석해야 하는 불편함이 존재합니다.
그렇지만 리틀 엔디안을 사용함으로서 작은 크기의 데이터형으로 변환 시 값이 최대한 유지된다는 장점 또한 존재합니다.
Big Endian이라고 불리는 방식은, 이와 반대로 가장 최상위 바이트가 가장 낮은 메모리 주소에 저장됩니다.
네트워크 바이트라고 부르는 방식이 이 빅 엔디안이고, 그에 맞게 대부분의 네트워크 장비들 (라우터 등)에서는 빅 엔디안 방식을 사용합니다.
따라서 네트워크 전송 작업을 하려고 바이트를 다뤄야 할 때는, 바이트 오더 또한 신경써서 변환이 이루어져야 합니다.
구조체와 마샬링
네트워크 데이터 전송을 위해 어떠한 자료를 바이트로 변환하는 가장 편한 방법은, 구조체를 사용하는 것입니다.
구조체는 메모리 공간에서 자료형들을 연속된 주소에서 관리하기 때문에, 패딩이 없는 구조체라면 구조체의 주소를 바이트 스트림 주소로 곧바로 사용이 가능합니다.
// 패킷 구조체들을 선언합니다.
NetHeader netSCMoveStartHeader;
NetSCMoveStart netSCMoveStart;
// 보낼 데이터들을 구조체에 세팅합니다.
createPacketMoveStart(&netSCMoveStartHeader, &netSCMoveStart, sender->id, sender->direction, sender->positionX, sender->positionY);
// 해당 구조체를 그대로 바이트 스트림으로서 네트워크로 전송합니다.
sendPacketBroadcast(sender, &netSCMoveStartHeader, reinterpret_cast<PCHAR>(&netSCMoveStart));
당연히, 이와 더불어 어떠한 바이트 스트림이 있을 때, 아래 코드 샘플처럼 이를 해당 구조체 포인터 형으로 캐스팅하여 사용하는 것 만으로, 데이터로 역직렬화가 완료된다는 장점이 존재합니다.
// 읽어들일 데이터 구조체를 초기화하고 공간을 마련합니다.
NetCSMoveStart *netCSMoveStart = new NetCSMoveStart;
UINT peekOutSize = 0;
// 해당 데이터 구조체 주소로, 네트워크로 받은 데이터를 그대로 복사합니다.
BOOL peekResult = player->recvBuffer->Peek(reinterpret_cast<PCHAR>(netCSMoveStart), sizeof(NetCSMoveStart), &peekOutSize, false);
구조체 방식의 한계점 : 가변 길이 메시지
하지만 이러한 구조체 방식은 한계점이 존재합니다. 그 중 대표적인 것이, 가변 길이에 대한 대응입니다.
구조체를 이용한 가변 길이 메시지 구현 방식으로 다음 방법들이 존재합니다.
// 방식 1
struct Chat
{
int len;
char data[MAX_BUFFER_SIZE];
}
// 방식 2
struct Chat
{
int len;
char data[0];
}
하지만 앞선 방법은 최대치의 크기를 항상 사용해야 한다는 점에서, 메모리는 물론이고 네트워크 트래픽에 있어서 굉장한 낭비를 불러오게 됩니다. 따라서 구현이 굉장히 쉬운만큼 효율성이 포기되는 방식입니다.
두 번째 방식은, data라는 변수로 가변 길이 메모리 공간에 대한 접근을 할 수 있게끔 해줍니다. 하지만, 실제로 struct라는 구조체의 크기는 int만큼이므로, 해당 구조체를 생성하고 소멸시킬 때 동적으로 직접 data크기를 포함한 전체 구조체 크기로 할당 및 해제해야 한다는 단점이 존재합니다.
직렬화 버퍼
직렬화 버퍼의 구조
직렬화 버퍼는 감당할 최대 크기의 버퍼를 가지며, 바이트 스트림으로 데이터를 쓰고 읽을 수 있는 기능들을 제공하는 클래스입니다.
기본 인터페이스 설계
// 버퍼 전체 크기를 리턴합니다.
INT32 GetBufferSize() const;
INT32 GetBufferSizeTotal() const;
// 버퍼에서 사용중인 크기를 리턴합니다.
INT32 GetDataSize() const;
INT32 GetBufferSizeUsed() const;
// 버퍼에서 남은 크기를 리턴합니다.
INT32 GetBufferSizeFree() const;
// 데이터를 읽는 위치의 포인터를 리턴합니다.
PCHAR GetBufferReadPos() const;
// 데이터를 쓰는 위치의 포인터를 리턴합니다.
PCHAR GetBufferWritePos() const;
// 읽기 위치를 지정한 크기만큼 뒤로 이동시킵니다.
BOOL MoveReadPosRear(INT32 moveSize, PINT32 outMovedSize);
// 쓰기 위치를 지정한 크기만큼 뒤로 이동시킵니다.
BOOL MoveWritePosRear(INT32 moveSize, PINT32 outMovedSize);
// 데이터를 지정한 크기만큼 읽어 outBuffer에 담아줍니다.
BOOL GetData(PCHAR outBuffer, INT32 requestSize);
// inBuffer로부터 지정한 크기만큼 읽어 버퍼에 저장합니다.
BOOL PutData(PCHAR inBuffer, INT32 insertSize);
직렬화 버퍼의 사용 (기본)
네트워크로 패킷을 전송하기 위해 메시지를 만드는 직렬화 버퍼의 사용은 다음과 같습니다.
VOID Make_PACKET_SC_DELETE_CHARACTER(SerializedBuffer *serializedBuffer, INT32 id)
{
PACKET_HEADER packetHeader;
packetHeader.byteCode = PACKET_CODE;
packetHeader.byteSize = sizeof(PACKET_SC_DELETE_CHARACTER) - sizeof(PACKET_HEADER);
packetHeader.byteType = PACKET_SC_DELETE_CHARACTER_;
// 직렬화 버퍼에 보낼 데이터들을 순서대로 차곡차곡 쌓습니다.
serializedBuffer->PutData((PCHAR)&(packetHeader.byteCode), sizeof(packetHeader.byteCode);
serializedBuffer->PutData((PCHAR)&(packetHeader.byteSize), sizeof(packetHeader.byteSize);
serializedBuffer->PutData((PCHAR)&(packetHeader.byteType), sizeof(packetHeader.byteType);
return;
}
이와 쌍으로, 네트워크 데이터로부터 데이터를 읽어오는 과정은 다음과 같습니다.
VOID NetworkPacket(Player *player, PACKET_HEADER *netHeader, SerializedBuffer *packet)
{
BYTE byteCode, byteSize, byteType;
// 직렬화 버퍼로부터 데이터를 지역변수로 읽어옵니다.
packet->GetData((PCHAR)&byteCode, sizeof(byteCode));
packet->GetData((PCHAR)&byteSize, sizeof(byteSize));
packet->GetData((PCHAR)&byteType, sizeof(byteType));
}
연산자 오버로딩을 통한 사용성 개선
위 사용 방식에서, 다음과 같이 연산자 오버로딩을 이용한다면 두 가지 장점을 얻을 수 있습니다.
// 직렬화 버퍼로 데이터를 Enqueue
SerializedBuffer &operator << (UCHAR byteValue);
SerializedBuffer &operator << (CHAR charValue);
SerializedBuffer &operator << (UINT32 uintValue);
SerializedBuffer &operator << (INT32 intValue);
// 직렬화 버퍼에서 데이터를 Dequeue
SerializedBuffer &operator >> (UCHAR &outByteValue);
SerializedBuffer &operator >> (CHAR &outCharValue);
SerializedBuffer &operator >> (UINT32 &outUintValue);
SerializedBuffer &operator >> (INT32 &outIntValue);
우선, 시프트 연산자를 통해 기존 cin, cout 처럼 데이터를 넣고 빼는 것과 동일하게 사용하게 만들면, 가독성과 직관성을 극대화시킬 수 있습니다.
또한, 기존 GetData 함수와 PutData 함수를 private 스코프로 변경한 뒤, 연산자 오버로딩으로만 데이터 입출력이 가능하도록 한다면, 우리가 원하는 자료형만 원하는 방식으로 입출력되도록 제어하고 관리할 수 있게 됩니다.
직렬화 버퍼 사용 (개선후)
위 연산자 오버로딩이 적용된 직렬화 버퍼의 최종 사용 코드는 다음과 같습니다.
VOID NetworkPacket(Player *player, PACKET_HEADER *netHeader, SerializedBuffer *packet)
{
BYTE byteCode, byteSize, byteType;
// 직렬화 버퍼로부터 데이터를 지역변수로 읽어옵니다.
*packet >> byteCode >> byteSize >> byteType;
}
VOID Make_PACKET_SC_DELETE_CHARACTER(SerializedBuffer *serializedBuffer, INT32 id)
{
PACKET_HEADER packetHeader;
packetHeader.byteCode = PACKET_CODE;
packetHeader.byteSize = sizeof(PACKET_SC_DELETE_CHARACTER) - sizeof(PACKET_HEADER);
packetHeader.byteType = PACKET_SC_DELETE_CHARACTER_;
// 직렬화 버퍼에 데이터를 인큐합니다.
*serializedBuffer << packetHeader.byteCode << packetHeader.byteSize << packetHeader.byteType << id;
return;
}
연산자 오버로딩을 이용하기 전보다 더욱 가독성과 직관성이 개선된 것을 볼 수 있습니다.
코드 모음
직렬화 버퍼에 데이터를 인큐하는 코드
__inline BOOL PutData(PCHAR inBuffer, INT32 insertSize)
{
if (GetBufferSizeFree() < insertSize)
{
return false;
}
memcpy(GetBufferWritePos(), inBuffer, insertSize);
bufferWritePosition_ += insertSize;
bufferUsedSize_ += insertSize;
return true;
}
직렬화 버퍼에 데이터를 디큐하는 코드
__inline BOOL GetData(PCHAR outBuffer, INT32 requestSize)
{
if (GetBufferSizeUsed() < requestSize)
{
return false;
}
memcpy(outBuffer, GetBufferReadPos(), requestSize);
bufferReadPosition_ += requestSize;
bufferUsedSize_ -= requestSize;
return true;
}
'연구한 이야기 > 깊게 공부해보기' 카테고리의 다른 글
동기화객체 성능테스트 (0) | 2023.06.19 |
---|---|
IOCP 서버 코어 개발하기 (2) | 2023.06.06 |
메모리풀과 프리리스트 (0) | 2023.05.02 |
TCP와 링버퍼 (0) | 2023.05.02 |
더 빠른 길찾기 (0) | 2023.05.02 |