Notice
Recent Posts
Recent Comments
Link
«   2025/07   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

TriviaBox

[펌]직렬화 Serialization 본문

Game Development/Misc

[펌]직렬화 Serialization

곰도리탕 2017. 10. 12. 17:37

출처: http://j.mearie.org/post/122845365013/serialization

원글 작성일:

며칠 전 해커뉴스에서 “인터넷은 디버깅 모드로 돌아가고 있다"는 글을 보았다. 아, 원래 글을 읽을 필요는 없다. 글의 내용이라는 게 JSON이나 XML은 텍스트 포맷인데 바이너리 포맷보다 오버헤드가 몇 배 이상 되기 때문에 CPU와 대역폭을 더 많이 소모하고 이게 모여서 환경 이슈를 일으킨다는 얘기다. 물론 더 설명할 필요 없이 개소리다. JSON의 오버헤드에 대한 몰이해(JSON/XML이 그만치 비효율적이지 않다는 얘기가 아니고, 반대로 글쓴이가 생각하는 것보다도  비효율적이라는 의미에서)는 차치하고라도, 원래 컴퓨터는 자원을 써서 사람을 편하게 하는 데 그 의의가 있는 것이고 그게 프로그래밍에 적용되지 않을 이유는 없다. 텍스트 포맷이 그 비효율성에도 불구하고 살아 남는 데는 당연히 합당한 이유가 있다. 사실 거기까지 가지 않아도 현대 컴퓨터는 계산기라기보다는 복사 기계에 더 가깝다만.

이 개소리를 굳이 글 서두에서 소개하는 이유는, 저 글의 앞머리에 쓰여 있는 한 가지는 진실이기 때문이다. 프로토콜의 “인코딩”과 “의미론”은 다른 것이다. (실은 의미론도 성능에 큰 영향을 주기 때문에—이를테면 작은 패킷을 레이턴시가 허용하는 한도 안에서 뭉치거나—이 또한 원래 글의 충분한 반론이 된다.) 그리고 우리는 의미론 신경쓰기에도 바쁘기 때문에 인코딩은 우리가 안 만들고 있는 걸 잘 가져다 쓰고 싶다. 다차원의 자료를 파일로 저장하거나 네트워크로 보내기에 알맞게 일차원으로 펼치고 다시 원래대로 되돌리는 것을 우리는 직렬화(serialization)라고 부른다. 프로토콜의 바이트 인코딩은 직렬화가 쓰이는 대표적인 예이다. 최근에 직렬화에 관련해서 생각을 여럿 정리할 일이 있었는데, 좋은 기회가 될 것 같아 이 참에 글로 정리한다.

직렬화의 종류

모든 직렬화에는 직렬화되는 자료가 어떤 종류인지를 나타내는 데이터 모델이 있고, 이 데이터 모델이 언제 결정되느냐에 따라서 직렬화를 두 종류로 나눌 수 있다. 이 차이를 생각하지 않고 직렬화 방법을 선택하면 비효율적이거나 확장성이 떨어지게 된다.

데이터 모델이 고정되어 있어서 나중에도 별로 바뀌지 않는다는 걸 알고 있는 경우, 직렬화 포맷을 데이터 모델마다 생성해서 쓸 수 있는데 이를 schematic하다고 부른다. (음, 정확하게 이렇게 부르는진 모른다. 적어도 나는 이렇게 부른다.) 흔한 예로 프로토콜 버퍼나 Cap’n Proto 같은 것을 들 수 있다. Schematic한 직렬화 방법은 성능을 우선시하느냐 데이터 크기를 우선시하느냐에 따라 다시 크게 나눌 수 있는데, 어지간해서는 어느 쪽이든 후술할 schemaless한 직렬화보다는 효율적이다. 엄밀히는 직렬화 방법 자체의 장점은 아니지만, 데이터 모델이 고정되어 있기 때문에 데이터 모델에 어긋나는 데이터를 포맷 단에서 대부분 걸러낼 수 있다는 것도 좋은 점이다. 하지만 미리 확장성을 고려하지 않으면 새로운 데이터를 표현할 수 없다는 근본적인 약점이 있으며, 보통 포맷을 자동 생성하기 위한 컴파일러를 끼고 들어가기 때문에 쓰기가 좀 귀찮다는 단점도 있다.

반대로, 대부분의 데이터 모델을 포함하는 범용(universal) 데이터 모델을 쓰는 접근이 있다. 예를 들어서 재귀가 없는 자료라면 스칼라값(숫자나 문자열 등), 순서가 있는 순열 및 순서가 없는 집합만 가지고 어지간한 건 다 나타낼 수 있다. 이러한 접근을 따르는 방법을 schemaless하다고 한다. (이것도 내가 부르는 말이다.) 흔한 예로 JSON이나 MessagePack 같은 것이 있다. Schemaless한 직렬화 방법은 schematic한 것과 정 반대되는 장단점을 가지고 있는데, 자료의 검증을 보통 수동으로 해야 하고 덜 효율적이지만, 해당 포맷에 공통되는 도구들을 공유해서 쓸 수 있고, 보통 잘 표준화되어 있으며 상호운용성이 올라간다. 예를 들어서 파일 포맷으로 JSON을 쓴다면 물론 크기는 좀 커지겠지만 대신 어지간한 언어에서 별도 작업 없이 그 파일을 읽을 수 있게 될 것이다.

목록에서 XML이 빠진 것을 눈치챈 사람이 있을 것이다. XML은 내 개인적으로 생각하기에는 이 개념들이 잘 정착하기 전에 만들어진 안타까운 물건인데, XML이 DTD나 XML Schema 같은 것과 결합해서 스키마가 존재하게 되면 schematic하고 그렇지 않으면 schemaless하다고 할 수 있다. XML이 안타까운 이유는, 둘 중 하나를 선택하지 못 하고 이도 저도 아닌 공통 포맷을 사용하고 있다는 점이다(…). 그것도 아주 복잡다단한, 부분적으로만 구조화되어 있고(semi-structured) 자연어 문서에 가까운 포맷을 말이다. XML의 유일한 장점은 포맷과 포맷을 다루는 기반 기술들이 어쨌든간에 다 표준화되어 있다는 점이었는데, 다른 표준화된 포맷들이 부상하면서 이제 내세울만한 장점도 아니게 되었다.

