상세 컨텐츠

본문 제목

[C++] 템플릿 함수를 이용해 STL 컨테이너를 직렬화해보자

프로그래밍/테크닉

by ∫2tdt=t²+c 2018. 10. 5. 18:10

본문

프로그래밍을 하다보면 클래스나 구조체의 내용물을 그대로 파일에 저장하거나, 파일로부터 읽어와야 할 경우가 생깁니다. 구조가 있는 객체의 내용물을 바이트 배열로 저장하는 것을 직렬화(Serialization)이라고 하고, 반대로 바이트 배열로부터 내용물을 읽어와 객체에 채우는 것을 역직렬화(Deserialization)라고 합니다.

Java나 C#과 같은 여러 언어에서는 직렬화 기능을 언어차원의 라이브러리로 지원해주는 경우가 많습니다. 따라서 클래스나 구조체의 내용물을 저장하는데에 어려움을 겪지 않죠. 하지만... 당연하게도 C++에서는 언어 자체적으로 그런 기능을 지원하진 않습니다. 직렬화가 하고싶으시다면 직접 짜시면 됩니다. (어떻게 보면 그게 C++의 매력이라고 할수도 있겠죠...?)


사실 이런 꼭 필요하고 자주 쓰이는 작업들의 경우 이미 여러 쟁쟁한 라이브러리가 나와있습니다. Google Protocol Buffer나 boost::serialize 등이 그것입니다. 이 포스팅에서도 잘 설명이 되어있네요. 하지만 종종 외부 라이브러리를 사용하기 어려운 환경이거나 바퀴를 재발명하는 재미를 추구하는 사람들을 위해서, 간단하게 직렬화하는 코드를 작성해보기로 했습니다.


먼저 정확하게 문제를 정의해보겠습니다. 우리의 직렬화 라이브러리가 직렬화할 대상은 다음과 같습니다.


  1. 정수, 부동소수점, 불리언 값 등 기초적인 값
  2. 직렬화가능한 타입들을 파라미터로 하는 STL Container (vector, map, set, string 등등)

2번이 키 포인트입니다. 이를 재귀적으로 적용하면 vector<map<int, int>>와 같이 컨테이너 안에 컨테이너가 포함되는 복잡한 타입도 직렬화할 수 있기 때문에 이론 상 어떤 종류의 타입도 직렬화가 가능합니다. 이를 위해서 템플릿 함수를 오버로딩하는 기법을 사용해보도록 합시다.


원형 정의



간단하죠? 이 함수들은 writeToBinStreamImpl 및 readFromBinStreamImpl 이라는 함수를 호출하는걸로 대충 끝냈습니다. 이제 이 함수들을 오버로딩해서 여러 타입들에 대응할 수 있게 확장하면 됩니다.


기초적인 값에 대한 오버로딩

기초적인 값들을 직렬화하는 건 간단합니다. 그냥 해당 값의 포인터를 얻어서 바이트 값을 알아내고 그대로 write하면 되니깐요.



사실 이 함수는 큰 문제가 있습니다. 만약 여기에 포인터 타입이 들어오면 어떻게 될까요? 이 함수는 단순히 그 포인터 값을 파일에 쓰기만 하므로, 포인터가 가리키는 값을 파일에 저장하지는 못합니다. 포인터가 포함된 구조체나 클래스 등이 들어와도 그대로 그냥 메모리 상의 값을 쓰려고 할 겁니다. 이런 일이 일어나서는 안되므로, 사전에 방지하도록 하겠습니다.

C++의 SFINAE 규칙을 따라, enable_if를 사용하면 이런 원치 않는 타입에 대한 오버로딩을 방지할 수 있습니다.




컨테이너에 대한 오버로딩

감 잡은 김에 std::vector 타입도 직렬화하는 함수를 작성해봅시다.


우리가 앞서 이미 기초적인 값들을 직렬화하는 함수를 작성했으므로, 얘네를 다시 이용하면 됩니다. writeToBinStream를 재귀적으로 호출하면 깔끔하겠죠.


pair 타입도 간단하게 직렬화 가능합니다.




pair가 되면 map도 쉽게 직렬화할 수 있겠죠?



set이나 string도 사실 vector와 다를게 없습니다.



역직렬화도 유사하게

위에 제시된 writeToBinStreamImpl 함수들에서 write를 그래도 read로 바꾸면 역직렬화 함수가 됩니다.



템플릿 함수 오버로딩 vs 추상 클래스의 상속

템플릿 함수의 강점은 유연함과 최적화에 있습니다. 만약 이를 ISerializable이라는 추상 클래스를 설정하고, 여기의 virtual function으로 readFromBinStream, writeToBinStream을 정의하는 식으로 설계를 했다면,

  1. STL 컨테이너들은 Serializable을 상속하지 않으므로 이들을 직렬화에 사용할수 없다. 사용하고자 한다면 이를 래핑한 별도의 클래스를 생성해야한다.
  2. 런타임에 virtual function의 호출경로가 결정되므로, 호출에 대한 오버헤드가 크다.

위와 같은 한계를 가졌을 겁니다. 반면 템플릿 함수를 오버로딩하면 굳이 클래스 상속을 활용하지 않아도 되므로, 기존에 이미 정의된 STL 컨테이너들에도 쉽게 적용이 가능하고, 템플릿 함수의 재귀호출에 따라 컴파일 시간에 writeToBinStream 함수와 readFromBinStream함수가 생성되므로 호출 오버헤드가 없습니다.

따라서 vector<pair<vector<pair<string, string>>, float>>과 같은 괴랄한 형태의 타입(물론 이런 타입을 별도의 클래스화하지 않고 그냥 쓰는건 죄악입니다만)도 깔끔하게 직렬화가 가능하고, 새로운 구조체 foo에 대해 vector<foo>을 직렬화하려거든 foo타입에 대한 wrtieToBinStreamImpl 및 readFromBinStreamImpl만 추가로 구현해서 오버로딩하면 됩니다. 깔끔하죠? 간단한 struct에 대해서도 자동적으로 템플릿 함수가 오버로딩되면 좋겠지만, C++ 문법 상으로는 구조체의 멤버변수들에 대해 알아낼 수 있는 방법이 없기에 이는 불가능합니다. 대신 std::tuple에 대한 직렬화를 구현하고, std::tuple을 상속한 struct를 사용하는 편법이 있을순 있겠네요.



+ 사실 위 코드들은 직렬화라고 하기엔 치명적인 약점들이 있는데, 1. 빅엔디안과 리틀엔디안 간의 바이트 순서 차이를 통일하지 못하고, 2. 정말로 값만 저장하기 때문에 바이너리 스트림에 저장된 타입과 읽어올 공간의 타입이 같은지 검증이 불가능합니다.

약간의 코드 추가로 두 약점 모두 충분히 개선이 가능하겠지만, 그걸 다 구현할 바에는 역시 boost::serialize를 쓰는게 편하겠죠?

관련글 더보기

댓글 영역