직렬화

게임 서버 프로그래밍/네트워크 개념정리

2020. 2. 24. 18:18

더보기

밑의 내용들은 모두 '멀티플레이어 게임 프로그래밍' 책을 참조하여 쓴 글입니다.

직렬화의 정의

직렬화 (Serialization)란 어떤 객체가 랜덤 액세스 가능한 형태로 메모리상에 존재할 때,

이를 일련의 여러 비트로 변환하여 길게 나열하는 것을 말한다.

 

네트워크상에서의 직렬화

먼저 단순 무식한 방법의 직렬화 방법이 있다. 객체를 받아와서 <char*> 형으로 변환시키는 것이다.

class RoboCat : public GameObject {

public:
	RoboCat() : mHealth(10), mMeowCount(3) {}
private:
	int32_t mHealth;
	int32_t mMeowCount;
};

void NaivelySendRoboCat(int inSocket, const RoboCat* inRoboCat)
{
	send(inSocket, reinterpret_cast<char*>(inRoboCat), sizeof(*inRoboCat), 0);
}

void NaivelyReceiveRoboCat(int inSocket, RoboCat* outRoboCat)
{
	recv(inSocket, reinterpret_cast<char*>(outRoboCat), sizseof(*outRoboCat), 0);
}

RoboCat 클래스의 멤버 변수들이 int32_t와 같은 원시 자료형이므로, 나이브한 송수신 방식으로도 무리 없이

잘 동작한다. 하지만 개발을 하다 보면 원시 자료형만을 송수신하지 않는다는 것을 알 수 있다.

 

class RoboCat : public GameObject {

public:
	RoboCat() : mHealth(10), mMeowCount(3),mHomeBase(0) 
	{
		mName[0] = '\0';
	}

	virtual void Update();

	void Write(OutputMemoryStream& inStream)const;
	void Read(InputMemoryStream& inStream);
private:
	int32_t				 mHealth;
	int32_t				 mMeowCount;
	GameObject*			 mHomeBase;
	char				 mName[128];
	std::vector<int32_t> mMiceIndices;
};

위와 같은 클래스인 경우 나이브한 송수신 함수로는 제대로 된 결과를 전송할 수 없는데, 그 이유는

실행되는 프로세스마다 가상 함수 테이블의 위치가 같다는 보장이 없기 때문이다.

 

위의 클래스가 메모리에 저장되는 구조를 보면, 첫 4바이트는 Update() 함수의 가상 함수 테이블을 가리키는

포인터가 된다. (32bit machine일 때)

위의 가상 함수 테이블 포인터 값이 복제 전달 되는 과정에서 엉뚱한 값으로 덮었으이고 말 것이다.

뿐만 아니라, mHomeBase와 같은 포인터도 복제가 될 때 의도치 않은 결과가 빚어진다.

포인터는 성격상 특정 프로세스의 메모리 공간의 한 주소를 가리키고 있기 때문에 이러한 포인터를 다른 프로세스에

무작정 전송해 놓고서 그쪽 메모리에도 정확히 해당 위치에 같은 값이 있겠거니 기대해선 곤란하다.

 

따라서, 포인터가 가리키거나 레퍼런스가 참조하는 데이터 전체를 동일하게 복제 전달해 주거나,

해당 데이터와 동일하게 취급되는 내용을 수신자 프로세스에서 찾아 연결해 주는 기능이 구현되어야 한다.

 

mName 같은 필드도 128바이트 전체를 쓰는 경우가 드물기 때문에, 그대로 복사 전달하면 대역폭 낭비를 불러일으킨다

따라서, mName 필드가 널 종료 문자열이므로 널 표시까지만 전송하면 대역폭을 줄일 수 있다.

 

vector 같은 경우는 소위 블랙박스화 된 데이터 구조체로 나이브하게 직렬 화하는 것이 불가능하다.

구조체 내부에 뭐가 들어있을지 모르는 경우에 데이터를 비트 단위로 복제하는 것은 안전하지 않다.

 

위의 문제점을 해결하기 위한 방법으로

  • 각 필드를 제각기 직렬 화하여 보내는 방법
  • 필드마다 패킷을 하나씩 보내는 방법
  • 객체 하나를 전송할 때 그 객체와 관련된 데이터를 버퍼 하나에 모아두었다가 그 버퍼를 통째로 전송하는 방법

위의 두 방법은 패킷마다 불필요한 헤더를 무수히 전송하여 대역폭 낭비가 발생하는 데다가

네트워크 연결에 부하를 주게 된다.

마지막 방법을 선호하며 이는 '스트림'을 쓰면 쉽게 처리할 수 있다.

 

스트림이란?

스트림 (stream)이란 순서가 있는 데이터 원소의 집합을 캡슐화하여 유저가 그 데이터를

읽거나 쓸 수 있게 해주는 자료구조이다.

 

스트림의 종류로 메모리 스트림, 비트 스트림 등이 있고 

이러한 스트림을 이용하면 원시 자료형과 POD(plain old data)로만 구성된 자료형을 직렬 화할 수 있지만

포인터나 컨테이너 등 간접 참조 데이터는 처리하지 못한다.

( POD에 관한 설명은 다음 주소로 들어가서 확인하자.)