직렬화의 대분류 외에도 직렬화를 규정짓는 특성은 몇 가지가 더 있다. 우선 스트리밍 가능한 직렬화는 데이터의 전체 크기를 알지 못 하고도 직렬화를 할 수 있다는 의미이다. (한 번 쓴 데이터로 다시 돌아가서 크기를 넣는다거나 하는 것은 불가능하다고 친다. 이게 항상 가능하다는 보장이 없다.) 보통 이는 배열이나 문자열 같은 것의 길이가 데이터 앞에 나오지 않는다는 것을 의미하는데, 역직렬화를 빠르게 하려면 길이를 먼저 아는 게 우선이기 때문에 필연적으로 직렬화를 빠르게 하기 위해서 역직렬화 성능을 희생한다고 생각하면 좋다. 그렇기 때문에 스트리밍을 명시적으로 의도하고 짜여진 직렬화 방법은 스트리밍할 거냐 말거냐를 선택할 수 있는 경우가 대부분이다. 또한 스트리밍이 된다 하더라도 문자열 같은 원자적 값까지는 스트리밍이 불가능한 경우가 있으니 구분할 필요가 있다.

정규화 가능한 직렬화는 데이터 모델에 들어 있는 모든 또는 대부분의 데이터에 대해서 정확히 하나의 정규화된 직렬화 결과가 존재하며 그 역도 성립한다는 뜻이다. 이를테면 순서 없는 순열(집합)에서 모든 원소를 특정 순서대로 정렬해서 표현한다면 한 집합에는 하나의 정렬된 방법만 존재하므로 정규화가 될 것이다. 정규화가 왜 필요하냐고 묻는다면… 암호학적 해시를 구한다거나 하는 걸 생각하면 좋겠다. 물론 정규화는 그다지 싸지 않은데, 있으면 좋긴 하지만 흔히 쓰는 게 아니라서 기본 직렬화 방법과 별개로 정규화된 부분집합이 있는 경우가 흔하다. 간혹 그런 거 쌩까는 사례가 있는데 나중에 정규화가 필요한 경우 ad-hoc하게 정규화를 붙이는 꼴사나운 경우가 생긴다.

랜덤 접근이 가능한 직렬화는 직렬화된 거대한 데이터를 모두 디코딩해 보지 않고서 데이터의 일부에 접근할 수 있다는 뜻이다. 실제로는 여러 가지 가능성이 있는데, 가장 넓은 의미에서는 개별 원자적 값의 길이에 의존하지 않는 시간이 걸리면 랜덤 접근이라고 할 수 있다. (이를테면 val[a][b][c]를 구하는데 많아봐야 O(a+b+c) 시간이 걸려야 한다.) 이보다 더 빠른 것도 불가능하진 않지만 (후술할 Cap’n Proto의 경우 배열만 중첩되어 있다면 n중 배열에 대해서 O(n) 시간까지 줄어든다) 일반적으로는 직렬화 포맷은 메모리에서 최대 속도로 읽을 것을 감안하고 만들어진 것이 아니기 때문에 이 정도만 해도 감지덕지다. 짐작할 수 있듯, 스트리밍 가능한 직렬화는 랜덤 접근이 불가능하거나 어렵다.

Schematic한 직렬화

ASN.1

수요가 있으니 당연히 오랜 역사가 있지만, 그 중에서도 초창기의 것으로서 가장 유명한 것은 역시 ASN.1일 것이다. 그렇다. SNMP나 인증서 관련된 작업을 하면 치를 떨면서 싫어하게 될 바로 그것이다(난 둘 다 해 봤다). 그래도 생각보단 나쁘지 않은 게, ASN.1은 데이터 모델과 인코딩을 거의 완벽하게 직교적으로 만든 성공적인 사례이며, 먼 훗날 다른 직렬화 포맷들이 겪을 대부분의 문제를 첫빠따로 맞아 봤기 때문에 거기에 대한 대비가 상당히 많이 되어 있다. ASN.1이 지원하는 특이한 요소를 살펴 보면 다음과 같다.

  • 임의 크기 정수
  • 8의 배수가 아닌 비트 수를 가진 비트열
  • 순서 없는 목록, 즉 집합
  • 특정 상황에서만 등장할 수 있는 필드
  • 해석 방법을 다른 필드에 의존하는 필드 (ANY DEFINED BY foo라고 쓰면 foo 필드의 값에 따라 해당 필드를 해석하겠다는 뜻이다.)
  • 명시적인 비트 필드
  • 문자열 등의 길이를 알지 못 하는 경우에도 인코딩 가능 (현대적인 포맷에서 이게 되는 경우는 CBOR 정도 밖에 없다)
  • 잘 정의된 정규화 규칙 (BER/DER의 구분)
  • (약간) 확장 가능한 인코딩 규칙

ASN.1이 지원하지 않는다고 확실하게 말할 수 있는 기능은 실수 타입 뿐이다. 그나마 ASN.1이 그 이듬해, 즉 1985년에 나온 IEEE 754보다 빨리 나와서 그런 거지… 반면 현대의 직렬화 포맷에서는 위에 있는 것 중 반 정도 지원하면 많이 지원하는 편에 속한다. 예를 들어서 “스트리밍”이 가능한, 즉 데이터의 길이를 알지 못 하는 경우에도 인코딩이 가능한 포맷들조차도, 이를테면 하나의 문자열을 쪼개서 보내는 게 가능한 경우는 드물다. 1984년에 나온 표준이 30년 뒤에 나온 것보다 기능이 많다니 대단하지 않은가?

