List
지난 1편에서는 데이터 시스템이 커질수록 직렬화 비용이 필연적인 병목이 된다는 점을 이야기했습니다. 초당 수백만 건의 이벤트를 처리해야 하는 환경에서, 범용적인 JSON은 '가독성'을 얻는 대신 CPU와 네트워크 비용을 비싸게 치르게 됩니다.
이번 글에서는 구글이 이 문제를 해결하기 위해 내놓은 Protobuf를 파헤쳐 봅니다. 사람이 읽는 건 포기했지만, 기계가 미친 듯이 빨리 읽을 수 있게 설계된 Wire Format을 바이트 단위로 뜯어보고, 왜 이 방식이 행(Row) 기반 데이터 처리의 표준이 되었는지 정리합니다.
1. 스키마 우선(Schema-First) 설계와 필드 번호
Protobuf는 데이터를 보내기 전에 스키마(.proto)를 미리 정의해야 합니다. 유연성을 희생하는 것처럼 보이지만, 성능 최적화를 가능하게 만드는 전제 조건이기도 합니다.
1-1. 필드 이름 대신 번호(Tag)를 보냅니다
JSON은 데이터를 보낼 때마다 친절하게 필드 이름(Key)을 같이 보냅니다.
{
"user_id": 1234,
"event": "click"
}
JSON
복사
반면 Protobuf는 .proto에 정의된 필드 번호(Tag)만 전송합니다.
message UserEvent {
int32 user_id = 1; // "user_id"라는 문자열 대신 숫자 1 사용
string event = 2; // "event"라는 문자열 대신 숫자 2 사용
}
Protobuf
복사
•
컴팩트함: user_id 같은 문자열을 매번 보내지 않고, 1, 2 같은 숫자(Tag)만 전송합니다. 이벤트 수가 많을수록, 필드명이 길수록 절약되는 용량은 기하급수적으로 늘어납니다.
•
스키마 진화(Schema Evolution): 필드 이름이 바뀌어도 번호(Tag)만 유지되면 호환성이 깨지지 않습니다. 서비스 간 배포 주기가 달라도 데이터 파이프라인이 안전하게 유지되는 완충재 역할을 합니다.
2. Wire Format: 인코딩의 기술
본격적인 바이트 분석에 앞서, Wire Format이 무엇인지 간단히 짚고 가겠습니다.
우리가 코드를 짤 때 데이터는 메모리 위에 객체(Object)나 구조체(Struct) 형태로 존재합니다. 하지만 이 데이터가 네트워크 선(Wire)을 타고 다른 컴퓨터로 이동하려면, 0과 1로 이루어진 바이트 나열로 변환되어야 합니다.
•
Wire Format: 데이터를 메모리에 펼쳐놓은 형태가 아니라, 전송을 위해 바이트 단위로 촘촘하게 포장해 놓은 형태를 말합니다.
Protobuf가 빠르고 가벼운 이유는 Wire Format이 기계가 읽기에 가장 효율적으로 설계되어 있기 때문입니다. Protobuf는 메시지를 Tag + Value의 연속된 쌍으로 인코딩합니다. 실제 바이트를 예시로 확인해보겠습니다.
예를 들어,int32 a = 1; 필드에 값 150을 넣으면, Protobuf는 이를 단 3바이트로 직렬화합니다.
직렬화 결과: 08 96 01
이 3바이트가 어떻게 만들어졌는지 단계별로 확인해보겠습니다.
2.1 Tag 생성 원리
Protobuf는 필드 번호와 타입 정보(Wire Type)를 한 덩어리(Tag)로 합쳐서 저장합니다. 공식은 다음과 같습니다.
첫 번째 바이트 08 (2진수 0000 1000)을 분해해 보면
•
하위 3비트 (000): Wire Type 0 (Varint = Variable-length Integer)
•
나머지 비트 (0000 1): Field Number 1
지금부터 1번 필드의 값이 나오며, 인코딩 타입은 Varint라는 것을 확인할 수 있습니다. 이렇게 Tag 하나에 "어디에 넣을 데이터인지"와 "어떻게 읽어야 하는지"를 동시에 담습니다.
2.2 가변 길이 정수 (Varint)
나머지 96 01은 값 150을 인코딩한 결과입니다. 일반적인 int32는 숫자가 작아도 무조건 4바이트를 쓰지만, Protobuf의 Varint는 값이 작을수록 적은 바이트를 사용합니다.
핵심 원리는 1바이트(8비트) 중 맨 앞 1비트를 '신호등'으로 쓰는 것입니다.
•
MSB (Most Significant Bit, 맨 앞 1비트): 뒤에 데이터가 더 남았는지 알려주는 플래그입니다.
◦
1: "아직 끝 아니야! 다음 바이트도 이어져 있어." (Continuation)
◦
0: "내가 마지막이야. 여기서 끊어." (Termination)
•
나머지 7비트: 실제 데이터를 담습니다.
숫자 150은 어떻게 96 01이 될까요? 150을 2진수로 바꾸면 10010110입니다. Protobuf는 이를 7비트씩 쪼개서 리틀 엔디안(Little Endian)으로 저장합니다.
•
데이터 쪼개기: 뒤에서부터 7비트(0010110)를 잘라냅니다. 남은 건 1입니다.
•
첫 번째 바이트 생성: 잘라낸 0010110 앞에, 뒤에 남은 게 있다는 신호(1)를 붙입니다.
◦
1 + 0010110 = 10010110 → 0x96
•
두 번째 바이트 생성: 남은 1 앞에, 이게 마지막이라는 신호(0)를 붙입니다.
◦
0 + 0000001 = 00000001 → 0x01
•
정리
◦
10010110 → 1 / 0010110 → 0000001 / 0010110 → 0 + 0000001 / 1 + 0010110
결과적으로 4바이트가 필요한 150이 단 2바이트(96 01)로 압축됩니다. ID, 카운트, 상태 값 등 작은 정수가 많은 환경에서는 이 방식이 엄청난 용량 절감을 가져옵니다.
2.3 음수 처리와 ZigZag 인코딩
Varint는 음수를 2의 보수로 표현할 때 비효율적(무조건 10바이트 사용 등)인 문제가 있습니다. 이를 해결하기 위해 sint32, sint64 타입은 ZigZag 인코딩을 사용합니다.
•
-1 → 1
•
1 → 2
•
-2→ 3
•
2 → 4 ...
이처럼 음수와 양수를 번갈아 가며 양의 정수로 매핑합니다. 결과적으로 절대값이 작은 음수도 적은 바이트로 저장할 수 있게 됩니다.
3. Row 기반 직렬화 관점에서 Protobuf가 강한 이유
Protobuf의 성격을 한 문장으로 정리하면 이렇습니다.
"레코드 1건을 작고 빠르게 직렬화/전송/파싱하는 데 최적화된 포맷"
행(Row) 기반 데이터는 보통 이런 특성을 가집니다.
•
한 레코드([id, name, age])가 하나의 완결된 덩어리입니다.
•
이벤트 하나, 변경 로그 하나가 독립적으로 오고 갑니다.
이런 환경(Kafka, gRPC)에서는 다음 요구사항이 핵심입니다.
1.
빠른 퍼블리시/컨슘: 개별 이벤트를 지연 없이 처리해야 함
2.
네트워크 효율: 불필요한 메타데이터(필드명)를 줄여야 함
3.
CPU 효율: 텍스트 파싱 비용을 최소화해야 함
Protobuf는 이 요구사항에 딱 맞아 떨어집니다. 그래서 트랜잭션 로그, CDC, 이벤트 스트림처럼 개별 레코드의 이동이 중요한 곳에서 표준처럼 쓰입니다.
4. JSON vs Protobuf 한눈에 비교
가장 많이 비교되는 두 포맷의 차이는 명확합니다.
비교 항목 | Protobuf (바이너리) | JSON (텍스트) |
데이터 크기 | 작음 (필드명 제거, Varint 압축) | 큼 (필드명 반복, 텍스트 형식) |
파싱 속도 | 빠름 (비트 연산 중심) | 느림 (문자열 스캔 및 파싱) |
타입 안정성 | 강함 (컴파일 타임 검증) | 약함 (런타임 에러 발생 가능) |
가독성 | 낮음 (사람이 해독 불가) | 높음 (눈으로 바로 확인 가능) |
5. Protobuf가 적합한 사용처
Protobuf의 Tag-Value 구조는 레코드를 하나씩 순차적으로 처리하는 흐름에 최적화되어 있습니다.
•
gRPC (Service-to-Service): 마이크로서비스 간 통신에서 페이로드 크기와 파싱 속도가 생명일 때
•
Streaming Pipeline: Kafka, Pub/Sub처럼 엄청난 양의 로그를 실시간으로 수집할 때
•
CDC (Change Data Capture): DB의 Row 단위 변경 사항을 캡처해서 타 시스템으로 전파할 때
마무리
Protobuf는 단순히 "JSON을 조금 더 압축한 것"이 아닙니다. 필드 이름을 번호(Tag)로 치환하고, 값의 크기에 따라 바이트를 유연하게 줄이는(Varint) 기술을 통해, 전송과 파싱 비용을 극도로 줄인 스키마 기반 포맷입니다. 이 조합 덕분에 Protobuf는 이벤트 하나하나가 독립적으로 움직이는 Row 기반 처리 환경에서 사실상 표준이 되었습니다.
하지만 이 강력한 장점은, 대용량 데이터를 '분석'해야 하는 순간 치명적인 단점이 됩니다. 수억 건의 데이터에서 amount 필드의 합계만 구하고 싶다고 가정해 봅시다. 행(Row) 기반인 Protobuf는 구조상 amount 하나를 꺼내기 위해 앞뒤에 붙은 user_id, timestamp 같은 불필요한 필드까지 모두 파싱하고 메모리에 올려야 합니다. 전송할 땐 효율적이었던 '행 단위 포장'이, 분석할 땐 CPU와 메모리를 다시 낭비하게 됩니다.
네트워크 레벨에서는 Protobuf가 효율적이었는데 그렇다면, 메모리 위에서 데이터를 분석할 때는 어떤 구조가 필요할까요?
다음 글에서는 이 질문의 해답인 Apache Arrow를 다룹니다. 왜 분석 워크로드에서는 열(Column) 기반 구조가 CPU 캐시와 SIMD 활용에 유리한지, 그리고 Pandas와 Spark가 직렬화/역직렬화 비용 없이 데이터를 주고받는 Zero-Copy의 마법이 어떻게 가능한지, 그 원리를 확인해보겠습니다.