Cap’n Proto, FlatBuffers, SBE를 주요 정성적 차이(스키마 진화, 제로 카피, 랜덤 액세스, 보안, 리플렉션, 초기화 순서 등) 관점에서 비교한다.
[
그룹에서 토론하기GitHub에서 보기](https://capnproto.org/)
kentonv · 2014년 6월 17일
업데이트 2014년 6월 18일: 이 글의 원본 버전 이후로 일부 수정을 했습니다.
업데이트 2014년 12월 15일: Cap’n Proto 0.5가 이제 Visual Studio를 지원하고 Java도 이제 잘 지원된다는 점을 반영하도록 업데이트했습니다.
어제 구글의 몇몇 엔지니어들이 Cap’n Proto와 비슷한 설계 원칙을 가진 새로운 직렬화 프로토콜/라이브러리인 FlatBuffers를 공개했습니다. 또 몇 달 전에는 Real Logic이 이와 같은 성격의 또 다른 프로토콜/라이브러리인 Simple Binary Encoding(SBE)을 공개했습니다.
이제 우리에게는 다정한 경쟁 구도가 생긴 것 같습니다. :)
가능한 제로-카피 직렬화 포맷이라는 개념이 널리 퍼지고 있는 걸 보니 반갑고, 또한 모두 관대한 라이선스의 오픈 소스라는 점도 훌륭합니다. 하지만 사용자 입장에서는 이 시스템들이 서로 어떻게 비교되는지 궁금해질 수 있습니다. 이 인코딩들이 특히 Protobuf나 다른 전통적인 포맷에 비해 “빠르다”는 정도의 막연한 인상은 있을지 모르지만, 직렬화 프로토콜에는 속도 말고도 고려할 요소가 더 많고, 무엇을 더 고려해야 하는지 고민하고 있을지도 모릅니다.
이 블로그 글의 목표는 제가 보기에는 이 라이브러리들 사이의 주요 정성적(qualitative) 차이들을 강조해 보여주는 것입니다. 당연히 저는 편향되어 있으며, 읽는 동안 그 점을 감안해야 합니다. 그럼에도 이것이 대안들을 스스로 조사하기 위한 좋은 출발점이 되기를 바랍니다.
아래는 제가 중요하다고 생각하는 고려 사항들입니다. 빠뜨린 게 보이나요? 연락해 주시면 추가하겠습니다. 특히 SBE와 FlatBuffers 작성자들이 제가 놓쳤을 수 있는 장점을 제안해 주시길 초대합니다.
아래에서 각 항목을 더 자세히 다루겠습니다.
참고: 프로토콜이나 프로젝트가 아니라 구현의 속성인 기능들에 대해서는, 별도 언급이 없는 한 C++ 구현을 기준으로 판단합니다.
Feature Protobuf Cap'n Proto SBE FlatBuffers Schema evolution yes yes caveats yes Zero-copy no yes yes yes Random-access reads no yes no yes Safe against malicious input yes yes yes opt-in upfront Reflection / generic algorithms yes yes yes yes Initialization order any any preorder bottom-up Unknown field retention removed
in proto3 yes no no Object-capability RPC system no yes no no Schema language custom custom XML custom Usable as mutable state yes no no no Padding takes space on wire?no optional yes yes Unset fields take space on wire?no yes yes no Pointers take space on wire?no yes no yes C++yes yes (C++11)yes yes Java yes yesyes yes C#yes yesyes yes Go yes yes no yes* Other languages lots!6+ others*no no Authors' preferred use case distributed
computingplatforms / sandboxingfinancial
trading games
스키마 진화 (Schema Evolution)
네 가지 프로토콜 모두 시간이 지나며 스키마에 새 필드를 추가하더라도 하위 호환성이 깨지지 않도록 허용합니다. 새 필드는 오래된 바이너리에서는 무시되고, 새 바이너리는 오래된 데이터를 읽을 때 기본값을 채웁니다.
다만 SBE는(코드를 읽어본 한) 서브오브젝트(group) 내부에 새 가변 폭(variable-width) 필드를 추가하는 것을 허용하지 않는 것으로 보입니다. 읽기 시 모든 가변 폭 필드를 명시적으로 순회하는 것이 애플리케이션의 책임이기 때문입니다. 새로운 중첩 필드를 모르는 오래된 앱이 그 필드를 건너뛰는 처리를 하지 못하면 버퍼 포인터가 동기화에서 벗어납니다. 가변 폭 필드는 최상위 객체에 추가할 수 있는데, 그 경우 메시지 끝에 붙게 되므로 오래된 코드가 그 뒤를 더 순회할 필요가 없습니다.
제로-카피 (Zero-copy)
세 경쟁자 모두의 중심 논지는 데이터가 메모리 내 구조와 와이어 상 구조가 동일해야 하며, 그로써 비용이 큰 인코드/디코드 단계를 피할 수 있어야 한다는 것입니다.
Protobuf는 옛 방식의 사고를 대표합니다.
랜덤 액세스 읽기 (Random-access reads)
메시지 내용을 임의의 순서로 탐색할 수 있나요? 관련하여, (예: 2GB) 크기의 큰 파일을 으로 매핑했을 때—파일 전체가 하나의 거대한 직렬화 메시지라고 가정하면—디스크에서 파일 전체가 페이지 인(page-in) 되지 않도록 하면서 특정 필드 하나만 찾아 읽을 수 있나요?
Protobuf는 어떤 내용도 사용하기 전에 전체 파일을 먼저 파싱해야 하므로 이를 허용하지 않습니다. 스트리밍 Protobuf 파서(대부분의 라이브러리는 제공하지 않지만)가 있다고 해도, 최소한 원하는 지점 앞에 등장하는 모든 데이터를 파싱해야 합니다. Protobuf 문서는 큰 파일을 많은 작은 조각으로 나눈 뒤, 조각들 사이를 탐색(seek)할 수 있는 다른 프레이밍 포맷을 구현하라고 권하지만, 이는 전적으로 애플리케이션에 맡겨져 있습니다.
SBE는 메시지 트리가 프리오더(preorder)로 기록되며 전체 서브트리를 건너뛸 수 있게 해주는 정보가 없기 때문에 랜덤 액세스를 허용하지 않습니다. 하나의 객체 안에 있는 기본(primitive) 필드들은 랜덤 순서로 접근할 수 있지만, 서브오브젝트는 반드시 프리오더로 엄격히 순회해야 합니다. SBE는 순차 메모리 접근이 랜덤 접근보다 빠르므로, 애플리케이션 코드의 순서도 가능한 한 빠르게 강제되도록 이런 제한을 중심으로 설계한 것으로 보입니다. Protobuf와 마찬가지로 SBE는 큰 파일에 대해 다른 프레이밍 포맷을 쓰길 권합니다.
Cap’n Proto는 C의 일반적인 인메모리 데이터 구조처럼 포인터를 사용해 랜덤 액세스를 허용합니다. 이 포인터들은 완전히 네이티브 포인터는 아니며, 메시지를 임의의 메모리 위치에 로드할 수 있도록 절대값이 아니라 상대값입니다.
FlatBuffers는 각 레코드가 모든 필드 위치에 대한 오프셋 테이블을 저장하고, Cap’n Proto처럼 객체 사이에 포인터를 사용함으로써 랜덤 액세스를 허용합니다.
악의적 입력에 대한 안전성 (Safe against malicious input)
Protobuf는 온갖 종류의 악의적 입력에 대해 견고하도록 세심하게 설계되었고, 구글의 세계적 수준 보안 팀에 의해 보안 리뷰를 받았습니다. Protobuf 구현이 안전할 뿐만 아니라, API 자체가 애플리케이션 코드에서 보안 실수를 저지르지 않도록 명시적으로 설계되었습니다. 인터페이스가 클라이언트 앱으로 하여금 안전하지 않은 코드를 작성하기 쉽게 만든다면, 그것은 Protobuf의 보안 결함으로 간주됩니다.
Cap’n Proto는 Protocol Buffers의 보안 관점을 계승했으며 비슷하게 안전하다고 믿고 있습니다. 다만 아직 보안 리뷰를 받지는 않았습니다.
SBE의 C++ 라이브러리는 이 버그가 해결된 이후 경계 검사(bounds checking)를 수행합니다.
업데이트 2014년 7월 12일: FlatBuffers는 이제 메시지에 대해 선택적으로 upfront 검증 패스를 수행하여 모든 포인터가 범위 내인지 확인하는 것을 지원합니다. 검증기는 명시적으로 호출해야 하며, 그렇지 않으면 어떤 경계 검사도 수행되지 않습니다. 검증기는 메시지 전체를 한 번 훑으며, 매우 빠르겠지만 이므로, 매우 큰 파일을 하는 경우 “랜덤 액세스”의 이점을 잃게 됩니다. FlatBuffers는 주로 네트워크 메시지가 아니라 정적이고 신뢰할 수 있는 데이터 파일 포맷으로 설계되었습니다.
리플렉션 / 제네릭 알고리즘 (Reflection / generic algorithms)
업데이트: 처음에는 SBE와 FlatBuffers에도 실제로 리플렉션 API가 있다는 사실을 발견하지 못했습니다. 죄송합니다!
Protobuf는 메시지의 모든 필드를 동적으로 순회하면서 이름 및 기타 메타데이터를 얻고, 특정 인스턴스에서 값을 읽고 수정할 수 있는 “리플렉션” 인터페이스를 제공합니다. Cap’n Proto도 이를 지원하며 “Dynamic API”라고 부릅니다. SBE는 일반적인 SBE 제약(내용을 순서대로만 반복할 수 있음) 하에서 “OTF decoder” API를 제공합니다. FlatBuffers는 idl.h에 Parser API가 있습니다.
리플렉션/동적 API가 있으면 매우 다양한 사용 사례가 열립니다. 리플렉션 기반 코드로 메시지를 JSON 같은 다른 포맷으로 변환할 수 있는데, 이는 상호운용성뿐 아니라 사람이 읽을 수 있어 디버깅에도 유용합니다. 또 다른 흔한 용도는 스크립트 언어용 바인딩을 만드는 것입니다. 예를 들어 Python용 Cap’n Proto 구현은 C++ 동적 API 위에 씌운 래퍼일 뿐입니다. 스키마를 런타임에 파싱하면, 컴파일 타임에 전혀 알 수 없는 타입에 대해서도 이런 작업들을 할 수 있다는 점에 주목하세요.
리플렉션의 단점은 일반적으로(생성된 코드에 비해) 매우 느리고 코드 부풀림(code bloat)을 유발할 수 있다는 것입니다. Cap’n Proto는 리플렉션 API를 사용하지 않는다면 앱에 링크하지 않아도 되도록 설계되어 있지만, 그 이점을 얻으려면 라이브러리를 정적으로 링크해야 합니다.
초기화 순서 (Initialization order)
메시지를 빌드할 때 코드 구조에 따라 데이터를 채워 넣는 순서에 유연성이 있으면 편리할 수 있습니다. 이런 유연성이 없다면, 메시지에 추가할 시간이 올 때까지 데이터를 따로 옆에 저장해두는 추가 장부(bookkeeping)가 필요할 수 있습니다.
Protocol Buffers는 메시지가 힙에서 구성되므로 초기화 순서 측면에서 본질적으로 완전히 유연합니다. 제한을 둘 이유가 없습니다. (다만 C++ Protobuf 라이브러리는 top-down 방식의 빌드를 강하게 권장합니다.)
하지만 모든 제로-카피 시스템은 메시지가 한 번에 써질 수 있도록 연속된 메모리 블록에서 구성되게 하기 위해 어떤 형태로든 아레나(arena) 할당을 사용해야 합니다. 그래서 상황이 더 복잡해집니다.
SBE는 특히 메시지 트리가 프리오더로 기록되기를 요구합니다(읽기와 마찬가지로, 단일 객체의 기본 필드들은 임의 순서로 초기화할 수 있습니다).
FlatBuffers는 어떤 객체의 크기가 그 내용에 의존하므로, 객체를 완성(finalize)하기 전에는 필요한 공간의 양을 알 수 없습니다. 따라서 다음 객체를 빌드하기 전에 하나의 객체를 완전히 끝내야 합니다. 이는 FlatBuffer 메시지가 잎(leaf)에서 시작해 bottom-up으로 빌드되어야 함을 뜻하기도 합니다.
Cap’n Proto는 어떤 순서 제약도 두지 않습니다. 객체의 크기는 할당 시점에 알려지므로 즉시 더 많은 객체를 할당할 수 있습니다. 메시지는 보통 top-down으로 빌드되지만, “orphans” API를 통해 bottom-up 순서도 지원됩니다.
알 수 없는 필드 유지(Unknown field retention)?
메시지를 읽은 다음, 그 메시지의 어떤 서브오브젝트 하나를 새 메시지의 서브오브젝트로 복사하고, 새 메시지를 다시 쓴다고 해봅시다. 복사된 객체는 여러분이 가진 것보다 더 새로운 버전의 스키마로 만들어져서 여러분이 모르는 필드를 포함하고 있을 수 있습니다. 그 필드들이 함께 복사될까요?
이 질문은 프록시나 브로커처럼 메시지를 다른 곳으로 전달하는 역할을 하는 서비스에 극도로 중요합니다. 중간자(middlemen)는 보통 프로토콜 세부사항에 관심이 없는데, 백엔드 프로토콜이 바뀔 때마다 이 중간자를 매번 업데이트해야 한다면 불편할 수 있습니다.
Protobuf는 와이어에서 알 수 없는 필드 태그를 보면 값을 메시지의 UnknownFieldSet에 저장해두며, 나중에 이를 복사하거나 다시 쓸 수 있습니다. (업데이트: Protocol Buffers 3, 즉 “proto3”에서는 이 기능을 제거했다고 합니다. 솔직히 무슨 생각인지 모르겠습니다. 이 기능은 구글 내부 시스템의 많은 곳에서 절대적으로 필수였습니다.)
Cap’n Proto의 와이어 포맷은 객체의 스키마를 몰라도 한 메시지에서 다른 메시지로 대상을 재귀적으로 복사할 수 있을 만큼의 정보만 정확히 담도록 매우 신중하게 설계되었습니다. 이것이 Cap’n Proto 포인터에 구조체인지 리스트인지, 그리고 크기가 얼마인지 나타내는 비트가 들어가는 이유입니다. 겉보기에는 중복처럼 보이는 정보죠.
SBE와 FlatBuffers는 와이어에 이런 타입 정보를 저장하지 않으므로, 스키마 없이 객체를 복사하는 것은 불가능합니다. (다만 송신자가 와이어에 전체 스키마를 함께 보내도록 요구할 의향이 있다면, 리플렉션 기반 코드로 사실상 모든 필드를 “알려진” 것으로 만들어 사용할 수는 있습니다. 하지만 꽤 일이 됩니다.)
오브젝트-케이퍼빌리티 RPC 시스템 (Object-capability RPC system)
Cap’n Proto는 오브젝트-케이퍼빌리티 RPC 시스템을 제공합니다. 이 글은 RPC 기능을 논의하려는 목적은 아니지만, 직렬화 포맷에 중요한 영향이 하나 있습니다. 오브젝트-케이퍼빌리티 RPC 시스템에서는 원격 객체에 대한 참조가 일급 타입(first-class type)이어야 합니다. 즉 구조체 필드의 타입이 “RPC 인터페이스 Foo를 구현하는 원격 객체에 대한 참조”일 수 있어야 합니다.
Protobuf, SBC, FlatBuffers는 이 타입을 지원하지 않습니다. 단순히 문자열 URL을 저장하거나, 참조를 표현하는 커스텀 구조체를 정의하는 것만으로는 충분하지 않습니다. 올바른 케이퍼빌리티 기반 RPC 시스템은 자신이 보내는 어떤 메시지 안에든 포함된 모든 참조를 인지하고 있어야 하기 때문입니다. 이 요구사항에는 여러 이유가 있는데, 가장 명백한 이유는 시스템이 수신자에게 참조를 내보내거나(export) 혹은 수신자가 사용할 수 있도록 권한을 조정해야 할 수 있다는 점입니다.
스키마 언어 (Schema language)
Protobuf, Cap’n Proto, FlatBuffers는 커스텀이고 간결한 스키마 언어를 갖고 있습니다.
SBE는 XML 스키마를 사용하며 장황합니다.
가변(mutable) 상태로 사용 가능 (Usable as mutable state)
Protobuf 생성 클래스들은 애플리케이션의 가변 내부 상태를 저장하는 편리한 방법으로 (남)용되어 온 경우가 많습니다. 시간이 지나며 메시지를 점진적으로 수정한 다음 필요할 때 직렬화하는 데에는 대체로 문제가 없습니다.
이 사용 패턴은 제로-카피 직렬화 포맷에서는 잘 맞지 않습니다. 이런 포맷은 메시지가 연속된 메모리에서 구성되도록 보장하기 위해 아레나 스타일 할당을 사용해야 하기 때문입니다. 아레나 할당의 특성상 전체 아레나를 해제하지 않고서는 어떤 객체도 개별적으로 해제할 수 없습니다. 따라서 객체가 버려지면 메시지 전체가 파괴될 때까지 메모리가 누수된 것처럼 남습니다. 오래 살아남는 메시지를 여러 번 수정하면 그만큼 메모리를 누수하게 됩니다.
패딩이 와이어에서 공간을 차지하나? (Padding takes space on wire?)
프로토콜이 0 값 패딩 바이트를 와이어에 많이 기록하는 경향이 있나요?
이는 제로-카피 프로토콜의 문제입니다. 고정 폭 정수는 상위 비트에 0이 많은 경향이 있고, 정렬(alignment)을 위해 패딩을 삽입해야 하는 경우도 있습니다. 이런 패딩은 메시지 크기를 쉽게 두세 배로 불릴 수 있습니다.
Protocol Buffers는 별도의 인코딩/디코딩 단계를 전제로 정수를 가변 폭(variable width)으로 인코딩하여 패딩을 피합니다.
SBE와 FlatBuffers는 제로-카피를 달성하기 위해 패딩을 그대로 둡니다.
Cap’n Proto는 보통 패딩을 그대로 두지만, 0만 빠르게 줄이는 것을 목표로 하는 “packing”이라는 매우 빠른 압축 알고리즘을 내장 옵션으로 제공합니다. 이 알고리즘은 Protobuf와 비슷한 크기를 얻는 경향이 있으면서도 더 빠릅니다(일반적인 범용 압축보다도 훨씬 빠릅니다). 다만 이 모드에서는 Cap’n Proto는 더 이상 제로-카피가 아닙니다.
Cap’n Proto의 packing 알고리즘은 SBE와 FlatBuffers에도 적절할 것입니다. 마음껏 가져다 쓰세요. :)
설정되지 않은 필드는 와이어에서 공간을 차지하나? (Unset fields take space on wire?)
필드에 값이 명시적으로 할당되지 않았다면, 와이어에서 공간을 차지하나요?
Protobuf는 tag-value 쌍을 인코딩하므로, 설정되지 않은 쌍은 그냥 건너뜁니다.
Cap’n Proto와 SBE는 구조체 시작으로부터 고정 오프셋에 필드를 배치합니다. 구조체는 스키마에 따라 알려진 모든 필드에 충분하도록 항상 크게 할당됩니다. 따라서 사용되지 않는 필드가 공간을 낭비합니다. (하지만 Cap’n Proto의 선택적 packing은 이 공간을 압축해 없애는 경향이 있습니다.)
FlatBuffers는 각 필드의 위치를 나타내는 별도의 오프셋 테이블(vtable)을 사용하며, 0은 필드가 존재하지 않음을 뜻합니다. 그래서 설정되지 않은 필드는 와이어에서 공간을 차지하지 않습니다(다만 vtable에는 공간을 차지합니다). vtable은 오프셋이 모두 동일한 인스턴스들 사이에서 공유될 수 있어서 이 비용이 상쇄될 수 있다고 합니다.
물론 이는 기본 필드와 포인터 값에 대한 이야기이고, 그 포인터가 가리키는 서브오브젝트 자체에 대한 이야기는 아닙니다. 이 모든 포맷은 초기화되지 않은 서브오브젝트는 생략합니다.
포인터가 와이어에서 공간을 차지하나? (Pointers take space on wire?)
비-기본(non-primitive) 필드는 포인터 저장이 필요할까요?
Protobuf는 가변 폭 필드에 대해 tag-length-value를 사용합니다.
Cap’n Proto는 가변 폭 필드에 포인터를 사용하여, 부모 객체의 크기가 어떤 자식의 크기와도 독립적이게 합니다. 이 포인터들은 와이어에서 일정 공간을 차지합니다.
SBE는 가변 폭 필드를 프리오더로 인라인 삽입해야 하므로 포인터가 필요 없습니다.
FlatBuffers도 포인터를 사용합니다. 대부분의 객체가 가변 폭임에도 불구하고 포인터를 쓰는 이유는, vtable이 16비트 오프셋만 저장하여 단일 객체의 크기를 제한하기 때문일 수 있습니다. 다만 FlatBuffers의 “structs”(고정 폭이며 확장 불가)는 인라인으로 저장된다는 점에 유의하세요( Cap’n Proto가 “struct”라고 부르는 것을 FlatBuffer는 “table”이라고 부릅니다).
플랫폼 지원 (Platform Support)
2014년 12월 15일 기준으로 Cap’n Proto는 FlatBuffers와 SBE가 지원하는 언어의 상위집합을 지원하지만, Protocol Buffers에는 여전히 한참 못 미칩니다.
Cap’n Proto C++은 GCC나 Clang을 컴파일러로 사용하는 POSIX 플랫폼에서 잘 지원되지만, Visual C++에 대해서는 제한적으로만 지원합니다. 기본 직렬화 라이브러리는 동작하지만, 리플렉션과 RPC는 아직 동작하지 않습니다. Visual Studio의 C++ 컴파일러가 C++11 지원을 완성하면 지원이 확대될 것입니다.
비교하자면 SBE와 FlatBuffers는 Visual C++에서 동작하는 리플렉션 인터페이스가 있지만, 둘 다 내장 RPC는 없습니다. 리플렉션은 특정 사용 사례에서 중요하지만, 대다수 사용자는 필요로 하지 않을 것입니다.
(이 섹션은 업데이트되었습니다. 처음 작성 당시에는 Cap’n Proto가 MSVC를 전혀 지원하지 않았습니다.)
저는 벤치마크를 제공하지 않습니다. Protobuf를 출시했을 때도, Cap’n Proto를 출시했을 때도 제공하지 않았습니다. (git에서 찾을 수 있는 꽤 좋은 숫자도 있긴 했지만요.) 그리고 이제 와서 시작해야 할 이유도 보지 못하겠습니다.
왜일까요? 벤치마크는 아무것도 말해주지 않기 때문입니다. 각 라이브러리가 선택한 상대적 트레이드오프를 이용해, 어떤 라이브러리든 “이기게” 만드는 벤치마크를 저는 쉽게 만들 수 있습니다. 심지어 다른 것들보다 무한히 느리다고 여겨지는 Protobuf가 이기는 경우도 만들 수 있습니다.
현실은 이 라이브러리들의 상대적 성능이 사용 사례에 깊이 의존한다는 것입니다. 여러분의 프로젝트에서 어느 것이 가장 빠를지 알기 위해서는, 결국 여러분의 프로젝트에서 종단 간(end-to-end)으로 벤치마크해야 합니다. 꾸며낸 벤치마크로는 답을 얻을 수 없습니다.
그렇다고 해도 제 직감으로는, 랜덤 액세스 지원을 포기하기로 한 결정 때문에 평균적인 경우에는 SBE가 Cap’n Proto와 FlatBuffers보다 성능에서 약간 앞설 것 같습니다. Cap’n Proto와 FlatBuffers 사이에서는 말하기 더 어렵습니다. FlatBuffers의 vtable 접근은 접근 비용을 더 비싸게 만들 것처럼 보이지만, 더 단순한 포인터 형식은 추적 비용이 더 쌀 수도 있습니다. 또한 FlatBuffers는 인코딩 시에(예: vtable 중복 제거 같은) 많은 장부(bookkeeping)를 하는 것으로 보이는데, 그것이 얼마나 비싼지는 잘 모르겠습니다.
대부분의 사람들에게는 성능 차이가 작아서, 라이브러리들의 정성적(기능) 차이가 더 중요할 가능성이 큽니다.