그러나 이 엄청난 복잡도가 결국 ASN.1의 발목을 잡았다. ASN.1만 그런 게 아니라, schematic한 직렬화를 제대로 구현하려면 데이터 모델을 기술하는 문법을 완벽하게 해석해서 거기에 따른 완전한 코드를 생성해 주는 컴파일러가 필요하다. 그런데 모델이 너무 복잡해서 컴파일러를 잘 만들기 어려웠다. 더군다나 ASN.1 자체가 오래된 포맷이기 때문에 레거시가 상당한데, 단적으로 문자열 타입이 11개(!!!!) 있다. 컴파일러는 울며 겨자먹기로 이 모든 걸 지원해야 한단 말이다… 그래서 컴파일러에 질린 사람들은 코드 생성을 하지 않고 손으로(!) ASN.1 기반 포맷을 수동 파싱하기 시작했다. 그리고 오늘날 ASN.1을 사용하는 X.509 인증서 구현에서 나오는 상당수의 보안 버그는 이 수동 파싱 코드에서 유래한다고 보아도 과언이 아니다.

XDR

ASN.1과 비교적 비슷한 시기에 등장했고 ASN.1보다 덜 짜증나는 포맷의 예제로 XDR이 있다. ASN.1을 제외한 거의 모든 schematic한 직렬화 방법들이 그렇듯, XDR은 故 썬 마이크로시스템즈(…)에서 원격 프로시저 호출(RPC) 프로토콜(이 경우, ONC RPC)에 쓰려고 만든 바이너리 포맷이다. XDR 또한 데이터 모델을 기술하는 언어를 가지고 있지만, 표준이 훨씬 읽을 만 하고 (ASN.1은 이런 비공식 자료 같은 게 그나마 쓸만하다는 문서이다) 별도의 컴파일러 없이 파서를 만들기가 훨씬 쉽다. 오죽하면 해커뉴스에서 “왜 30년 전에 나온 XDR은 놔두고 JSON의 바이너리 포맷을 만드나요?”라는 시니컬한 반응까지 나왔겠는가. (후술하겠지만 둘이 완벽히 일치하는 역할은 아니다.)

XDR 또한 30년 전에 만들어진 직렬화 방법이기 때문에 현대의 직렬화 방법과 비교하면 흥미로운 점이 몇 가지 있다. 대표적으로 4바이트 패딩이 있는데, 이 때문에 XDR에는 8비트 정수나 16비트 정수 같은 게 없고, 많은 플랫폼에서 빠르게 동작할 수 있는 대신 작은 값을 많이 쓴다면 데이터 크기가 좀 커질 수 있다. 또한 보통 구별된 공용체(discriminated union)라고 부르는, 여러 가능한 데이터 중 정확히 하나만을 가리키는 종류의 타입들이 XDR에도 있는데, 무슨 데이터인지를 가리키는 구별값(discriminant)이 명시적이다. 그래서 구별값을 원하는 타입(이라고는 해도 정수 아니면 열거형이지만)으로 줄 수 있는데, 개인적으로는 괜찮은 선택이라고 생각한다. 반면 스트리밍을 지원하지 않는다는 점(모든 데이터는 사전에 크기가 정해져 있어야 한다)과, 포맷 구조상 배열 원소를 하나씩 읽지 않고 통째로 스킵할 수 있는 방법이 존재하지 않는다는 점은 확연한 단점이라고 할 수 있다. 후자는 opaque 타입으로 시뮬레이션할 수 있긴 하지만. 이런 점을 종합해 볼 때, XDR은 의외로 네트워크 프로토콜용보다는 바이너리 파일 포맷을 설계하는 용도로 더 쓸모가 있지 않나 싶다.

XDR 말고도 원격 프로시저 호출에서 유래한 직렬화 방법은 여럿 있다. 대표적인 걸 꼽으면 오만 잡다한 곳에서 쓰이는 CORBA에는 IDL을 네트워크를 통해서 보내기 위한 GIOP라는 포맷이 있고, 자유 소프트웨어 진영에서 널리 쓰이는 D-Bus 같은 것에도 물론 직렬화 포맷이 있고, 페이스북과 구글은 자기네 쓰려고 각각 Thrift와 프로토콜 버퍼를 만들었다. 이런 것들을 하나 하나 설명하지 않는 이유는 구조가 너무 뻔하고 뻔하기 때문이다. 아 물론 D-Bus의 VARIANT 타입은 많이 좀 특이하고, 프로토콜 버퍼에는 예상되는 범위에 따라 인코딩된 크기가 제각각인 정수 타입이 여럿 있고 같은 종류의 차이가 있긴 하지만 근본적인 설계가 크게 다르지는 않다는 얘기다. 그래서 여기에서는 이들과 다소 동떨어진 성격을 가진 두 개의 포맷만 더 설명하기로 하겠다.

Cap’n Proto

Cap’n Proto는 프로토콜 버퍼의 정신적인 후계자라 할 수 있다. (개발자가 원래 구글에서 프로토콜 버퍼 만들다가 나온 사람이다.) 이 포맷의 최대 특징은 인 메모리 표현이라는 것이다. 즉 디코딩 없이도 데이터를 읽을 수 있다! 그래서 하는 드립이 “infinitely faster”이다(…). 그냥 단순히 인 메모리 표현인 수준이 아니라 온갖 최적화가 들어 있는데, 몇 가지를 들면:

  • 많은 직렬화 포맷과는 달리 (반면, 많은 데이터 포맷과 비슷하게) 구조체 같은 복합 자료형을 그걸 포함하는 자료형에 인라이닝해서 집어 넣지 않고 내용과 그 내용을 가리키는 포인터로 나눠서 저장한다. 단, 구조체의 리스트는 spatial locality를 위해 인라이닝한다.
  • 기본값이 있는 필드는 저장시 그 기본값과 XOR된다. 이러면 기본값과 비슷할 수록 0인 바이트가 많아지는데, 구조체 등에서 0인 바이트는 뒤에서 짤라 낸다. 크기가 줄어듦과 동시에 나중에 포맷을 확장하는 데도 유리하다(추가된 필드가 이전 버전의 데이터에서는 기본값으로 해석되도록 만들 수 있다).
  • 읽기 성능을 위해 패딩이 들어가지만, 패딩을 최소화하도록 (많아야 63비트 이하가 되도록) 필드의 순서를 재배치한다. 그리고 최소 인코딩 단위가 1비트라서 비트 단위 패킹도 들어간다! 그래서 Bool이 여덟개 있으면 1바이트만 잡아 먹고, List(Bool)은 훌륭한 비트열 구현이 된다.
  • 포인터가 들어갈 필드들은 보통의 값이 들어갈 필드들과 구분되어 뒷쪽에 배치된다. 포인터가 들어갈 필드들은 오프셋이 필드 위치에 상대적이라서 이동시 재지정이 필요한데, 값과 섞이지 않기 때문에 재지정이 많이 간단해진다.
  • UTF-8 문자열을 담는 Text 포맷은 기술적으로 뒤에 널 문자가 포함된 List(UInt8) 타입과 동일하다. C/C++ 따위에서 쓰려니 이렇게 되었다.
  • 직렬화시 버퍼의 재할당을 막기 위해 데이터 포맷 자체가 논리적으로 여러 버퍼로 쪼개질 수 있도록 설계되어 있다. 그 결과로 구조체나 리스트에 대한 포인터는 보통 포인터일 수도 있고 다른 버퍼를 가리키는 포인터일 수도 있다. 쓰는 입장에서는 둘 다 똑같지만.