이와 같은 데이터를 처리하기 위한 기법으로 '임베딩 (또는 인 라이닝)'이라는 것을 이용할 수 있다.

 

임베딩 (또는 인 라이닝)이란?

임베딩이란 독립적인 데이터를 다른 데이터 중간에 끼워 넣는 것을 의미한다.

예를 들어서 위의 vector 값을 송수신한다면

template<typename T>
void Write(const std::vector<T>& inVector)
{
	size_t elementCount = inVector.size();
	Write(elementCount);
	for (const T& element : inVector)
		Write(element);
}

template<typename T>
void Read(std::vector<T>& outVector)
{
	size_t elementCount;
	Read(elementCount);
	outVector.resize(elementCount);
	for (T & element : outVector)
		Read(element);
}

이런 식으로 함수가 제공될 수 있다. vector의 길이를 먼저 송신하는 것은 수신할 때 vector의 길이를 먼저 알아야

그 길이만큼 재할당을 할 수 있기 때문이다.

 

아무튼 이런 방식을 사용한다면 vector 같은 내부를 알 수 없는 구조체도 원시 자료 형태로 데이터를 하나씩 내보내고

받을 수 있다. 만약 더욱 다양한 형태의 컨테이너, 또는 포인터로 참조된 데이터를 추가하고 싶으면 템플릿 특수화를

이용하면 된다.

 

하지만, 이 같은 방법으로는 원조 객체에 전적으로 포함되는, 즉 다른 객체와 공유되지 않는 데이터에 한해서만

적용할 수 있는 직렬화 기법이다.

위의 GameObject* mHomeBase 같은 경우는 하나 이상의 포인터로 여러 곳에서 참조된다.

임베딩 기법을 이용한다면, 같은 기지 객체를 두 RoboCat이 제각기 복사하여 직렬 화하게 된다.

이렇게 되면 원래 하나만 있어야 할 기지 객체가 복원 시 똑같은 내용으로 두 개 만들어지며 또한, 매번

직렬화를 거쳐 동기화될 때마다 계속해서 그 수가 늘어나게 될 것이다.

 

또 다른 경우는, 데이터 구조 자체가 임베딩이 아예 불가능한 경우가 있다.

class RoboCat : public GameObject {

public:
	RoboCat() : mHealth(10), mMeowCount(3),mHomeBase(0) 
	{
		mName[0] = '\0';
	}

	virtual void Update();

	void Write(OutputMemoryStream& inStream)const;
	void Read(InputMemoryStream& inStream);
private:
	int32_t				 mHealth;
	int32_t				 mMeowCount;
	GameObject*			 mHomeBase;
	char				 mName[128];
	std::vector<int32_t> mMiceIndices;
};

class HomeBase : public GameObject 
{
	std::vector<RoboCat*> mRoboCats;
};

RoboCat을 직렬 화하는 함수가 있다고 가정하면, RoboCat을 직렬화하는 동안 RoboCat이 참조하는 

HomeBase를 임베드해야 할 것이고, HomeBase를 임베딩 하다가 모든 RoboCat을 또 임베딩 해야

하는데, 이 중에는 앞서 직렬화 중이던 RoboCat도 포함되어 있을 것이다. 이런 식으로는 무한

재귀 호출로 스택 오버 플로우가 일어난다.

이를 해결하기 위해서 '링킹'이라는 작업이 필요하다.

 

링킹 이란?

여러 번 참조될 법한 객체에 고유 식별자 혹은 ID를 부여해 두었다가 이들 객체를 직렬 화할 때

오로지 식별자 값만 직렬 화하는 것이다.

 

일단 수신자가 모든 객체 데이터를 복원한 뒤, 수정 루틴을 돌려 각 식별자에 대응되는 참조 객체를

찾아 적절한 멤버 변수에 끼워 넣는다. 자세한 내용은 다음 포스팅에서 하기로 하고, 아주 간단한

코드를 먼저 살펴보자.

class LinkingContext 
{
public:
	uint32_t GetNetworkId(GameObject* inGameObject)
	{
		auto it = mGameObjectToNetworkIdMap.find(inGameObject);
		if (it != mGameObjectToNetworkIdMap.end())
			return it->second;
		else
			return 0;
	}

	GameObject* GetGameObject(uint32_t inNetworkId)
	{
		auto it = mNetworkIdToGameObjectMap.find(inNetworkId);
		if (it != mNetworkIdToGameObjectMap.end())
			return it->second;
		else
			return nullptr;
	}

private:
	std::unordered_map<uint32_t, GameObject*> mNetworkIdToGameObjectMap;
	std::unordered_map<GameObject*, uint32_t> mGameObjectToNetworkIdMap;
};

이미 매핑이 되었다는 가정하에 Write / Read는 해당 키를 입력하는 것으로 쉽게 사용될 수 있다.

 

조금 부족한 설명들이 있는데 이는 다음 장의 객체 리플리케이션 공부 후 포스팅하도록 하겠습니다!

'게임 서버 프로그래밍 > 네트워크 개념정리' 카테고리의 다른 글

객체 리플리케이션  (0) 2020.03.02
압축  (0) 2020.02.24
NAT과 P2P  (0) 2020.02.19
TCP (Transmission Control Protocol)  (0) 2020.02.19
UDP (User Datagram Protocol)  (0) 2020.02.19