요즘 '구글 엔지니어는 이렇게 일한다' 라는 책을 읽고 있는데 거기서 어떤 아이디어가 떠오르면 꽁꽁 숨겨놓지 말고 공유해서 더 좋게 개선하고 나누라는 글이 있었습니다.
혼자 연구하는 것은 한계가 있고 예상치 못한 디자인 결함이 있을 수 있으니 공유해서 다른 사람들의 지혜를 구하라는 뜻이었죠.
배운것을 실천하기 위해 제가 생각하고 구현한 짧은 코드 조각을 공유합니다.
(C++을 깊이 알지 못해서 오류나 문제되는 부분이 있을 수 있습니다. 이상한 부분이 있으면 주저하지 말고 의견 남겨 주세요^^)
주제는 C++로 클래스를 직렬화/역직렬화 하는 방법입니다.
클래스의 모든 멤버변수들을 대상으로 할 수도 있고, 원하는 변수들만 선택적으로 할 수도 있게 하고 싶은게 목표였습니다.
모든 변수들을 통째로 직렬화 할 때는 부스트나 프로토버퍼 같은 라이브러리를 쓰면 가능하지만 특정 변수들만 선택적으로 하려니 좀 애매하더군요.
(추가:나중에 알게되었는데 프로토버퍼는 부분 직렬화가 가능합니다.)
예를들어 서버에 캐릭터 속성을 담은 클래스가 있고, 변수들이 한 100개 정도 된다고 했을 때
이것들중 변경이 생긴 변수들만 직렬화 해서 클라이언트로 보내고 싶다 라는 경우입니다.
직렬화를 할 때는 직접 변수를 기입해주면 간단합니다.
예를들면 이런 식이죠 :
Serailize << hp << speed << power
문제는 역직렬화에서 발생합니다.
Deserialize >> hp >> speed >> power
이렇게 구현하면 될거라 생각했지만 받는쪽에서는 어떤 변수가 직렬화 되었는지 알 수 없다는것이 문제입니다.
왜냐하면 100가지가 넘는 변수들중에 런타임중 변경이 생긴 값들만 전송될 것이기 때문이죠.
패킷 구조를 미리 정해놓자니 모든 조합을 다 만들어 놔야 한다는 부담이 생기게 됩니다.
따라서 보내는 쪽에서 어떤 변수들이 포함되어 있다 라는 정보를 데이터에 같이 넣어줘야 합니다.
'구글 프로토 버퍼'처럼 외부에서 별도의 실행 파일로 코드를 생성해 내는 방식 말고 런타임중 알 수 있는 정보로만 구현하고 싶었습니다.
이걸 가능하게 하기 위해 첫번째로 생각한 방법은 xml이나 json포맷으로 전달하는 것입니다.
변수명과 데이터를 함께 패킷에 넣어 보낸 뒤, 받는 쪽에서는 해당 이름으로 변수를 찾아 값을 넣어주는 것이죠.
하지만 이 방법은 치명적인 단점이 있는데 바로 자동화가 힘들다는 것입니다.
변수명은 패킷 안에 string값으로 넘어올테고 C++에서 string을 통해서 변수에 직접 접근하는 것은 불가능하기 때문이죠.
결국 string을 읽어 switch~case나 if문으로 변수명을 찾아서 넣어주는 코드를 작성해야 하기 때문에 코드도 지저분해지고 성능도 떨어지게 됩니다.
두번째로 생각한 방법은 멤버변수를 배열처럼 접근하는 방법입니다.
배열 인덱스로 변수들을 쭉 순회할 수 있다면 for문을 이용해서 간단히 자동화 시킬 수 있기 때문이죠.
변수들을 담고 있는 vector를 하나 만들고 거기에 변수들의 포인터를 담는 방식으로 구현해볼 수 있을것 같았습니다.
예를들면 :
class CharacterAttribute
{
public:
std::vector<???> members;
int64_t hp;
float power;
bool isAlive;
};
members.push_back(&hp);
members.push_back(&power);
members.push_back(&isAlive);
이렇게 생성자에서 미리 담아놓고 members[0], members[1], members[2] 해서 쓰는것이죠.
그럼 Serialize는 다음과 같이 구현할 수 있습니다.
void CharacterAttribute::Serialize(char* buffer)
{
for (int i=0; i<members.size(); ++i)
{
//TODO: memcpy를 써서 members[i]를 buffer에 복사.
}
}
Deserialize는 이렇게 되죠.
void CharacterAttribute::DeSerialize(const char* buffer)
{
for (int i=0; i<members.size(); ++i)
{
//TODO: memcpy를 써서 buffer에서 members[i] 로 복사.
}
}
여기서 members[i]는 멤버변수를 가리키는 포인터이기 때문에 해당 변수명으로 접근하지 않고도 값을 넣어줄 수 있게 됩니다.
변수의 크기는 클래스에 이미 선언되어 있기 때문에 패킷 buffer에 넣지 않아도 알 수 있는 것이고요.
단, 반드시 서버, 클라 모두 같은 클래스 정의를 써야 합니다.
물론 여기 까지는 그냥 구조체를 통째로 memcpy 해버리는 전통적인 방식으로도 가능하기 때문에 일부러 vector에 넣는 수고를 하는것이 무의미 할 수 있습니다.
이 구현 방식의 큰 장점은 '원하는 변수만 런타임중에 선택적으로 직렬화/역직렬화' 할 수 있다는 것입니다.
그것이 가능하려면 어떤 변수가 직렬화 되었는지 역직렬화 하는 쪽에서 알아야 하고,
이것을 vector의 인덱스를 통해 시퀀스하고 단순한 방식으로 멤버변수에 접근할 수 있게 되는 것이죠.
또한 vector에 넣는 코드도 매번 타이핑 하지 않고 자동으로 처리되게 하고 싶었습니다.
작업하다 보면 실수로 빼먹는 경우도 생기기 때문이죠.
즉, 구현부에서는 변수 선언을 제외한 다른 부가적인 코드나 매크로 없이도 구현되게 하는 것이 목표입니다.
이 구현을 위해서 변수를 래핑할 Serializable 클래스를 하나 만들고 모든 멤버변수는 이 클래스를 통해서 생성되게 합니다.
class CharacterAttribute
{
public:
std::vector<???> members;
//int64_t hp;
Serializable<int64_t> hp;
//float power;
Serializable<float> power;
//bool isAlive;
Serializable<bool> isAlive;
};
직렬화 하고 싶은 변수들을 Serializable템플릿을 사용하여 선언합니다.
Serializable은 템플릿 클래스로 구현되며 get, set등 해당 변수를 조작하는 몇가지 매소드로 구성되어 있습니다.
template<typename T>
class Serializable
{
public:
T data{};
T Get()
{
return data;
}
void Set(T val)
{
data = val;
}
...
};
이 외에도 추가적인 코드가 들어 있지만 간략하게 나타내면 위와 같습니다.
Serializable<int64_t> hp;
선언하는 곳에서는 이런식으로 선언을 하고
hp.Set(100);
int64_t myhp = hp.Get();
이렇게 Get/Set 매소드를 통해서 변수에 값을 쓰고 읽을 수 있습니다.
vector에 넣는 과정을 자동화 하기 위해서 Serializable 생성자를 이렇게 구현합니다.
Serializable(AutoSerializer* serializer)
{
serializer->AddMember(this);
}
변수를 선언하고 위 생성자가 호출되면 자동으로 vector에 들어가게 됩니다.
속성 클래스 생성시 AutoSerializer를 상속받게 하고, 아래처럼 this를 넘겨주면 생성자로 AutoSerializer의 포인터가 넘어 갑니다.
(여기서는 C++의 uniform initialization방식을 사용했습니다)
만약 상속을 받기 싫을 경우에는 멤버 변수에 포함시켜 해당 변수의 포인터를 넘겨줘도 됩니다.
단, 직렬화 대상이 되는 변수들은 반드시 같은 AutoSerializer를 가리키고 있어야 합니다.
class CharacterAttribute : public AutoSerializer
{
public:
Serializable<int64_t> hp{this}; // this == AutoSerializer 입니다.
};
AutoSerializer는 변수들을 Serialize/Deserialize 해주는 역할을 담당합니다.
멤버로 vector 컨테이너를 들고 있으며 Serialize, DeSerialize 함수가 구현됩니다.
class AutoSerializer
{
public:
std::vector<???> members;
void Serialize();
void DeSerialize();
};
여기서 vector에 들어갈 타입은 템플릿 형식이기 때문에 다양한 타입의 Serializer를 직접 넣을 수 없어 공통된 interface를 하나 만들어 줍니다.
class Serializable : ISerializable
이렇게 Serializable템플릿 클래스를 ISerializable에서 상속받게 하고 ISerializable의 포인터로 vector를 선언합니다.
std::vector<ISerializable*> members{};
이렇게 하면 Serializable<> 로 선언된 변수들의 포인터는 members에 순차적으로 들어가게 됩니다.
이걸 통해서 Serialize함수를 구현하게 되면 다음과 같이 됩니다.
void Serialize(char* buffer)
{
// 1) 직렬화 대상이 되는 변수의 개수를 넣어줍니다.
for (int i=0; i<members.size(); ++i)
{
// 2) 배열 인덱스를 buffer에 넣어줍니다. 여기서는 i 변수의 값을 넣어주면 됩니다.
// 3) 실제 데이터를 넣어줍니다. members[i].GetDataPtr() 등과 같이 구현 됩니다.
}
}
DeSerialize의 경우 members.size() 만큼 for문을 돌 수도 있지만 직렬화 할 때 변수 개수를 하나 넣어서 같이 보내준다면 해당하는 값들만 선택적으로 역직렬화 할 수 있습니다.
void DeSerialize(const char* buffer)
{
// totalCount는 Serialize할 때 계산해서 넣어줍니다.
for (int i=0; i<totalCount; ++i)
{
// 1) 배열 인덱스를 뽑아 옵니다. 이 값으로 members[index] 형태로 접근할 수 있습니다.
// 2) members[index] 에 데이터를 변수의 사이즈 만큼 복사해 넣습니다.
// 직렬화/역직렬화 모두 동일한 클래스를 사용한다면,
// 변수의 사이즈는 이미 클래스에 정의되어 있으므로 buffer에서 가져올 필요가 없습니다.
}
}
이렇게 해서 간단히 속성값을 들고 있는 객체를 만든 뒤 테스트 해본 결과입니다.
class CharacterAttribute : public AutoSerializer
{
private:
// serializable.
Serializable<int64_t> hp{ this };
Serializable<int32_t> job{ this };
Serializable<float> speed{ this };
// non-serializable.
bool isAlive{};
};

