직렬화라는 개념은 통신에서만 중요한 것이 아니다.
파일 입출력을 할 때도 매우 중요한 개념이다.
하지만 지금 공부하는 부분은 서버이기 때문에 통신에 있어서의 패킷 직렬화에 대해 알아보자.
1. 패킷 직렬화란?
메모리에 있는 데이터를 패킷에 일렬로 차곡차곡 쌓아 하나의 바이트 배열로 만드는 것을 패킷 직렬화라고 한다.
어 그렇다면 우리가 기존에 버퍼에 데이터를 넣어 보내던 것도 패킷 직렬화가 아닌가?
PacketHeader* header = bw.Reserve<PacketHeader>();
// id(uint64), 체력(uint32), 공격력(uint16)
bw << id << hp << attack;
우린 지금까지 위와 같이 버퍼에 헤더가 들어갈 공간을 예약하고 ID, HP, ATTACK을 차례대로 넣어줬다.
그럼 버퍼엔 일렬로 저 데이터가 쌓여있을 것이다.
uint64 id;
uint32 hp;
uint16 attack;
br >> id >> hp >> attack;
다시 우리가 활용할 수 있는 형태로 이렇게 빼서 저장하면 역직렬화(Deserialization) 한 것이다.
지금까지 우리가 한 것들은 기초에 지나지 않는다.
패킷 구조를 제대로 설계하고 이를 효율적으로 직렬화해 서로 알아먹기 좋은 형태로 만들어 갈 것이다.
2. 패킷 설계
사실 위에 있는 것처럼 일일이 오퍼레이터를 사용해 데이터를 밀어 넣는 것은 상당히 비효율적이다.
여지껏 정형화된 구조 없이 하나하나 헤더와 데이터를 붙여서 패킷을 전송했고 그 패킷을 받았다.
이제 이런 부분을 하나씩 고쳐나갈 볼 것이다.
먼저 클라이언트에서 받은 패킷을 어떻게 해결할지 생각해 보자.
2-1. 패킷의 구조 잡아주기
패킷이 어떤 데이터를 가질지에 대해서 미리 정형화한 후 이를 XML 또는 JSON 형태로 저장할 것이다.
패킷을 만드는 함수의 개수가 프로젝트에 따라 다르겠지만 많으면 200개 이상이 될 수도 있다.
이 함수들을 일일이 손으로 타이핑하는 것은 좀 그렇다.
같은 코드가 반복될테니 이는 자동화할 수 있겠다.
나중에 자동화에 대해서도 배울 예정이기도 하니 이번엔 패킷의 정의를 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="BuffsListItem" desc="">
<Field name="buffId" type="uint64" desc=""/>
<Field name="remainTime" type="float" desc=""/>
</List>
</Packet>
</PDL>
Packet Definition List라서 PDL이라고 하더라.
일단 이건 나중에 사용할 물건이니 이렇게 저장만 해 두자.
먼저 가변 데이터들은 내비두고 고정 데이터들만 있는 패킷을 생각해 보자.
struct PKT_S_TEST
{
uint16 packetSize; // 공용 헤더
uint16 packetId; // 공용 헤더
uint64 id; // 8
uint32 hp; // 4
uint16 attack; // 2
}
이 형태라면 고민할 게 없다.
여기서 가변 데이터인 버프 리스트를 추가한다.
struct PKT_S_TEST
{
struct BuffsListItem
{
uint64 buffId;
float remainTime;
};
uint16 packetSize; // 공용 헤더
uint16 packetId; // 공용 헤더
uint64 id; // 8
uint32 hp; // 4
uint16 attack; // 2
Vector<BuffsListItem> buffs;
}
버프 ID와 남은 시간에 대해선 BuffsListItem이라는 구조체로 묶어서 관리하기로 했다.
배열인 벡터로 관리하니 데이터도 연속성 있게 저장될 것이다.
문제가 없는 듯 보이지만 여기엔 문제가 있다.
- 버프가 몇개 있는지 확인할 수 없다
- 개수 데이터를 같이 보내지 않았다. - 버프 데이터가 시작되는 지점을 알 수가 없다
- 시작 지점을 모르니 예전처럼 또 8 빼고 4 빼고 2 빼고 하면서 찾아가야 한다.
이 패킷엔 지속적으로 버프 관련 데이터들이 있게 될 것이기 때문에,
버퍼에 대한 정보를 알려주는 버퍼의 헤더를 따로 만들면 될 것 같다.
struct PKT_S_TEST
{
struct BuffsListItem
{
uint64 buffId;
float remainTime;
};
uint16 packetSize; // 공용 헤더
uint16 packetId; // 공용 헤더
uint64 id; // 8
uint32 hp; // 4
uint16 attack; // 2
// 버퍼 데이터의 헤더
uint16 buffsOffset;
uint16 buffsCount;
}
오프셋을 통해 버프 데이터의 시작지점을 알 수 있고, 버프 데이터가 몇 개나 있는지 바로 확인할 수 있게 되었다.
이제 이 패킷은 다음과 같은 구조를 갖게 될 것이다.
[ PKT_S_TEST ] [BuffsListItem BuffsListItem BuffsListItem ...]
2-2. 패킷의 유효성 검증
서버에 있어 가장 중요한 것은 보안이다. 최소한 내가 보내는 패킷의 사이즈가 설계에 부합하는지 정도는 확인해야 한다.
따라서 구조체에 아래와 같은 검증 함수를 만들었다.
bool Validate()
{
// 패킷의 전체 사이즈 검증
uint32 size = 0;
size += sizeof(PKT_S_TEST);
size += buffsCount * sizeof(BuffsListItem);
if (size != packetSize)
return false;
// 버프 데이터 개수가 전체 사이즈를 초과할 만큼 있는지 검증
if (buffsOffset + buffsCount * sizeof(BuffsListItem) > packetSize)
return false;
return true;
}
꼼꼼히 확인한 후 결과를 리턴해 줄 것이다.
2-3. 구조체의 특성
구조체는 그 구조체 안의 가장 사이즈가 큰 데이터를 기준으로 전체 데이터를 정렬한다.
아래와 같은 구조체가 있다고 가정하자.
struct PKT_S_TEST
{
uint32 hp; // 4
uint64 id; // 8
uint16 attack; // 2
};
int main()
{
PKT_S_TEST pkt;
pkt.hp = 1;
pkt.id = 2;
pkt.attack = 3;
}
여기서 중단점을 걸어 저 구조체의 메모리를 살펴보면 뭔가 이상한 점을 발견할 수 있다.
cccc라는 이상한 데이터가 중간에 들어가 있는 것을 확인할 수 있다.
uint64가 8바이트라는 크기를 가지기 때문에 나머지 데이터들도 8바이트의 공간을 사용하게끔 된 것이다.
이런 패딩이 있는 것은 공간 낭비기 때문에 pack()이라는 전처리기를 활용해 이 패딩을 없앨 수 있다.
#pragma pack(1)
struct PKT_S_TEST
{
uint32 hp; // 4
uint64 id; // 8
uint16 attack; // 2
};
#pragma pack()
int main()
{
PKT_S_TEST pkt;
pkt.hp = 1;
pkt.id = 2;
pkt.attack = 3;
}
위와 같이 전처리기를 작성했다. 1이라는 숫자는 1바이트 단위로 데이터를 넣으라는 의미이다.
굳이 감싸지 않고 위에만 써도 된다.
이제 메모리를 살펴보면,
패딩 없이 있는 그대로 데이터가 저장됨을 알 수 있다.
3. 수정사항 반영
이제 구성한 패킷을 직접 사용해 보자.
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;
// ...
}
구조체로 만들어졌기 때문에 하나하나 데이터를 뽑아서 쓸 필요가 없어졌다.
구조체 변수를 만들어 한방에 넘겨줄 수 있게 됐다.
버프 데이터를 뽑는 부분은 아래와 같다.
vector<PKT_S_TEST::BuffsListItem> buffs;
buffs.resize(pkt.buffsCount);
for (int32 i = 0; i < pkt.buffsCount; i++)
br >> buffs[i];
cout << "BufCount : " << pkt.buffsCount << endl;
for (int32 i = 0; i < pkt.buffsCount; i++)
{
cout << "BufInfo : " << buffs[i].buffId << " " << buffs[i].remainTime << endl;
}
버프 구조체 벡터를 만들어 받은 버프 데이터의 개수만큼 resize 한다.
반복문으로 모든 데이터를 벡터에 저장하고 그대로 출력한다.
보내는 쪽엔 일단 버프 헤더만 추가해 주자.
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; // 1 : Test Msg
헤더 구조체를 만들고 헤더 공간의 예약한 후에 버프 데이터를 밀어 넣었다.
실행 결과는 아래와 같다.
개수와 그 정보를 제대로 뽑아와 출력해 준다.
이 구성은 FlatBuffers와 유사하다.
4. 생각해 볼 점
지금까지의 수신 패킷 처리 방식은 복사 비용이 발생한다.
별도의 변수를 만들어서 그 변수에 버퍼의 내용을 복사한 후 데이터를 활용하게 되기 때문이다.
복사 비용을 줄일 수 있다면 더 효율적일 것 같다는 생각이 든다.
// 버퍼의 맨 앞에 PKT_S_TEST가 있기 때문에 바로 캐스팅 가능
// 별도의 변수에 데이터를 복사하는 것은 복사 비용으로 인해 비효율적일 수 있음
PKT_S_TEST* pkt = reinterpret_cast<PKT_S_TEST*>(buffer);
if (pkt->Validate() == false)
return;
이런 식으로 포인터를 넘겨주는 식으로도 만들 수 있지 않을까?
어차피 buffer에 넘어온 데이터는 형태가 변형된 것이 아니라 패킷의 구조 그대로 있을 것이다.
따라서 버퍼에서 캐스팅을 통해 바로 패킷의 고정 데이터의 포인터를 넘겨줄 수 있다.
그리고 검증 함수에 내용을 추가하자.
// 사이즈 추가 검증
size += sizeof(PKT_S_TEST);
if (packetSize < size)
return false;
패킷의 전체 사이즈보다 PKT_S_TEST가 클 수는 없으므로 이를 체크해 준다.
버프 데이터들도 관리하기 쉽게 헬퍼 클래스를 만들어 보자.
template<typename T>
class PacketList
{
public:
PacketList() : _data(nullptr), _count(0) { }
PacketList(T* data, uint16 count) : _data(data), _count(count) { }
T& operator[](uint16 index)
{
ASSERT_CRASH(index < _count);
return _data[index];
}
uint16 Count() { return _count; }
private:
T* _data; // 버프 데이터가 시작되는 지점에 대한 포인터
uint16 _count;
};
생성자에서 버프 데이터의 시작지점(_data)과 개수(_count)를 받아준다.
배열처럼 사용할 수 있도록 오퍼레이터도 만들어 준다.
다시 패킷 구조체로 돌아와서 버프 리스트를 뱉어줄 함수를 하나 만든다.
using BuffsList = PacketList<PKT_S_TEST::BuffsListItem>;
BuffsList GetBuffsList()
{
BYTE* data = reinterpret_cast<BYTE*>(this);
data += buffsOffset; // BYTE가 1바이트 만큼이라 오프셋 크기 만큼 정확하게 더해질 것임
return BuffsList(reinterpret_cast<PKT_S_TEST::BuffsListItem*>(data), buffsCount);
}
데이터를 받아 구조체를 생성하고 이를 리턴해 주는 형태다.
data를 바이트형으로 한 이유는 바이트로 선언하면 1바이트씩 계산하기 때문에,
오프셋을 더하면 정확하게 오프셋 사이즈만큼 더해지기 때문이다.
버프 데이터까지 처리할 준비가 됐으니 핸들러 함수에 아래와 같은 내용을 추가할 수 있다.
PKT_S_TEST::BuffsList buffs = pkt->GetBuffsList();
// 최대한 복사를 줄여 코스트를 줄일 수 있다.
cout << "BufCount : " << buffs.Count() << endl;
for (int32 i = 0; i < buffs.Count(); i++)
{
cout << "BufInfo : " << buffs[i].buffId << " " << buffs[i].remainTime << endl;
}
이제 버프 데이터도 복사가 아니라 포인터로 뽑아먹을 수 있게 됐다.
배열의 형태로 해도 문제는 없지만, 이터레이터를 활용할 수 있다면 더 간편할 것 같다.
이터레이터 클래스를 아래와 같이 만들었다.
template<typename T, typename C>
class PacketIterator
{
public:
PacketIterator(C& container, uint16 index) : _container(container), _index(index) { }
bool operator!=(const PacketIterator& other) const { return _index != other._index; }
const T& operator*() const { return _container[_index]; }
T& operator*() { return _container[_index]; }
T* operator->() { return &_container[_index]; }
PacketIterator& operator++() { _index++; return *this; } // 전위
PacketIterator operator++(int32) { PacketIterator ret = *this; ++_index; return ret; } // 후위
private:
C& _container;
uint16 _index;
};
필요한 연산자들을 오버로딩해 준다.
포인터 연산자에 대해선 const 또는 아닌 연산자를 모두 준비했다.
PacketList 클래스에 이터레이터 사용을 위한 begin() 및 end()를 추가한다.
// ranged-base for 지원
PacketIterator<T, PacketList<T>> begin() { return PacketIterator<T, PacketList<T>>(*this, 0); }
PacketIterator<T, PacketList<T>> end() { return PacketIterator<T, PacketList<T>>(*this, _count); }
컨테이너로는 PacketList를 넘겨주고 시작 지점과 끝 지점을 나타낼 인덱스를 넘겨준다.
그럼 아래와 같은 코드를 통해 이터레이터를 사용해 컨테이너를 순회할 수 있다.
for (auto it = buffs.begin(); it != buffs.end(); ++it)
{
cout << "BufInfo : " << it->buffId << " " << it->remainTime << endl;
}
for (auto& buff : buffs)
{
cout << "BufInfo : " << buff.buffId << " " << buff.remainTime << endl;
}
5. 이터레이터 동작 확인
3개의 순회 코드가 실행됐기 때문에 같은 데이터를 3번 출력한다.
이터레이터도 의도한 대로 잘 동작한다.
FlatBuffers가 이렇게 참조하는 형태로 만들어져 있다.
6. 가변길이 안에 또 가변길이가 있다면?
이제 단순히 버프에 대한 내용뿐만 아니라 그 버프의 영향을 받을 대상에 대한 정보도 넣어볼 것이다.
또 가변길이 데이터가 늘어났는데, 이 경우엔 어떻게 직렬화해야 할지 단박에 떠오르진 않는다.
이전에 만들었던 PDL을 수정하는 것을 시작으로 이에 대해 생각해 보자.
<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>
일단 이렇게 리스트 안에 victims라는 리스트가 또 생겼다. uint64의 userId만을 가지는 리스트다.
하지만 리스트 안에 리스트를 넣어버리면 이전처럼 배열같이 데이터에 접근할 수 있을까?
아마 더 관리하기 까다로워질 것이다.
먼저 서버의 패킷 핸들링 쪽을 클라이언트 쪽에서 한 것과 같이 수정해 주자.
패킷 구조체, 이터레이터 및 리스트 클래스는 그대로 복사해 왔다.
그리고 기존의 Make_S_TEST()는 삭제하고 이를 대체할 「PKT_S_TEST_WRITE」 클래스를 만든다.
6-1. 이를 위한 클래스를 준비
아래와 같이 기존의 버프 리스트까지를 담은 클래스가 준비됐다.
// 이 위엔 구조체와 템플릿 클래스들 존재
class PKT_S_TEST_WRITE
{
public:
using BuffsListItem = PKT_S_TEST::BuffsListItem;
using BuffsList = PacketList<PKT_S_TEST::BuffsListItem>;
PKT_S_TEST_WRITE(uint64 id, uint32 hp, uint16 attack)
{
_sendBuffer = GSendBufferManager->Open(4096);
_bw = BufferWriter(_sendBuffer->Buffer(), _sendBuffer->AllocSize());
_pkt = _bw.Reserve<PKT_S_TEST>();
_pkt->packetSize = 0; // To Fill
_pkt->packetId = S_TEST;
_pkt->id = id;
_pkt->hp = hp;
_pkt->attack = attack;
_pkt->buffsOffset = 0; // To Fill
_pkt->buffsCount = 0; // To Fill
}
// 버프 개수를 미리 받는 이유
// 만약 다른 가변길이 데이터가 버프 뒤에 올 예정이라고 했을 때
// 새로운 버프가 생겨서 버퍼 뒤쪽에 붙어 다른 가변길이 데이터를 감싸는 형태가 되면 안된다
// 따라서 미리 정해진 만큼 버퍼에 쓰게 해 데이터가 꼬이는 문제를 방지
BuffsList ReserveBuffsList(uint16 buffCount)
{
// Reserve 함수에 카운트 받는 부분 추가
BuffsListItem* firstBuffsListItem = _bw.Reserve<BuffsListItem>(buffCount);
_pkt->buffsOffset = (uint64)firstBuffsListItem - (uint64)_pkt;
_pkt->buffsCount = buffCount;
return BuffsList(firstBuffsListItem, buffCount);
}
SendBufferRef CloseAndReturn()
{
// 패킷 사이즈 계산
_pkt->packetSize = _bw.WriteSize();
_sendBuffer->Close(_bw.WriteSize());
return _sendBuffer;
}
private:
PKT_S_TEST* _pkt = nullptr;
SendBufferRef _sendBuffer;
BufferWriter _bw;
};
이 클래스 안에서 거의 다 처리할 수 있게 하기 위해 SendBufferRef와 BufferWriter를 들였다.
클라이언트에서 작업할 때와 마찬가지로 고정 길이 데이터들을 미리 세팅한 후 가변 길이 데이터를 넣도록 했다.
가변 길이 데이터인 만큼 데이터가 얼마나 올지 알 수가 없다.
그때마다 개수를 받아서 그만큼 메모리에 공간을 예약해야 한다.
하지만 for문으로 일일이 예약하기 부단 Reserve() 함수게 개수를 넘겨줘 한방에 예약할 수 있으면 편할 것이므로 그렇게 했다.
firstBuffsListItem은 해당 영역의 시작 지점의 포인터를 갖게 된다.
이 점을 활용해 오프셋을 계산할 수 있다.
패킷 고정 데이터 영역의 시작과 버프 데이터의 시작의 차이가 오프셋이 될 것이다.
시작지점과 개수를 넘겨줘 리스트를 만들어 리턴하면 예약은 끝.
주석에도 적혀있지만 개수를 정해 공간을 예약하는 것은 매우 중요하다.
같은 구조체라도 그 안에 들어있는 가변 길이 데이터 때문에 길이가 중구난방이 된다면 파싱을 제대로 할 수 없을 것이다.
구조체가 12라는 사이즈를 가지기 때문에 12마다 끊어가서 데이터를 파싱해야 하는데,
중간에 이상한 데이터가 껴 있어서 잘못된 메모리를 파싱 하게 된다면 큰 문제가 발생하게 될 것이다.
개수를 정해두는 것으로 파싱이 용이해지고 특정한 데이터 구간이 끝나거나 시작되는 지점을 정확히 알 수 있다.
이제 열어둔 버퍼를 닫고 보낼 준비를 해야 한다.
패킷의 최종 사이즈를 계산하고 닫은 다음 그 버퍼를 리턴하도록 함수를 만들었다.
6-2. 테스트
새로 작성한 클래스가 잘 동작하는 것을 확인하고 추가 데이터를 넣어보자.
서버엔 아래와 같이 패킷을 작성하는 코드를 작성했다.
while (true)
{
// [ PKT_S_TEST ]
PKT_S_TEST_WRITE pktWriter(1001, 100, 10);
// [ PKT_S_TEST ][BuffsListItem BuffsListItem BuffsListItem]
PKT_S_TEST_WRITE::BuffsList buffList = pktWriter.ReserveBuffsList(3);
buffList[0] = { 100, 1.5f };
buffList[1] = { 200, 2.3f };
buffList[2] = { 300, 0.7f };
SendBufferRef sendBuffer = pktWriter.CloseAndReturn();
GSessionManager.Broadcast(sendBuffer);
this_thread::sleep_for(250ms);
}
고정 데이터를 미리 넘겨주고 > 버프 데이터 공간을 예약하고 > 데이터를 밀어 넣었다.
닫고 나서 리턴된 버퍼를 넘겨주기만 하면 끝.
아직까진 문제가 없다.
7. 새로운 가변 길이 데이터 등장
아까 얘기한 대로 버프의 영향을 받을 플레이어의 정보에 대한 데이터를 추가해 보자.
이 데이터는 아까 개수에 대한 얘기를 할 때 느낀 바 대로 BuffsListItem이 끝나는 지점의 다음에 넣어줄 것이다.
먼저 BuffsListItem 구조체에 Victim에 대한 정보(헤더)를 추가한다.
struct BuffsListItem
{
uint64 buffId;
float remainTime;
// Victim List
uint16 victimsOffset;
uint16 victimsCount;
};
해당 Victim의 위치를 알기 위한 오프셋과 Victim의 숫자를 헤더로 가질 것이다.
그리고 Victim도 리스트로 관리될 것이기 때문에 이를 위해 필요한 절차들을 추가한다.
using BuffsVictimsList = PacketList<uint64>;
BuffsVictimsList ReserveBuffsVictimsList(BuffsListItem* buffsItem, uint16 victimsCount)
{
uint64* firstVictimsListItem = _bw.Reserve<uint64>(victimsCount);
buffsItem->victimsOffset = (uint64)firstVictimsListItem - (uint64)_pkt;
buffsItem->victimsCount = victimsCount;
return BuffsVictimsList(firstVictimsListItem, victimsCount);
}
이번에도 마찬가지로 using으로 이름을 지어줬다.
하는 일도 ReserveBuffsList()와 사실상 동일하다.
다른 점이라면 이 함수는 버프 리스트의 원소를 받는다는 점이다.
두 데이터가 짝을 이뤄야 아므로 당연하다고 할 수 있겠다.
서버에서 이에 대한 데이터를 밀어 넣는 것은 아래와 같은 느낌이다.
PKT_S_TEST_WRITE::BuffsVictimsList vic0 = pktWriter.ReserveBuffsVictimsList(&buffList[0], 3);
{
vic0[0] = 1000;
vic0[1] = 2000;
vic0[2] = 3000;
}
PKT_S_TEST_WRITE::BuffsVictimsList vic1 = pktWriter.ReserveBuffsVictimsList(&buffList[1], 1);
{
vic1[0] = 1000;
}
PKT_S_TEST_WRITE::BuffsVictimsList vic2 = pktWriter.ReserveBuffsVictimsList(&buffList[2], 2);
{
vic2[0] = 3000;
vic2[1] = 5000;
}
짝이 되는 리스트의 원소와 개수를 넘겨주고 데이터를 밀어 넣게 된다.
7-1. 클라이언트 수신 대응
서버에서 보내는 데이터의 내용이 바뀌었으니 클라이언트도 이에 맞게 고쳐줘야 한다.
먼저 구조체에 관련 변수를 추가하자.
uint64 buffId;
float remainTime;
// 각 버프의 효과를 받는 대상자(Victim)을 가리키기 위한 Offset
// Victim은 별도의 가변길이 데이터로 관리되고 있기 때문에
// 그 위치로 이동하기 위한 오프셋이 필요하다
uint16 victimsOffset;
uint16 victimsCount;
서버에서 한 것과 동일하다.
그리고 VictimList를 가져올 함수를 만들자.
BuffsVictimsList GetBuffsVictimList(BuffsListItem* buffsItem)
{
BYTE* data = reinterpret_cast<BYTE*>(this);
data += buffsItem->victimsOffset;
return BuffsVictimsList(reinterpret_cast<uint64*>(data), buffsItem->victimsCount);
}
이 함수도 사실상 GetBuffsList()와 동작이 사실상 동일하다.
대신 짝을 이루는 버프 아이템이 필요하다는 점이 차이점이라고 할 수 있다.
검증 함수도 수정해야 한다.
bool Validate()
{
uint32 size = 0;
// 고정 데이터가 전체 크기보다 크진 않은지
size += sizeof(PKT_S_TEST);
if (packetSize < size)
return false;
// 버프 데이터 개수만큼 잘 있는지
if (buffsOffset + buffsCount * sizeof(BuffsListItem) > packetSize)
return false;
// Buffers 가변 데이터 크기 추가
size += buffsCount * sizeof(BuffsListItem);
// 리스트에 있는 데이터들이 유효한지 체크
BuffsList buffList = GetBuffsList();
for (int32 i = 0; i < buffList.Count(); i++)
{
if (buffList[i].Validate((BYTE*)this, packetSize, OUT size) == false)
return false;
}
// 최종 크기 비교
if (size != packetSize)
return false;
return true;
}
먼저 고정 데이터와 버프 데이터의 유효성을 검증한다.
여기까지 문제가 없다면 Victim 데이터에 대한 유효성을 검증해야 한다.
버프 리스트를 받아온 후, 이를 순회하며 잘못된 데이터가 있는지 확인할 것이다.
전체 패킷 사이즈와 검증용 size를 넘겨줘 이에 더해가며 검증하게 했다.
Victim 데이터를 검증할 함수는 아래와 같다.
bool Validate(BYTE* packetStart, uint16 packetSize, OUT uint32& size)
{
if (victimsOffset + victimsCount * sizeof(uint64) > packetSize)
return false;
// 만약 이 안에 또 가변길이 데이터가 있었다면
// 여기서 또 그 데이터의 Validate()를 호출하여 검증해야 함
size += victimsCount * sizeof(uint64);
return true;
}
오프셋부터 Victim의 숫자만큼의 사이즈가 전체 패킷 길이를 넘어버리면 문제가 있는 것이다.
순회하면서 계속 size는 커질 것이다.
완벽한 검증 방법은 아니지만 지금은 이대로도 괜찮을 것이다.
이후에 Victim 안에 다른 가변 길이 데이터가 붙는다면 이 검증 함수 안에
그 데이터를 검증할 함수를 또 호출해야 할 것이다.
Victim의 검증이 완료되고 size와 packetSize를 비교해서 둘이 동일하다면 최종 검증이 완료된 것이다.
이제 이를 파싱하고 출력해 줄 부분을 작성하자.
PKT_S_TEST::BuffsList buffs = pkt->GetBuffsList();
cout << "BufCount : " << buffs.Count() << endl;
for (auto& buff : buffs)
{
cout << "BufInfo : " << buff.buffId << " " << buff.remainTime << endl;
PKT_S_TEST::BuffsVictimsList victims = pkt->GetBuffsVictimList(&buff);
cout << "Victim Count : " << victims.Count() << endl;
for (auto& victim : victims)
{
cout << "Victim : " << victim << endl;
}
}
버프 정보를 출력하는 것까진 이전과 동일하다.
Victim에 대한 정보를 출력하기 위해 해당 리스트를 불러오고,
그 개수를 출력한 다음 Victim의 ID를 출력하게 된다.
최종적인 실행 결과를 확인해 보자.
8. 실행 결과
모든 데이터가 입력한 대로, 뽑아온 대로 잘 출력됨을 확인할 수 있다.
9. 데이터를 직접 꽂는 것이 과연 좋은가?
지금 구현된 방식은 FlatBuffers의 동작 방식과 매우 유사하다.
별도의 복사 없이 바로 밀어 넣는다는 점에서 복사 비용이 들지 않는 매우 합리적인 방식으로 보인다.
하지만 아래와 같이 길게 반복되는 부분은 실수의 여지가 많고 번거로워 마냥 좋지는 않을 것이다.
PKT_S_TEST_WRITE::BuffsVictimsList vic0 = pktWriter.ReserveBuffsVictimsList(&buffList[0], 3);
{
vic0[0] = 1000;
vic0[1] = 2000;
vic0[2] = 3000;
}
PKT_S_TEST_WRITE::BuffsVictimsList vic1 = pktWriter.ReserveBuffsVictimsList(&buffList[1], 1);
{
vic1[0] = 1000;
}
PKT_S_TEST_WRITE::BuffsVictimsList vic2 = pktWriter.ReserveBuffsVictimsList(&buffList[2], 2);
{
vic2[0] = 3000;
vic2[1] = 5000;
}
여기선 작업 내용이 많지 않지만, 큰 프로젝트에서 엄청난 양을 작성해야 한다면?
잔 실수가 나오기 좋을 것이다.
이전처럼 데이터를 복사해 오는 패킷 핸들링 방식이 직관적이기에 작업자가 작업하기 좋을 수 있다.
대신 복사 비용이 들겠지만.
각 프로젝트마다 어울리는 방식을 선택하면 될 것이다.
다음엔 Protobuf와 패킷 자동화에 대해 공부해 보자.
'Study > C++ & C#' 카테고리의 다른 글
[C++/Python] 패킷 자동화 (0) | 2023.07.13 |
---|---|
[C++] Protobuf (0) | 2023.07.13 |
[C++] Unicode / Encoding (0) | 2023.07.10 |
[C++] Packet Handler (0) | 2023.07.08 |
[C++] Buffer Helpers (0) | 2023.07.07 |