ASN.1에서 볼 수 있듯 이런 기발한 최적화들은 자칫하면 복잡도를 유발해서 구현체를 침몰시킬 수 있는데, 다행히도 Cap’n Proto 구현들은 비교적 괜찮은 편이다. 애당초 ASN.1처럼 표준만 만들고 구현들을 만들라고 하는 게 아니라 구현체 주도적으로 표준이 만들어지고 있기 때문에 상황이 나은 편이기도 하다. 직렬화 포맷 뿐만 아니라 Cap’n Proto RPC에도 흥미로운 아이디어들이 여럿 있는데(예를 들어 Promise에 기반해 여러 RPC 호출을 파이프라이닝하는, 이른바 “시간 여행”) 이 글의 범위를 한참 벗어나므로 생략. Cap’n Proto가 흥한 뒤로 디코딩 프로세스 자체를 생략하는 다른 포맷들이 여럿 나왔는데, 이들에 대한 비교는 Cap’n Proto 저자가 직접 쓴 글을 참고하자.

EBML

Cap’n Proto가 프로토콜 버퍼의 현대적인(…) 버전이라면, EBML은 ASN.1의 현대적인 버전이라고 할 수 있다. EBML은 본래 매트료쉬카(Matroska) 미디어 컨테이너 포맷에서 쓰기 위해 만든, 그러니까 RPC랑 전혀 상관이 없는 순수 파일 포맷용으로 만들어진 것이다. 아무래도 컨테이너 안에는 메타데이터도 이것 저것 많고 다양한 종류의 패킷들이 혼재해 있어서 만들지 않았나 싶다. 이전에도 파일 포맷을 체계적으로 만들려는 시도는 여럿 있었는데, PNG의 chunk 설계나 RIFF 같은 것들이 그 예시가 되겠다. 하지만 직렬화 포맷으로서 쓸만한 건 내가 알기로는 EBML 정도 밖에 없는 듯 싶다.

EBML은 ASN.1의 태그-길이-값(TLV) 포맷을 매우 정확히 준수한다. 즉 길이 부분을 생략할 수 없다. 하지만 ASN.1에 비해 복잡도는 매우 떨어졌는데, ASN.1에는 모든 데이터 모델에서 공통적으로 사용할 수 있는 태그들(universal tags)이 있었지만 EBML은 궁극적인 해석을 데이터 모델에게 모두 맡긴다. 즉 “32비트 정수” 같은 태그가 있는 게 아니라 명시적으로 “이 파일이 바뀐 시점의 유닉스 타임스탬프” 같은 의미론으로 태그를 짜야 하는 것이다. 그리고 서로 다른 의미론을 가진 같은 데이터가 나타나는 상황을 제거하기 위해 한 데이터 모델 안에서의 모든 태그는 달라야 한다. EBML에서 1바이트짜리 태그는 126개 있는데, 따라서 많이 커다란 포맷에서는 아주 빈번히 나타나는 태그의 종수를 줄일 필요성이 있을 것이다. (같은 종류의 태그가 여러 맥락에서 나타날 수는 있다.) 대신 모든 태그에 유일한 의미론이 붙기 때문에 디버깅하거나 랜덤 접근 같은 것들을 하기는 좋을 것이다.

Schemaless한 직렬화

S-expression, Netstring, XML

Schematic한 직렬화 방법들이 네트워크 프로토콜과 파일 포맷에서 유래한다면, schemaless한 직렬화 방법들은 한동안 프로그래밍 언어에서 쓸 목적으로 유래하는 경우가 많았다. 그래서 어떻게 말하면 리스프의 리더 매크로가 최초의 schemaless한 직렬화 방법이라고 주장할 수도 있다. 한편으로는 네트워크 프로토콜 쪽에서도 schemaless한 수요가 없던 것은 아니라서, 널리 쓰이지 않았다 뿐이지 역사적으로는 MSDTP 같은 것도 있었다. 하지만 실질적으로 “표준화”되고, “언어나 구현체에 독립적”이며, 결과적으로 소기의 성과를 거둔 직렬화 방법을 기준으로 한다면, 아마 1997년이 최초의 쓸만한 무언가가 나온 때라고 할 수 있다. 그리고 흥미롭게도 이 시기에 제안된 언급할 가치가 있는 직렬화 방법은 세 개나 있다.

S-expression은 리스프의 바로 그 S-expression을 직렬화용으로 쓰기에 좋도록 몇 가지 수정을 가한 것이다. 이 제안은 암호학 분야에서 사용할 목적으로 인터넷 표준으로 제안되긴 했지만 실제로는 표준이 되지 않았고 덕분에 많이 쓰이지는 않는데, 그럼에도 불구하고 용도(SPKI 공개키 암호화 시스템)도 그렇고 저자(Rivest는 RSA의 그 R이다)도 그렇고 상당히 잘 짜여진 명세가 되었다. 요즘 쓰이는 직렬화 방법과 비교하면 몇 가지 차이가 있는데, 텍스트 포맷인지라 문자열을 표현하는 방법이 여럿(base-64, 16진법 표기 등) 있다는 것과, 모든 것이 문자열 아니면 순열이라는 것(숫자도 없다), 그리고 문자열에 선택적으로 표시 힌트가 붙을 수 있다는 점이다. 표시 힌트는 일종의 메타데이터인데 보통은 구현체가 해석해야 할 문자열에 추가적인 타입을 주는 역할을 할 수 있다. 예를 들어서 [image/png] "\x89PNG\r\n\x1a\n..." 같은 건 PNG 이미지라는 식으로. 데이터 모델이 단순하기 때문에 정규화된 표현도 만들기 쉽고 구현도 어렵지 않은 편이었는데, 아무래도 이런 포맷은 널리 쓰이지 않으면 아무리 좋아도 묻히기 때문에 다소 아쉽다.

