우리가 데이터를 저장하거나 누군가에게 보낼 때 일반적인 데이터는 그냥 보내면 되지만 포인터나 동적할당된 데이터는 그냥 보낼 수 없다. 주소값으로 저장이 되는 이런 데이터는 프로그램을 실행할 때마다 달라지게 되는데 이걸 그냥 보내는 건 의미가 없기 때문이다. 패킷 직렬화는 이러한 데이터를 전송가능한 형태로 바꾸는 것을 말한다.
간단한 게임을 만든다면
SendBufferRef ServerPacketHandler::Make_S_TEST(uint64 id, uint32 hp, uint16 attack, vector<BuffData> buffs)
{
SendBufferRef sendBuffer = GSendBufferManager->Open(4096);
BufferWriter bw(sendBuffer->Buffer(), sendBuffer->AllocSize());
PacketHeader* header = bw.Reserve<PacketHeader>();
// id(uint64), 체력(uint32), 공격력(uint16)
bw << id << hp << attack;
// 가변데이터
bw << (uint16)buffs.size();
for (BuffData& buff : buffs)
{
bw << buff.buffId << buff.remainTime;
}
header->size = bw.WriteSize();
header->id = S_TEST;
sendBuffer->Close(bw.WriteSize());
return sendBuffer;
}
그냥 이런 식으로 함수를 만들어 밀어넣는 정도면 충분할 것이다.
하지만 조금 규모가 있는 게임을 만든다면 패킷의 개수도 당연히 많아질 것이고 패킷마다 이렇게 함수를 만들어 사용하는 것에 무리가 있을 것이다. 또한 데이터가 추가되고 변경되는 과정에서 데이터의 순서까지 고려해야 되기 때문에 신경쓸 것이 한 두개가 아닐 것이다.
그래서 원본 패킷의 설계를 어디서 들고 있느냐도 중요하다. C++로 서버를 만들어 vector를 사용했는데 클라이언트는 유니티 C#이라면 곤란해진다. 그래서 보통 XML이나 json으로 포멧을 만들어 사용한다.
간단한 예시를 살펴보자
struct BuffData
{
uint64 buffId;
float remainTime;
};
struct S_TEST
{
uint64 id;
uint32 hp;
uint16 attack;
// 가변데이터
vector<BuffData> buffs;
};
이렇게 정의해 놓았던 패킷이
<?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>
</Packet>
</PDL>
이렇게 바뀌었다.
이제 이걸 활용해서 각자 환경에 맞게 코드를 만들면 된다.
서버에서는 보안이 굉장히 중요하다. 지금까지의 코드에서는 패킷헤더의 사이즈를 통해 패킷을 조립하고 완성된 데이터를 넘겨준다. 하지만 사이즈는 항상 신용할 수 없다. 그래서 패킷을 핸들링하는 곳에서 이상한 값이 나오면 걸러주는 일을 해야한다.
BufferReader br(buffer, len);
PacketHeader header;
br >> header;
uint64 id;
uint32 hp;
uint16 attack;
br >> id >> hp >> attack;
패킷을 받을 때 이런 식으로 받았었는데 생각해보면
struct PKT_S_TEST
{
uint64 id;
uint32 hp;
uint16 attack;
// 가변데이터
//vector<BuffData> buffs;
};
void ClientPacketHandler::Handle_S_TEST(BYTE* buffer, int32 len)
{
BufferReader br(buffer, len);
PacketHeader header;
br >> header;
PKT_S_TEST pkt;
br >> pkt;
...
이런식으로 그냥 받으면 되지 않나라는 생각이 든다. 근데 그렇게 하면 문제가 생긴다.
struct PKT_S_TEST
{
uint32 hp; // 4
uint64 id; // 8
uint16 attack; // 2
};
int main()
{
PKT_S_TEST pkt;
pkt.id = 1;
pkt.hp = 2;
pkt.attack = 3;
...
문제를 보여주기 위해 id와 hp의 순서를 바꿔보았다. 위 패킷의 크기는 4 + 8 + 2 = 14 바이트가 나와야 한다. 하지만 아래 사진을 보자.
hp의 값인 2가 저장되고 쓰레기값이 들어간 후 id의 값인 1이 들어갔다.
조사식으로 크기를 보아도 24바이트가 나온다.
이유는 생각보다 단순하다. 컴퓨터는 8의 배수로 읽는 것이 속도가 빠르기 때문에 8바이트 데이터인 id를 기준으로 다른 데이터에도 8바이트씩 할당하여 빈공간을 쓰레기값으로 채우는 것이다. 빠르게 읽기 위해서 id 뒤에 4바이트 attack 뒤에 6바이트 총 10바이트의 사용하지 않는 공간을 추가로 할당하는 것이다. 그렇기 때문에 위와 같이 바로 pkt에 밀어 넣으면 문제가 생길 수도 있다.
그러면 저렇게 할 수 있는 방법은 없을까?
#pragma pack(1)
struct PKT_S_TEST
{
uint32 hp; // 4
uint64 id; // 8
uint16 attack; // 2
};
#pragma pack()
이렇게 패킷을 1바이트 단위로 저장하라고 pack을 해주면 된다.
이제야 의도대로 나오게 되었다.
그럼 이제 고정길이 데이터는 패킷의 크기를 알 수 있으니 문제가 생기면 발견할 수 있을 것이다.
이제 문제는 가변길이 데이터이다. 기존에는 패킷에 가변길이 데이터가 있어 패킷의 크기를 하나로 정할 수 없다는 문제가 있었다.
그렇다면 패킷에 가변길이 데이터가 아니라 그 데이터의 정보를 넣어서 크기를 고정해보자. 말보단 직접 코드를 보도록 하자.
#pragma pack(1)
// [ PKT_S_TEST ][ BuffListItem BuffListItem BuffListItem ]
struct PKT_S_TEST
{
struct BuffListItem
{
uint32 buffId;
float remainTime;
};
uint16 packetSize; // 공용 헤더
uint16 packetId; // 공용 헤더
uint64 id; // 8
uint32 hp; // 4
uint16 attack; // 2
uint16 buffsOffset;
uint16 buffsCount;
bool Validate()
{
uint32 size = 0;
size += sizeof(PKT_S_TEST);
size += buffsCount * sizeof(BuffListItem);
// 계산한 크기가 저장된 패킷 크기랑 다르면 문제가 있음
if (size != packetSize)
return false;
// 버프 오프셋(시작위치)부터 버프의 개수만큼 셌는데 패킷 크기를 넘어가면 문제 있음
if (buffsOffset + buffsCount * sizeof(BuffListItem) > packetSize)
return false;
return true;
}
// 가변데이터
//vector<BuffData> buffs;
};
#pragma pack()
buffsOffset과 buffsCount라는 가변길이 데이터의 정보를 나타내는 값을 패킷에 넣는다. 그래서 PKT_S_TEST라는 패킷이 먼저 가고 그 뒤를 이어 가변길이 데이터가 쭉 따라갈 것이다.
void ClientPacketHandler::Handle_S_TEST(BYTE* buffer, int32 len)
{
BufferReader br(buffer, len);
if (len < sizeof(PKT_S_TEST))
return;
PKT_S_TEST pkt;
br >> pkt;
if (pkt.Validate() == false)
return;
cout << "ID: " << pkt.id
<< " HP: " << pkt.hp
<< " Attack: " << pkt.attack << endl;
vector<PKT_S_TEST::BuffListItem> buffs;
buffs.resize(pkt.buffsCount);
for (int i = 0; i < pkt.buffsCount; i++)
{
br >> buffs[i]; // pragma pack(1)했으므로 문제 없음
}
cout << "BuffCount : " << pkt.buffsCount << endl;
for (int32 i = 0; i < pkt.buffsCount; i++)
{
cout << "BuffInfo : " << buffs[i].buffId << ' ' << buffs[i].remainTime << endl;
}
}
패킷을 받는 전체 코드이다.
보내는 코드도 한 번 보자.
SendBufferRef ServerPacketHandler::Make_S_TEST(uint64 id, uint32 hp, uint16 attack, vector<BuffData> buffs)
{
SendBufferRef sendBuffer = GSendBufferManager->Open(4096);
BufferWriter bw(sendBuffer->Buffer(), sendBuffer->AllocSize());
PacketHeader* header = bw.Reserve<PacketHeader>();
// id(uint64), 체력(uint32), 공격력(uint16)
bw << id << hp << attack;
struct ListHeader
{
uint16 offset;
uint16 count;
};
// 가변데이터
ListHeader* buffsHeader = bw.Reserve<ListHeader>();
buffsHeader->offset = bw.WriteSize();
buffsHeader->count = buffs.size();
for (BuffData& buff : buffs)
bw << buff.buffId << buff.remainTime;
header->size = bw.WriteSize();
header->id = S_TEST;
sendBuffer->Close(bw.WriteSize());
return sendBuffer;
}
ListHeader를 만들어 가변 데이터에 대한 정보를 따로 저장해주었다.
이제 패킷의 내용은 바뀔 수 있지만 패킷의 범위를 벗어나는 작업에 대해서는 탐지할 수 있게 되었다.
'게임서버 강의 > 패킷 직렬화' 카테고리의 다른 글
6. 패킷 직렬화 #3 (2) | 2025.07.05 |
---|---|
5. 패킷 직렬화 #2 (0) | 2025.07.04 |
3. Unicode (0) | 2025.05.28 |
2. Packet Handler (0) | 2025.05.26 |
1. Buffer Helpers (0) | 2025.05.24 |