첫번째는 전체 멤버변수를 직렬화 한 결과이며,
두번째는 hp와 speed값만 직렬화 한 것입니다. job변수의 값은 전달되지 않은 것을 확인할 수 있습니다.
isAlive는 Serializable로 선언된 변수가 아닌 일반적인 bool 타입으로 선언되어 직렬화 대상에서 제외 된 모습입니다.
직렬화 결과는 char 버퍼에 저장되며 동적으로 할당하기 위해 std::vector<char> 로 사용했습니다.
(C#으로 구현한다면 byte[]과 같습니다)
사용하는 부분의 코드는 다음과 같습니다.
int main()
{
//------------------------------------------------
std::cout << "----- serailize all -----" << std::endl;
CharacterAttribute foo{};
foo.SetTestData();
// foo -> byte array.
std::vector<char> bufferFoo(foo.GetSerializationBufferSize(), 0);
foo.Serialize(bufferFoo.data());
foo.Print();
// byte array -> bar.
std::cout << std::endl;
std::cout << "----- deseralize all -----" << std::endl;
CharacterAttribute bar{};
bar.Deserialize(bufferFoo.data());
bar.Print();
//------------------------------------------------
std::cout << std::endl;
std::cout << std::endl;
std::cout << std::endl;
std::cout << "----- serailize modified variables only -----" << std::endl;
CharacterAttribute foo2{};
foo2.SetHp(123);
foo2.SetSpeed(99.5f);
foo2.SetAlive(true);
// foo2 -> byte array.
std::vector<char> bufferFoo2(foo2.GetModifiedSerializationBufferSize(), 0);
foo2.Serialize(bufferFoo2.data());
foo2.Print();
// byte array -> bar2.
std::cout << std::endl;
std::cout << "----- deserialize modified variables only -----" << std::endl;
CharacterAttribute bar2{};
bar2.SetJob(1);
bar2.Deserialize(bufferFoo2.data());
bar2.Print();
전체 소스코드는 github에 올려놨습니다. 궁금하신 분들은 한번씩 봐주시면 감사하겠습니다.
https://github.com/sunduk/SerializeCPP
비슷한 아이디어를 누군가는 이미 생각했을 수도 있고 말도 안되는 구현일지도 모르겠습니다.
하지만 다른 시각으로 보면 문제점이나 개선할 부분이 나올 수도 있다고 생각해서 용기내어 올려봅니다!