Netstring은 S-expression을 좀 더 작게 만든 모양새이다. 이 또한 암호학에서 유명한 Daniel J. Bernstein(djb)의 작품인데, djb는 아예 대놓고 C 구현을 매우 단순하게 만들겠다는 목표로 포맷을 최소한으로 줄였다. 그래서 나온 것이 <길이>:<데이터>,라는 유일한 구조. 엄밀히 말하면 netstring은 스키마가 없으면 파싱이 불가능한데 데이터가 또 다른 netstring인지 다른 데이터인지 알 방법이 없기 때문이다. 하지만 역사적으로는 S-expression의 의도한 용도와 궤를 같이 하고, 실제로 부분집합으로 취급할 수 있기 때문에 여기에서 다룬다.

XML은 다들 잘 아시는 그것이다. 사실 XML을 1997년대(정확히는 1996~1998년)에 만들어졌다고 하기에는 약간 무리가 있긴 한데, XML은 결국 SGML의 데이터 모델은 그대로 가져 오고 문법은 기본 문법을 토대로 만들어졌으며, SGML의 이른바 태그 수프(tag soup) 데이터 모델은 그 역사가 매우 깊기 때문이다. 하지만 태그 수프 데이터 모델을 처음으로 데이터 포맷의 영역으로 끌어 당긴 것이 XML임은 부정할 수 없다. 이에 뒤따르는 XML 관련 기술의 흥망성쇠는, 결국 XML의 데이터 모델을 잘 이해하지 못 한 것에 기인한다고 생각한다. 어째서 XML로 JSON을 분해하고 재인코딩하려 하며, 어째서 XML을 스트리밍 포맷으로 쓰려 하며, 진짜로 어째서 뭐가 잘못되었길래 XML로 프로그래밍 언어를 만들고 있는가?

Bencode, JSON

Bencode는 비트토런트에서 메타데이터를 교환하기 위해서 만들어진 직렬화 방법이다. 물론 모든 토런트 데이터가 bencode를 통하는 건 아니고(비트토런트의 피어 프로토콜은 매우 간단한 바이너리 프로토콜이다), .torrent 파일이나 트래커 응답이 bencode로 날아 온다. Bencode는 정수, 문자열, 순열, 그리고 (파이썬의 관례를 따라 “사전”이라고 부르는) 연관 배열을 지원하며, 숫자가 들어 올만한 모든 곳에서 바이너리 인코딩을 쓰는 대신에 그냥 10진법을 아스키 코드로 때려 박기 때문에 엔디안 문제 등에서 자유롭다는 특징이 있다. 또한 비정규화된 데이터, 이를테면 정수 42를 i42e 대신 i042e로 나타내거나, 연관 배열의 키 순서를 뒤섞거나 하는 것이 원천 차단되어 있어서 모든 bencode된 데이터는 정규화되어 있다. 반면 배열 안에 들어가는 원소 숫자나 배열의 바이트 길이 같은 게 적혀 있지는 않기 때문에 빠르게 탐색하는 데는 적합하지 않고, 따라서 말 그대로 정규화된 인코딩/디코딩에 적합한 포맷이라 할 수 있다.

JSON은 물론 모두들 잘 알고 계시는 그것. 자바스크립트 문법을 기반으로 Douglas Crockford가 표준화를 시도한 것이 널리 퍼졌다. JSON은 자바스크립트로 (거의) 그대로 해석될 수 있도록 설계되긴 했지만 보안 문제 때문에 아무래도 상관 없는 이야기가 되었고, 진짜로 가장 큰 의의는 유니코드 문자열을 전면에 내세우는 처음으로 쓸만한 포맷이라는 점이다. (즉, XML은 쓸모가 없다…) 이 선택은 큰 의미를 지니는데, 이 뒤로 나오는 포맷 중 상당수가 “바이너리 문자열”과 “텍스트 문자열”의 구분을 엄밀하게 하려고 시도하기 때문이다. 앞에서 말한 bencode조차 문자열은 바이너리였다. 아니, 정규화를 차치하고라도 이전에 만들어진 수많은 데이터 포맷들이 이미 인코딩 문제로 골머리를 썩고 있던 상황이었다. 이런 상황에서 텍스트 문자열은 유니코드로 인코딩해야 한다는 어찌 보면 당연하지만 별로 안 지켜지던 규칙을 JSON이 타이밍 좋게 지킨 셈이다.

