* 정의
직렬화(serialization)란 어떤 객체가 랜덤 엑세스(random access) 가능한 형태로 메모리상에 존재할 때, 이를 일련의 여러 비트로 변환하여 길게 나열하는 것을 말한다. 변환된 비트열은 디스크에 저장하거나 네트워크를 통해 전송되며 추후 복원가능하다.
* 사용 이유
직렬화를 사용하지 않고도 어떤 객체를 무식하게 복사해서 사용할 수 있고, 간단한 구조의 클래스라면 문제가 되지 않는다. 하지만 여러 가지 상황에서 다음과 같은 문제점이 발생할 수 있다.
다음과 같은 객체를 무식하게 복사한다고 가정했을 때 문제점은 다음과 같다.
class Object
{
public:
Object(int value1, int value2, Object* other = nullptr)
: _value1(value1)
, _value2(value2)
, _other(other) {}
virtual void execute(void) {};
private:
int _value1;
int _value2;
Object* _other;
std::vector<int> _values;
};
- 해당 객체는 가상 함수 테이블(virtual function table)을 가지며 가상 함수 테이블 포인터도 같이 복사하게 되는데, 네트워크 통신을 통해 다른 프로세스에서 해당 가상 함수 테이블의 위치가 같다고 보장할 수 없다.
- 값만 단순히 복사하는 것(_value1, _value2)은 상관없지만 포인터를 무식하게 복사(_other)한다면 네트워크 통신을 통해 받은 포인터의 위치에 정확히 있다고 보장할 수 없다.
- std::vector를 복사하는 경우 정확한 내부구조를 알 수 없지만 하나 이상의 포인터가 std::vector 내부에 있기 때문에 단순히 복사하면 안된다.
위와 같은 문제점을 해결하기 위해 직렬화가 필요하며 이와 함께 다른 여러가지 방법을 사용해야한다.
* 스트림
- 정의
스트림(stream)이란 순서가 있는 데이터 원소의 집합을 캡슐화하여 유저가 그 데이터를 읽거나 쓸 수 있게 해 주는 자료구조이다. 출력, 입력, 입출력 스트림이 존재하며 스트림을 이용하여 객체의 필드들을 차례대로 기록할 수 있으며 필요 시 순서대로 읽어올 수 있다.
- 메모리 스트림의 구조
메모리 스트림은 힙에 동적할당된 메모리 버퍼를 감싸둔 것으로 스트림 종류에 따라 순차적으로 쓰기 / 읽기 기능을 가지고 있다.
원시자료형을 지원하는 입출력 스트림은 다음과 같이 정의해볼 수 있다.
class MemoryStream
{
public:
MemoryStream(char* buffer = nullptr, uint32 capacity = 0)
: _buffer(buffer)
, _head(0)
, _capacity(capacity) {}
virtual ~MemoryStream(void) { free(_buffer); }
template<typename T> void serialize(T& data)
{
static_assert(std::is_arithmetic<T>::value
|| std::is_enum<T>::value
, "원시자료형이 아닙니다.");
serializeData((void*)&data, sizeof(data));
}
virtual void serializeData(void* data, uint32 size) = 0;
protected:
char* _buffer;
uint32 _head;
uint32 _capacity;
};
class OutputMemoryStream : public MemoryStream
{
public:
OutputMemoryStream(void)
: MemoryStream() { reallocateBuffer(64); }
virtual ~OutputMemoryStream(void) {}
void write(const void* data, uint32 size)
{
uint32 finalHead = _head + size;
if (_capacity < finalHead)
{
reallocateBuffer(std::max(_capacity * 2, finalHead));
}
memcpy(_buffer + _head, data, size);
_head = finalHead;
}
virtual void serializeData(void* data, uint32 size) override final
{
write(data, size);
}
private:
void reallocateBuffer(uint32 newCapacity)
{
_buffer = static_cast<char*>(std::realloc(_buffer, newCapacity));
_capacity = newCapacity;
}
};
class InputMemoryStream : public MemoryStream
{
public:
InputMemoryStream(char* buffer, uint32 capacity)
: MemoryStream(buffer, capacity) {}
virtual ~InputMemoryStream(void) {}
void read(void* outData, uint32 size)
{
const uint32 finalHead = _head + size;
if (_capacity < finalHead)
{
return;
}
memcpy(outData, _buffer + _head, size);
_head = finalHead;
}
virtual void serializeData(void* data, uint32 size) override final
{
read(data, size);
}
};
위의 코드로 객체를 각각 직렬화하기 위해서는 직렬화코드를 일반적으로 객체 내부에 함수를 만들어 직렬화 코드를 작성 해야한다.
class Object
{
public:
...
...
void Serialize(MemoryStream* stream)
{
stream->serialize<uint32>(_key);
stream->serialize<int>(_value1);
stream->serialize<int>(_value2);
stream->serialize<uint32>(_other->_key);
uint32 count = _values.size();
stream->serialize<const uint32>(count);
for (uint32 index = 0; index < count; ++index)
{
stream->serialize<int>(_values[index]);
}
}
...
...
};
읽기 따로 쓰기 따로 함수를 만들지 않고도 해당 Serialize 함수는 스트림 종류에 따라 읽거나 쓰는 작업을 수행한다.
메모리를 비트단위로 저장할 수도 있다. 간단히 write코드는 다음과 같이 쓸 수 있다.
void writeBits(uint8 data, uint8 bitCount)
{
const uint32 nextBitHead = _bitHead + bitCount;
if (_bitCapacity < nextBitHead)
{
reallocBuffer(std::max(_bitCapacity * 2, nextBitHead));
}
const uint32 byteOffset = _bitHead >> 3; // 현재의 바이트위치를 찾기 위해 현재 비트위치에서 8로 나눈다.
const uint8 bitOffset = _bitHead & 0x7; // 현재의 바이트를 제외한 나머지 비트이다.
uint8 currentMask = ~(0xff << bitOffset); // 현재 이미 기록된 비트는 덮어쓰지 않게 마스크를 만들어준다.
_buffer[byteOffset] = (_buffer[byteOffset] & currentMask) | (data << bitOffset); // 마스크로 현재 기록된 비트를 보호하고 해당 바이트의 남은 비트에 기록한다.
uint8 writtenBitCount = 8 - bitOffset; // 현재 스트림에 새롭게 쓰여진 비트 수
if (writtenBitCount < bitCount) // 이전 바이트에서 비트를 쓰고도 아직 비트가 남아있다.
{ // 나머지 비트를 써준다.
_buffer[byteOffset + 1] = data >> writtenBitCount;
}
_bitHead = nextBitHead;
}
- 엔디언 호환
컴퓨터의 메모리와 같은 1차원의 공간에 여러 개의 연속된 대상을 배열하는 방법을 엔디언(endianness)이라고 하며, 바이트를 배열하는 방법을 특히 바이트 순서(byte order)라 한다. 엔디언은 크게 리틀 엔디언(little-endian)과 빅 엔디언(big-endian)으로 나뉜다. 리틀 엔디언은 가장 최하위 바이트 부터 먼저 저장하며 빅 엔디언은 최상위 바이트를 가장 먼저 저장한다.
예를 들어 0x12345678의 값을 가지는 4바이트 수를 0x01000001 번지에 저장한다고 하면 다음과 같이 나타낼 수 있다.
<리틀 엔디언>
바이트 값 | 0x78 | 0x56 | 0x34 | 0x12 |
메모리 주소 | 0x01000000 | 0x01000001 | 0x01000002 | 0x01000003 |
<빅 엔디언>
바이트 값 | 0x12 | 0x34 | 0x56 | 0x78 |
메모리 주소 | 0x01000000 | 0x01000001 | 0x01000002 | 0x01000003 |
직렬화 수행중에 스트림의 엔디언과 플랫폼의 엔디언이 다르다면 바이트를 뒤집어서 쓰고, 읽는 쪽에서는 읽고 바이트를 뒤집어서 해석하면 된다.
간단히 부호 없는 정수를 뒤집는 바이트 스와핑 함수는 다음과 같이 정의할 수 있다.
uint16 ByteSwap2(uint16 data)
{
return (data >> 8) | (data << 8);
}
uint32 ByteSwap4(uint32 data)
{
return (((data >> 24) & 0x000000FF)
| ((data >> 8) & 0x0000FF00)
| ((data << 8) & 0x00FF0000)
| ((data << 24) & 0xFF000000));
}
uint64 ByteSwap8(uint64 data)
{
return (((data >> 56) & 0x00000000000000FF)
| ((data >> 40) & 0x000000000000FF00)
| ((data >> 24) & 0x0000000000FF0000)
| ((data >> 8) & 0x00000000FF000000)
| ((data << 8) & 0x000000FF00000000)
| ((data << 24) & 0x0000FF0000000000)
| ((data << 40) & 0x00FF000000000000)
| ((data << 56) & 0xFF00000000000000));
}
* 데이터 주도 직렬화
위의 코드에서는 객체의 클래스에 선언된 각 멤버 변수에 대하여 그 값을 하나씩 직렬화해주는 소스코드를 작성해 주어야 했다. 이런 멤버 변수들을 런타임에 알아낼 수 있다면 멤버 변수들을 직렬화하는 하나의 코드로 모든 객체들을 직렬화 할 수 있게 된다. 이를 가능하게 해주는 것이 리플렉션(reflection) 시스템이다.
간단히 리플렉션은 구체적인 클래스 타입을 알지 못해도, 그 클래스의 메소드, 타입, 변수들에 접근할 수 있도록 해주는 설계 기법이라고 생각하면 된다.
리플렉션을 간단히 구축하기 위해 필요한 요소는 다음과 같다.
- 해당 타입을 표현하는 enum타입 // Type_uint32, Type_float 등
- 해당 변수의 정보들을 담고 있는 래퍼(Wrapper) 클래스 // 변수의 이름(디버깅용), 변수 enum타입, 오프셋(해당 객체에서의 변수까지 오프셋 정보, 이 정보로 해당 변수의 메모리에 접근한다.)
- 변수의 정보들을 담고있는 래퍼 객체들을 담고 있는 클래스
간단히 코드로 나타내면 다음과 같다. 이 함수를 통해 모든 리플렉션을 지원하는 객체를 직렬화 할 수 있다.
// 각 멤버변수의 정보를 담고있는 클래스
class MemberVariableInfo
{
public:
MemberVariableInfo(const std::string& name, DataType dataType, uint32 offset)
: _name(name), _dataType(dataType), _offset(offset) {}
uint32 getOffset(void) const { return _offset; }
DataType getDataType(void) const { return _dataType; }
private:
std::string _name;
DataType _dataType;
uint32 _offset;
};
// 클래스의 멤버변수들을 가지고 있는 컨테이너, 클래스가 하나씩 가지고 있다.
class DataTypeContainer
{
public:
DataTypeContainer(std::initializer_list<const MemberVariableInfo> input)
: _memberVariableInfoSet(input) {}
const std::vector<const MemberVariableInfo>& getMemberVariableInfoSet(void) const { return _memberVariableInfoSet; }
private:
const std::vector<const MemberVariableInfo> _memberVariableInfoSet;
};
void Serialize(MemoryStream* stream, const DataTypeContainer* dataTypeContainer, uint8* data)
{
const std::vector<const MemberVariableInfo>& memberVariableInfoSet = dataTypeContainer->getMemberVariableInfoSet();
for (auto& member : memberVariableInfoSet)
{
void* memberPos = data + member.getOffset();
switch (member.getDataType())
{
case DataType::Type_uint32:
{
stream->serialize(*(uint32*)memberPos);
}
break;
case DataType::Type_uint16:
{
stream->serialize(*(int32*)memberPos);
}
break;
// ...
// ...
default:
break;
}
}
}
// 리플렉션을 적용받기 위해서 초기화 시점에 적절하게 세팅해줘야 한다.
class Object
{
public:
static DataTypeContainer* _dataTypeContainer;
// 초기화과정 적절한 시점에 호출해준다.
static void initDataTypeContainer()
{
_dataTypeContainer = new DataTypeContainer
{
MemberVariableInfo("_value1", DataType::Type_int32, OFFSET(Object ,_value1))
, MemberVariableInfo("_value2", DataType::Type_int32, OFFSET(Object ,_value2))
// ...
};
}
// ...
// ...
};
리플렉션 기능을 확장하면 필요에 따라 직렬화 여부도 조절할 수 있고 압축처리 기능도 넣을 수 있다. 또한 직렬화 이외의 가비지 컬렉션과 GUI 편집기 등을 구현할 때도 널리 쓰인다.
* 참조된 데이터 처리
- 임베딩(인라이닝)
앞에서 나온 Object 객체의 std::vector필드를 직렬화 해야 할 경우 내부에 포인터를 가지고 있기 때문에 그대로 직렬화 하면 문제가 발생한다. vector를 통째로 직렬화하기보다 vector에 저장된 데이터만 기록해야한다.
- 원소의 수인 vector::size()를 직렬화한다.
- 원소들의 데이터를 직렬화한다.
읽을 때는 개수를 먼저 읽고 그 개수만큼 원소를 읽어오면 된다.
위와 같이 독립적인 데이터를 다른 데이터 중간에 끼워 넣는 것을 임베딩(embedding) 또는 인라이닝(inlining)이라고 한다.
- 링킹
앞에서 나온 Object 객체의 Object* 필드를 직렬화 해야 할 경우 주소값을 직렬화하지 않고 이 Object를 구분할 수 있는 ID나 Key값을 사용하면 된다.
Object* _other를 쓰고 읽는 방법
- _other->getKey()를 직렬화한다.
- 읽는 쪽에서 ObjectKey로 해당 Object객체를 찾는다.
- 이를 위해서 객체들을 관리하는 별도의 자료 구조가 필요하다. 간단히 다음과 같은 형식을 갖는다. ex) unordered_map<ObjectKey, Object*> objects / Object objectPool[ObjectKey.getValue()]
- 해당 객체를 찾았다면 이어서 처리해주면 된다.
이처럼 식별자로 해당 객체를 나중에 연결해주는 절차를 링킹(linking)이라고 한다.
* 압축
- 희소 배열(sparse array) 압축
네트워크 통신에서 우리가 보내는 데이터는 전부 사용하지 않을 가능성이 높다. 예를 들어 이름 문자열을 보낼 때 기본 128바이트라고 가정해보면 실제로 128바이트 모두를 사용하는 경우는 별로 없다. 그렇기 때문에 바이트 수와 필요한 바이트만큼 직렬화하면 데이터를 압축할 수 있다. 절차는 다음과 같다.
- 이름 필드에서 이름 길이를 받아온다.
- 이름 길이를 직렬화한다.
- 이름 길이만큼 이름 필드를 직렬화한다.
- 네트워크를 통해 전송한다.
- 엔트로피 인코딩(entropy encoding)
엔트로피 인코딩은 데이터의 예측 가능성에 따라 압축률이 달라진다는 이론이다. 이 이론에서는 기댓값에 가까운 값을 지닌 패킷일수록 그렇지 않은 패킷보다 적은 정보(엔트로피)만 포함한다고 한다. 이러한 이유로 예상에 부합하는 값을 가진 패킷을 전송할 때 그렇지 않은 패킷보다 적은 비트수로 인코딩해 보낼 수 있다.
예를 들어 캐릭터 좌표값 중 높이가 90%확률로 이전 높이와 같다고 가정해보면 다음과 같이 생각해 볼 수 있다.
위치 기록 로직
{
X를 기록한다.
Z를 기록한다.
Y값이 이전 위치와 같으면
비트에 true를 켜준다.
이전과 같지 않으면
비트에 false를 켜준다.
Y를 기록한다.
}
만약 좌표값이 각각 float형으로 되어있다고 가정한다면 일반적으로 Y값을 보내는 경우 예상 비트는 32비트이다.
하지만 위와 같은 로직을 사용한다면 다음과 같다.
최종 평균 비트 수 = 0.9 * 1 + 0.1 * 33 = 4.2비트
평균적으로 27.8비트를 절약되었다.
- 고정소수점
게임에 따라서 정밀도가 높지 않아도 되는 경우가 있다. 예를 들어 한 게임에서 0.1정도의 정밀도만 보장하면 된다고 가정해보면 좌표를 나타내는데 필요한 가지수를 알아낼 수 있다. 만약 월드 크기를 1000 * 1000사이즈로 제한하고 있다면 가능한 최대 개수는 다음과 같이 구할 수 있다.
가능한 최대 개수 = (최댓값 - 최솟값) / 정밀도 + 1 = 1000 / 0.1 + 1 = 10001
log2(10001) = 13.29이므로 14비트로 이 게임에서는 16비트 자료형하나면 한 축의 좌표를 표현가능하다.
- 기하(geometry) 압축
여러 가지 형태의 기하 자료형의 제약 사항을 알고 있다면 이를 활용하여 비트수를 줄일 수 있다.
사원수의 경우, 4차원 float 벡터의 표현으로 회전을 표현할 때 정규화하기 때문에(각 성분 제곱하여 모두 더한 값 1 , 길이1) 값을 3개만 안다면 나머지 절댓값을 구할 수 있다. 거기에 16비트 고정소수점 정밀도를 사용한다면 고정 소수점 값 3개(16 * 3비트)와 나머지 한개의 부호(1비트)로 49비트를 가지고 표현할 수 있다.
행렬의 경우에도 많이 사용되지 않는 스케일 같은 값들을 엔트로피 인코딩 기법으로 대역폭을 줄일 수 있다.