* 정의

 

직렬화(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;
};
  1. 해당 객체는 가상 함수 테이블(virtual function table)을 가지며 가상 함수 테이블 포인터도 같이 복사하게 되는데, 네트워크 통신을 통해 다른 프로세스에서 해당 가상 함수 테이블의 위치가 같다고 보장할 수 없다.
  2. 값만 단순히 복사하는 것(_value1, _value2)은 상관없지만 포인터를 무식하게 복사(_other)한다면 네트워크 통신을 통해 받은 포인터의 위치에 정확히 있다고 보장할 수 없다.
  3. 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에 저장된 데이터만 기록해야한다. 

  1. 원소의 수인 vector::size()를 직렬화한다. 
  2. 원소들의 데이터를 직렬화한다.

읽을 때는 개수를 먼저 읽고 그 개수만큼 원소를 읽어오면 된다.

 

위와 같이 독립적인 데이터를 다른 데이터 중간에 끼워 넣는 것을 임베딩(embedding) 또는 인라이닝(inlining)이라고 한다.

 

 

- 링킹

 

앞에서 나온 Object 객체의 Object* 필드를 직렬화 해야 할 경우 주소값을 직렬화하지 않고 이 Object를 구분할 수 있는 ID나 Key값을 사용하면 된다.

 

Object* _other를 쓰고 읽는 방법

  1. _other->getKey()를 직렬화한다.
  2. 읽는 쪽에서 ObjectKey로 해당 Object객체를 찾는다.
  3. 이를 위해서 객체들을 관리하는 별도의 자료 구조가 필요하다. 간단히 다음과 같은 형식을 갖는다. ex) unordered_map<ObjectKey, Object*> objects / Object objectPool[ObjectKey.getValue()]
  4. 해당 객체를 찾았다면 이어서 처리해주면 된다.

이처럼 식별자로 해당 객체를 나중에 연결해주는 절차를 링킹(linking)이라고 한다.

 

 

* 압축

 

- 희소 배열(sparse array) 압축

 

네트워크 통신에서 우리가 보내는 데이터는 전부 사용하지 않을 가능성이 높다. 예를 들어 이름 문자열을 보낼 때 기본 128바이트라고 가정해보면 실제로 128바이트 모두를 사용하는 경우는 별로 없다. 그렇기 때문에 바이트 수와 필요한 바이트만큼 직렬화하면 데이터를 압축할 수 있다. 절차는 다음과 같다.

  1. 이름 필드에서 이름 길이를 받아온다.
  2. 이름 길이를 직렬화한다.
  3. 이름 길이만큼 이름 필드를 직렬화한다.
  4. 네트워크를 통해 전송한다.

 

- 엔트로피 인코딩(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비트를 가지고 표현할 수 있다. 

 

행렬의 경우에도 많이 사용되지 않는 스케일 같은 값들을 엔트로피 인코딩 기법으로 대역폭을 줄일 수 있다.

 

 

'네트워크' 카테고리의 다른 글

레이턴시  (0) 2022.01.08
리플리케이션  (0) 2022.01.04
신뢰성과 UDP  (0) 2021.12.07
NAT  (0) 2021.12.07
서버-클라이언트 구조 설계 : IOCP와 Overlapped I/O  (0) 2021.11.29

+ Recent posts