Bencode와 JSON은 거의 같은 데이터 모델을 가지고 있으며, 심지어 나타난 시기도 거의 비슷하다. 아마도 비트토런트가 지금 만들어졌더라면 bencode는 JSON으로 모조리 대체되었을 것이다. 그럼 굳이 왜 bencode를 여기서 언급하는가? 왜냐하면 인간이 읽을 필요가 없다면 bencode는 JSON보다 거의 확실하게 나은 포맷이기 때문이다. JSON이 bencode보다 나쁜 것들은 대략 이런 게 있다.

  • 탈출열로만 표현할 수 있는 문자들은 오버헤드가 많다. 예를 들어서 UTF-8로 U+001B 한 문자를 표현하는데 JSON은 8바이트("\u001b")겠지만 bencode는 3바이트(1: + 1바이트)이다. 물론 한 문자만 표현하는 게 아니면 오버헤드는 훨씬 더 누적된다(최대 6:1).
  • 비단 탈출열이 없거나 적은 문자열만 쓴다고 하더라도, 탈출열이 존재한다는 것 하나만으로 성능에 굉장한 페널티가 붙는다. 현대적인 직렬화 포맷은 복사 없이 데이터를 그대로 접근할 수 있는 걸 선호하는데(zero-copy) JSON은 어떻게 구현체를 짜도 복사가 필요하다. (이것이 내가 JSON이 생각 이상으로 더 비효율적이라고 주장하는 근거이다. 문자열이 많을 경우 잘 구현된 JSON 구현체와 잘 구현된 다른 바이너리 포맷 구현체는 수백배 이상 차이가 날 수 있다.) S-expression처럼 탈출열이 없어서 바로 해석할 수 있는 종류의 문자열이 하나 더 존재했으면 상황이 훨씬 나았을 것이다.
  • JSON은 일단은 자바스크립트 문법의 부분집합으로 설계되긴 했고 그렇게 생겨먹기도 했지만, 실은 아니다. U+2028 LINE SEPARATOR와 U+2029 PARAGRAPH SEPARATOR는 유니코드에서 플랫폼에 무관한 개행문자를 지원하려 넣은 문자인데, 이 문자의 쓸모는 그렇다 치더라도 자바스크립트에서는 문자열에 이 문자들이 들어갈 수 없는 반면 JSON에서는 들어갈 수 있다. 물론 JSON을 작성할 때는 해당 문자들을 탈출열로 표현하고 읽는 쪽에서는 어느 쪽이든 허용하게 하는 것도 가능한데 (어차피 탈출열을 피할 수는 없으므로 이게 최선이다), 이 문제가 알려진지 몇 년이 지났는데도 표준에서는 감감 무소식이다. 그냥 쌩까려고 하는 듯.
  • 숫자를 텍스트로 표현하기 때문에 실제로 숫자로 해석되었을 때의 상호 운용성이 중요한데 표준에서 보장하는 게 없다. 기껏해야 “IEEE 754 binary64 정도면 괜찮지 않을까?” 수준… 덕분에 64비트 정수를 쓰려고 하는 구현체들은 눈물을 머금고 나중에 문자열로 재인코딩을 해야 했다. 즉 처음에는 모르고 정수를 썼다가 나중에 문자열로 바꿔야 한단 얘긴데, 실제로 트위터가 예전에 이걸로 큰 엿을 먹었었다. 표준이 무엇이 보장되고 무엇이 보장되지 않는지에 대해서 너무 대강 대충 쓰고 넘어가서 생긴 불상사.
  • 오브젝트에 중복되는 키가 있는 경우를 대놓고 “어떻게 동작해도 상관 없다”고 적어 놓고 있다. 뭐? (Bencode의 경우에도 명시적으로 이런 경우를 오류로 처리하진 않는다. 하지만 키가 정렬되어 있어야 하기 때문에 중복되는 키를 일부러라도 만들기가 더 어려워진다.) 물론 JSON 표준은 최종적으로는 구현체들의 실태를 토대로 만들어진 것이기 때문에 표준을 두루뭉술하게 만드는 건 어쩔 수 없을 수도 있지만, 나중에 만들어질 구현체들에 대한 가이드라인조차 없는 것은 실망스럽다.
  • 제대로 된 정규화 방법을 서술하지 않았다.

반면 bencode가 JSON보다 나쁜 건 이 정도이다.

  • true/false/null이 없다. 근데 true/false는 의미론은 좀 다르지만 i1e/i0e로 대체할 수 있고 null은 null-like한 값이 여럿 있는 환경에서 혼돈을 일으키기 때문에 딱히 없다고 나쁜 건 아니다. 좀 극적이고 짜증나는 예를 들면, 루아에서 쓸만한 JSON 구현체 중 하나는 null을 아예 제대로 처리하지 못 하고 다른 하나는 nil과는 다른 이상한 값(userdata 0x0)을 준다…
  • 정수 크기에 대한 보장이 없다. Bencode는 본래 파이썬을 기준으로 만들어졌기 때문에 딱히 그럴 필요도 없긴 했지만, 여러 언어로 구현될 수 있는 포맷인 이상 제한을 어딘가에서 뒀으면 좋았을 것이다. 일단은 사실상의 표준으로 64비트 부호 있는 정수가 쓰이고 있다.
  • 실수 타입이 없다. 하지만 어차피 JSON의 실수 타입은 Infinity/NaN도 없고 딱히 디코딩된 결과가 정확히 어떤 실수값으로 디코딩되는지에 대해서 표준이 입을 닫아 버렸기 때문에 그냥 사용자의 책임으로 문자열로 오가는 게 더 나을 수도 있다. 0.1이 IEEE 754 binary64에서 0x3fb9999999999999로 디코딩되어야 할지 0x3fb999999999999a로 디코딩되어야 할지 내가 알게 뭐람? (보너스 점수: 어느 쪽이 올바른 반올림인지 알아 맞춰 보시오.)
  • 연관 배열의 키 순서가 고정이기 때문에 성능 페널티가 좀 있다. 이걸 피한답시고 키 순서를 수동으로(…) 쓰는 인간들이 있을 수 있는데, 이런 구현과 디코딩할 때 키 순서 제대로 확인 안 하고 넘어가는 구현이 합쳐지면 재앙이 일어난다.
  • 유니코드가 기본이 아니다. 뭐, 정 안 되겠으면 UTF-8을 강제하도록 하자.

종합하면, bencode는 엄밀히 쓰여진 표준이 아닌데도 생각보다 나쁘지 않은 결과가 나온 반면, JSON은 표준화되었는데도 이 모양 이 꼴이다. 아놔. 자바스크립트 문법의 (자칭) 부분집합이라는 목표를 감안해도 JSON 표준은 상당히 엉성한 편이다. (ECMAScript 표준은 거의 모든 언어 동작을 서술하는 걸로 유명하다는 것과 비교해 보자!) 그러나 이 모든 단점에도 불구하고, JSON은 하나의 이정표를 세웠다. 누구나 납득하고 사용할 수 있는 하나의 범용 데이터 모델 말이다. 그렇기 때문에 다른 좋은 이유가 없다면 (내 맘에는 안 들긴 해도) JSON을 쓰는 것 자체가 나쁜 건 아니다. 그냥 JSON이 이런 저런 제약을 갖고 있다는 것만 이해하면 된다. 한편 JSON이 대중화된 뒤로 만들어진 거의 모든 schemaless한 직렬화 방법은 JSON 데이터 모델을 기반으로 확장하는 모양새를 띠고 있는데, 그럼에도 이들이 모두 JSON이 범했던 기술적인 오류를 피해 간 건 아니다.

MessagePack, BSON

JSON은 물론 널리 쓰이지만, 텍스트 포맷이라는 문제 하나 만으로 왠지 쓰고 싶어지지 않을 때가 있다. 선술했듯 bencode도 대안이긴 하지만 JSON 데이터 모델에 딱 들어 맞는 것은 아니다. 그래서 등장한 것이 MessagePack인데, 아마도 현재 “바이너리 JSON”이 필요하다고 하면 가장 쉽게 사용할 수 있는 종류의 포맷이 아닌가 싶다. 대표적으로 레디스 루아 환경에서는 JSON과 MessagePack을 기본으로 지원한다.

MessagePack의 데이터 모델은 JSON과 거의 같은데, 정수 타입과 실수 타입이 명확히 나뉘어 있고, 텍스트 문자열과 바이너리 문자열이 나뉘어 있으며, 구현체가 지정하는 확장 타입을 최대 256종까지 집어 넣을 수 있다는 것이 얼마 안 되는 차이점이다. JSON 데이터 모델과 정확히 같지는 않지만 거의 무시해도 되는 차이라고 할 수 있다. 사실 바이너리 문자열을 텍스트가 아니라 모든 값이 0~255 사이의 정수인 배열로 매핑시키면 아예 똑같다고 주장해도 된다.

바이너리 포맷으로서 MessagePack의 특이점은 작은 정수들(-32부터 127까지)이 1바이트로 표현된다는 점, 짧은 길이를 가진 문자열이나 배열 등의 오버헤드가 1바이트 밖에 안 된다는 점, 그리고 좀 더 나아가서 모든 타입 코드가 첫 1바이트에 몰려 있다는 점이라고 할 수 있다. 실제 매핑을 보면 1바이트 영역을 최대한 알차게 쓰려고 최선을 다한 걸 볼 수 있는데, 이 때문에 MessagePack은 포맷의 확장성을 포기해야 했다. 당장 할당할 수 있는 타입 코드가 256개 중 하나(!) 밖에 남아 있지 않은 상황이다. (위치상 원래는 자바스크립트 undefined를 의도했던 것 같다.) 이런 매우 세밀한 할당은 후에 CBOR이 계승하게 되며, 한편으로는 MessagePack이 일찌감치 완벽한 바이너리 JSON로 자리잡는데 걸림돌이 되어 왔다. 일단 스트리밍이 안 되는 터라…

MessagePack과 동시기에 나온 포맷 중에서 또 하나 유명한 것이 BSON이다. 이 포맷은 RPC가 아니라 MongoDB에서 프로토콜에 쓰려고 만든 것이 시초이며, JSON이 비효율적이라는 (당연한) 이유로 만든 것이기 때문에 이름 또한 “Binary JSON”의 줄임말이다. 그리고 이 이름은 희대의 낚시 이름이 되었다. 결론부터 말하면, 이 포맷은 이름값을 못 한다. 왜 그런지 살펴 보자.

BSON은 주장과는 달리 JSON 데이터 모델을 쓰지 않는다. MongoDB를 써 보신 분이라면 아시겠지만 BSON이 JSON으로 매핑될 때는 $로 시작하는 특수한 키를 사용하고, 따라서 “일반적인 용도로는” 이러한 키를 사용할 수 없다! JSON에서 본래 데이터 모델의 범위를 벗어나는 데이터를 표현하는데 이러한 키를 사용한다는 걸 생각하면—아마도 이게 노림수겠지만서도—, 올바른 JSON 데이터인데도 MongoDB에 그대로 넣을 수 없는 경우가 왕왕 생기게 된다. 그래서 이런 상호 운용성을 해치면서 집어 넣었다는 타입이 12바이트 오브젝트 아이디, 64비트 타임스탬프, 정규식(아니 도대체 왜?), 자바스크립트 코드(아니 도대체 왜…???) 따위이다. 이런 기괴한 구성은 순전히 BSON이 MongoDB 내부의 용도로 만들어졌고 일반적인 직렬화 포맷으로 만들어지지 않았다는 것을 증명한다.

BSON이 직렬화 포맷으로서 실격인 점은 하나 더 있다. 대부분의 직렬화 방법은 가장 기본으로 인코딩되는 값이 원자적 값이다. 즉 최상위에 스칼라 값(숫자나 문자열 등)이나 컨테이너(배열이나 연관 배열 등)가 올 수 있고, 컨테이너 안에 다른 원자적 값이 올 수 있는 식이다. JSON의 경우 나중에 보안 문제 때문에 연관 배열이 아닌 값을 최상위에 쓰는 것을 꺼리는 경향이 있지만, 그거야 HTTP에서 쓸 때의 얘기고 일반적인 경우에까지 적용되는 얘기는 아니다. 그런데 BSON의 기본 단위는 “문서”, 즉 연관 배열이다. 스칼라 값은 존재하지 않으며 단지 연관 배열의 “어느” 키가 “어느” 스칼라 값을 가진다는 사실만 인코딩된다. 한 술 더 떠서 배열도 타입 코드만 다를 뿐 연관 배열로 인코딩되어, [1, 3, 9]는 {"0": 1, "1": 3, "2": 9}와 같이 인코딩된다! (정확히 첫 한 바이트만 다르다.) 이 때문에 웬만한 BSON 라이브러리는 이 개념적인 차이를 메꾸기 위해 이상한 삽질을 해야 하고, 물론 구현의 품질에도 영향이 간다. 이를테면 C용 BSON 라이브러리는 어찌나 끔찍했던지 공식 라이브러리인 주제에 한 번 교체된 전적이 있다. (내가 직접 써 봤다…)

정리하자면, 본의 아니게(?) 펼쳐진 초창기 바이너리 JSON 포맷 대결에서 MessagePack은 BSON에 희대의 압승을 거두었다. 그럼에도 불구하고 BSON은 MongoDB가 흥함과 더불어, 그리고 그 이름 때문에 여전히 고려 대상이 될 수 있는 바이너리 JSON 포맷으로 인식되는 게 사실이다. 아무쪼록 이런 오해가 하루 속히 불식되길 바란다.

CBOR, UBJSON

MessagePack이 나온 뒤 더 나은 바이너리 JSON 포맷을 만들려는 시도는 여럿 있었지만 아직까지 뚜렷한 성과를 낸 포맷은 아직 없다. 하지만 어쩌면 앞으로 흥할 가능성이 있는 포맷이 몇 있긴 한데, CBOR과 UBJSON이 바로 그것이다. 둘은 많은 점에서 비슷하고 또한 많은 점에서 다르기 때문에 함께 소개하는 것이 비교 및 분석에 도움이 되리라 생각한다.

CBOR은 쉽게 말하면 MessagePack의 확장판이다. MessagePack 자체도 사실 JSON 데이터 모델을 보수적으로나마 확장했는데, CBOR은 좀 더 급진적으로 (하지만 근본적으로는 JSON과 큰 문제 없이 매핑되도록) 모델을 확장했다. 그래서 CBOR에는 16비트 실수(IEEE 754 binary16)나, undefined 값, 그리고 S-expression 이래로 오랜만에 등장하는 태그된 값이 있다. 이 태그된 값은 MessagePack의 확장 타입과 마찬가지 역할을 하며, 이를테면 큰 정수, 날짜/시각 타입, URL 등등이 모두 태그를 통해 표현된다. 또한 ASN.1과 유사하게 컨테이너 레벨과 데이터 레벨에서 모두 스트리밍이 가능하며(문자열도 쪼갤 수 있다), 표준에 JSON으로 변환하는 방법과 정규화 방법이 모두 포함되어 있다. 덕분에 구현체가 꽤 복잡할 것으로 보이지만, 제약을 많이 건 덕분에 디코더는 의외로 비교적 쉽게 구현할 수 있다(표준에 well-formedness 의사코드가 포함되어 있는데 실제 디코더도 크게 다르지 않다).

UBJSON은 JSON 데이터 모델에서 전혀 벗어나지 않고 적절한 바이너리 포맷을 만들려는 시도이다. CBOR은 다양한 데이터 타입을 지원하지만 이 데이터 타입이 모든 언어에서 동등하게 지원된다는 보장은 사실 없다. (binary16이 대표적인 예일 것이다.) 또한 포맷을 복잡하게 만들어서 직렬화된 결과를 더 작게 만들 수도 있지만 일반적인 압축 알고리즘보다 더 잘 하기는 어렵다. 따라서 UBJSON은 디코딩하기 매우 쉽고, “적절히” 작은 결과가 나오며, 그럼에도 현대적인 직렬화 방법의 요소를 갖고 있는 포맷을 목표로 하고 있다. UBJSON은 아직 현재 진행형인데(2015년 6월 현재 웹사이트에 있는 명세와 Github에 있는 명세가 다르다!;;;), 그럼에도 불구하고 i) 바이너리 데이터를 0~255까지의 정수만 담겨 있는 배열과 똑같이 처리하되, 오버헤드 없이 저장할 수 있도록 한다는 것과, ii) 컨테이너 단위 스트리밍을 지원하는 것, 그리고 iii) 디코딩에 영향을 주지 않는 keep-alive 명령을 추가한다는 것은 크게 변하지 않은 듯 하다.

CBOR과 UBJSON은 같은 문제에 대한 다른 흥미로운 대답을 제시하고 있다. 둘의 공통점은 스트리밍을 지원한다는 것이며(MessagePack에서 가장 많이 지적된 문제였던 것으로 보인다), 차이점은 데이터 모델에 대한 시각차로 요약할 수 있다. 개인적으로는 UBJSON이 바이너리 데이터를 취급하는 방법이 훌륭하다고 생각했다. JSON에서는 유니코드가 아닌 바이너리를 인코딩하는 방법이 여럿 있었으며 서로 상호 교환이 불가능했고, 그래서 훗날의 포맷들은 유니코드 문자열과 바이너리 문자열을 나누는 것을 최우선 과제로 삼았다. 그러나 UBJSON은 대신 널리 쓰이는 바이너리 인코딩 방법과 정확히 호환되면서도 실제로 인코딩할 때는 오버헤드가 생기지 않도록 하는 방법을 썼다. 이를 통해 데이터 모델을 그대로 유지하면서도 효율적으로 바이너리 데이터를 다룰 수 있는 것이다. 결론적으로, CBOR의 흥망성쇠는 좋은 구현이 얼마나 많이 나타나는가로, UBJSON의 흥망성쇠는 표준을 얼마나 덜 확장하는가로 결정될 거라고 생각한다(UBJSON 이슈 트래커를 보면 참 가관이다).

결론

데이터 직렬화는 풀린 듯 풀리지 않은 문제이다. 지난 40년(!)간 여러 많은 방법이 제안되었음에도 계속 새로운 포맷이 등장하고 흥하고 망하는 걸 보면, 이 문제는 유일한 답이 있다기보다는 용도에 따라 여러 가지 트레이드오프가 존재한다는 것이 옳은 시각일 것이다. 이 글에서는 여러 직렬화 방법들을 비교하면서 장단점을 논의하였는데, 그래도 굳이 몇 개의 포맷을 추천한다면 내 생각은 현재는 다음과 같다.

  1. 진짜 아무 라이브러리도 없이 쉽게 구현하는 게 목표라면 XDR이나 그 변종 (XDR에서 4바이트 정렬은 사실 무시해도 무방하다고 본다)
  2. 데이터 모델이 복잡하여 검증을 쉽게 했으면 싶으면 Cap’n Proto
  3. 검증이 별로 필요 없고, 최대한의 상호 운용성이 필요하면 JSON
  4. 검증이 별로 필요 없고, 크기/성능에 신경을 쓴다면 MessagePack

내 희망은 위에서 3/4번이 10년 안에 CBOR이나 UBJSON 등으로 함께 교체되었으면 싶지만, 앞날은 모르는 법. 그 전까지는 트레이드오프에 따라 잘 선택을 해야 할 것이다. 물론 잘못 선택했다고 내 탓은 아니다…

Comments