Search

260729_2226_같은 데이터를 Protobuf로 바꿨더니 페이로드가 70% 줄었다 (직렬화)

같은 데이터를 Protobuf로 바꿨더니 페이로드가 70% 줄었다 (직렬화)

핵심 한 줄: 서비스 간 통신에서 JSON → Protobuf로 바꾸니 같은 데이터 크기가 약 70% 줄었다. 이유를 이해하려면 직렬화가 데이터를 어떻게 바꾸는지부터 봐야 한다.

1. 영상에서 말한 것 (요약)

1.
직렬화 = 메모리 객체를 바이트 배열로 바꾸는 것 (네트워크 전송·파일 저장에는 메모리 구조 그대로를 쓸 수 없음)
2.
JSON = 텍스트 기반 → 사람이 읽기 쉬움, 대신 필드명·따옴표·콜론·중괄호가 전부 바이트를 차지
3.
Protobuf = 바이너리 기반 → 필드 이름 대신 번호, 숫자는 바이너리로 직접 저장, 구분자/따옴표 없음
4.
속도 차이도 큼: JSON은 문자열 파싱(따옴표, 이스케이프, 숫자 문자열→정수 변환), Protobuf는 바이트를 그대로 읽으면 끝
5.
전부 Protobuf로 바꾸면? → 디버깅·스키마 관리 때문에 그렇게 단순하지 않음
6.
실무 선택: 성능이 필요한 내부 통신 = Protobuf, 유연성이 필요한 외부 통신 = JSON → “JSON이 느린 건, 사람이 읽을 수 있는 대가”

2. 직렬화(Serialization)란?

프로그램 안에서 User 객체는 힙에 필드들이 흩어져 있는 메모리 구조다.
소켓으로 보내거나 디스크에 쓰려면 연속된 바이트 열이 필요하다.
[메모리 객체] ──직렬화──▶ [바이트 배열] ──네트워크/파일──▶ [바이트 배열] ──역직렬화──▶ [객체]
Plain Text
복사
직렬화 (serialize / marshal / encode): 객체 → 바이트
역직렬화 (deserialize / unmarshal / decode): 바이트 → 객체
포맷(JSON, Protobuf, MessagePack, Avro …)마다
“무엇을 바이트에 넣고, 무엇을 생략할지”가 다르다.
그 차이가 페이로드 크기파싱 CPU로 드러난다.

3. 같은 데이터, 포맷별 크기 — 왜 70%나 줄까?

영상 예시 감각을 코드로 풀어보면 대략 이렇다.

3-1. 예제 데이터 (주문 한 건)

필드
user_id
42
amount
123400 (원)
status
"PAID"
item
"keyboard"

3-2. JSON (텍스트)

{"user_id":42,"amount":123400,"status":"PAID","item":"keyboard"}
JSON
복사
바이트를 세어 보면:
구성
예시
바이트를 쓰는 이유
필드 이름
"user_id", "amount"
키가 매 메시지마다 반복
구분자
{ } : , "
문법용 문자도 전부 바이트
숫자
123400
문자 '1','2','3','4','0','0' → 6바이트
영상에서 강조한 포인트:
숫자 123400을 정수로 저장하면 약 2~3바이트면 되는데,
JSON은 문자열이라 자릿수만큼(여기선 5~6바이트) 쓴다.
거기다 필드 이름·콜론·따옴표·중괄호가 전부 바이트를 차지한다.
대략적인 크기(공백 없는 minified JSON):
{"user_id":42,"amount":123400,"status":"PAID","item":"keyboard"} → 약 64바이트
Plain Text
복사

3-3. Protobuf (바이너리 + 스키마)

먼저 스키마.proto에 정의한다.
필드 이름은 개발자용이고, 와이어에는 필드 번호만 나간다.
syntax = "proto3"; message Order { int32 user_id = 1; // 와이어에는 "user_id"가 아니라 태그 1 int32 amount = 2; // 123400 → varint 바이너리 string status = 3; string item = 4; }
Protobuf
복사
와이어 포맷 개념:
[태그(필드번호+타입)] [값] [태그] [값] ...
Plain Text
복사
"amount" 같은 문자열 키
2(필드 번호) + 바이너리로 인코딩된 123400
따옴표·콜론·중괄호
같은 Order를 Protobuf로 직렬화하면 대략 20바이트 전후까지 줄어드는 경우가 많다.
(필드·값에 따라 다르지만, 영상에서 말한 ~70% 감소와 같은 체급이다.)
JSON ████████████████████████ ~64 B Protobuf ██████ ~20 B (약 70% ↓)
Plain Text
복사

4. JSON이 커지는 이유 — 숫자 하나만 파헤치기

영상 예시: 123400원.

4-1. 정수로 두면

123400을 16비트/가변길이로 넣으면 대략 2바이트면 충분하다.
123400 (10진) = 0x1E208 → 고정 16bit로는 부족하고, 32bit면 4바이트. Protobuf의 varint는 "작은 수는 짧게" 인코딩해서 이 값도 보통 2~3바이트로 들어간다.
Plain Text
복사

4-2. JSON으로 두면

"amount":123400
JSON
복사
"amount" → 8바이트 (따옴표 포함하면 더)
: → 1바이트
123400문자 6개 = 6바이트
(앞뒤 , { } 도 별도)
값 하나만 봐도 “정수 바이너리 vs 십진 문자열” 차이가 난다.
필드가 많아질수록 키 반복이 JSON 쪽을 더 불리하게 만든다.

4-3. 바이트 비교 스케치 (Python)

import json import struct amount = 123400 # JSON: 숫자를 "문자"로 json_part = json.dumps({"amount": amount}, separators=(",", ":")) print(json_part) # {"amount":123400} print(len(json_part.encode("utf-8"))) # 키+구분자+숫자문자 전부 # "순수 정수"에 가까운 바이너리 (개념용) print(struct.pack("<I", amount)) # 4바이트 little-endian print(len(struct.pack("<I", amount))) # 4
Python
복사
Protobuf는 여기에 필드 태그 1바이트 + varint 값 정도만 더한다.
JSON처럼 "amount"를 매번 쓰지 않는다.

5. Protobuf 쪽을 코드로 따라가기

5-1. .proto 정의

// order.proto syntax = "proto3"; package shop; message Order { int32 user_id = 1; int32 amount = 2; string status = 3; string item = 4; }
Protobuf
복사

5-2. 코드 생성 (예: Python)

protoc --python_out=. order.proto # → order_pb2.py 생성
Bash
복사

5-3. 직렬화 / 역직렬화

import order_pb2 order = order_pb2.Order( user_id=42, amount=123400, status="PAID", item="keyboard", ) # 직렬화: 객체 → bytes payload = order.SerializeToString() print(len(payload)) # JSON보다 훨씬 작음 print(payload.hex()) # 사람이 바로 읽기 어려운 바이너리 # 역직렬화: bytes → 객체 parsed = order_pb2.Order() parsed.ParseFromString(payload) print(parsed.user_id, parsed.amount, parsed.status)
Python
복사

5-4. JSON과 나란히 크기 비교

import json import order_pb2 data = { "user_id": 42, "amount": 123400, "status": "PAID", "item": "keyboard", } json_bytes = json.dumps(data, separators=(",", ":")).encode("utf-8") pb = order_pb2.Order(**data) pb_bytes = pb.SerializeToString() print(f"JSON : {len(json_bytes)} bytes") print(f"Protobuf : {len(pb_bytes)} bytes") print(f"감소율 : {(1 - len(pb_bytes)/len(json_bytes))*100:.1f}%")
Python
복사
실제로 돌려보면 데이터 shape에 따라 다르지만,
정수·enum이 많은 메시지일수록 영상처럼 60~70%대 감소가 흔하다.
문자열 비중이 크면 격차는 줄어든다(문자열 본문은 둘 다 비슷하게 넣기 때문).

5-5. Go 예시 (같은 스키마)

// go generate / protoc로 shop.Order 생성 후 order := &shop.Order{ UserId: 42, Amount: 123400, Status: "PAID", Item: "keyboard", } b, _ := proto.Marshal(order) fmt.Println(len(b)) // 바이너리 페이로드 크기
Go
복사

5-6. HTTP로 보낼 때 (gRPC 없이도 가능)

영상/실무 맥락의 “서비스 간 통신”은 gRPC만이 아니다.
일반 HTTP에서도 content-type만 바꾸면 된다.
# 서버 응답 headers = {"Content-Type": "application/protobuf"} body = order.SerializeToString() return Response(body, headers=headers)
Python
복사
# 클라이언트 raw = response.content order = order_pb2.Order() order.ParseFromString(raw)
Python
복사
내부 마이크로서비스끼리라면 이 패턴만으로도 JSON 대비 대역폭·CPU를 크게 줄일 수 있다.

6. 속도 차이 — “파싱 비용”

영상 설명 그대로:
JSON
Protobuf
디코딩
문자열 스캔, 따옴표 찾기, 이스케이프 처리, "123400" → 정수 변환
태그 읽고 타입에 맞게 바이트를 그대로 해석
CPU
상대적으로 무거움
상대적으로 가벼움
GC/할당
중간 문자열·토큰이 많음
스키마 기반이라 할당이 적은 편
개념 코드로 보면:
# JSON 경로 (개념) # '{"amount":123400}' # → 토큰화 → "amount" 키 매칭 → "123400" 문자열 → int(123400) # Protobuf 경로 (개념) # bytes: [tag=2][varint 123400] # → 태그 2 = amount(int32) → varint 디코드 → 정수 123400
Python
복사
초당 수만 건의 내부 RPC에서는 이 차이가 p99 지연·CPU 비용으로 누적된다.

7. 그럼 전부 Protobuf로? — 영상에서 말한 함정

7-1. 디버깅

JSON: 로그에 찍으면 바로 읽힌다
Protobuf: 바이너리protoc --decode, grpcurl, 전용 뷰어 없이는 내용을 확인하기 어렵다
# 스키마가 있을 때 사람이 읽게 만드는 예 protoc --decode=shop.Order order.proto < order.bin
Bash
복사
운영·장애 대응에서는 “눈으로 볼 수 있는가”가 생각보다 큰 비용이다.

7-2. 스키마(필드 번호) 관리

Protobuf는 필드 번호로 데이터를 식별한다.
message Order { int32 user_id = 1; int32 amount = 2; // string coupon = 3; // 예전에 쓰다 삭제 string status = 4; }
Protobuf
복사
위험 패턴:
한번 쓴 번호를 삭제 후 재사용하면,
이전 버전 클라이언트가 데이터를 잘못 읽는다.
예:
1.
v1: 3 = coupon (문자열 "SAVE10")
2.
필드 삭제 후 v2에서 3 = discount_rate (정수 10)로 재사용
3.
아직 v1인 서비스가 오면 "SAVE10"을 정수로 해석하거나, 새 정수를 문자열로 깨뜨림
JSON은 필드 이름이 데이터에 포함되어 있어서 이런 “번호 재사용 사고”가 구조적으로 덜하다.
(대신 오타·스키마 불일치 같은 다른 문제는 남는다.)
안전한 진화 규칙 (실무 상식):
해도 됨
하면 안 됨
새 필드에 새 번호 추가
기존 필드 번호 변경
optional 필드 추가 (구버전은 무시)
삭제된 번호 재사용
reserved 3; 로 번호 봉인
의미 다른 타입으로 같은 번호 재정의
message Order { reserved 3; // 예전에 coupon 이었던 번호 — 영원히 금지 reserved "coupon"; int32 user_id = 1; int32 amount = 2; string status = 4; int32 discount_rate = 5; // 새 필드는 새 번호 }
Protobuf
복사

8. 실무에서의 선택 (영상 결론)

성능이 필요한 내부 통신 → Protobuf
유연성이 필요한 외부 통신 → JSON
JSON이 느린 건, 사람이 읽을 수 있는 대가다.
상황
추천
이유
MSA 내부 gRPC / 고빈도 RPC
Protobuf
크기·파싱 비용 절감
모바일 서버 (대역폭 민감)
Protobuf (또는 압축+스키마)
페이로드·배터리
공개 REST API, 브라우저, 파트너 연동
JSON
디버깅·호환·도구 생태계
설정 파일, 사람이 자주 고치는 문서
JSON / YAML
가독성
이벤트 스트림 (Kafka 등) 대량 레코드
Protobuf / Avro
레코드당 바이트가 곧 비용
한 시스템 안에서도 흔히 이중화한다:
[브라우저] --JSON/REST--> [API Gateway] --Protobuf/gRPC--> [Order] --Protobuf--> [Payment]
Plain Text
복사
외부는 JSON으로 열고, 내부 hot path만 Protobuf로 조이면
영상에서 말한 70% 절감 효과를 비용이 큰 구간에만 가져갈 수 있다.

9. Wire format 한 단계 더 (왜 “번호 + 바이너리”가 작은가)

Protobuf 메시지는 대략 이런 바이트 나열이다.
태그 = (field_number << 3) | wire_type
Plain Text
복사
예: amount = 2, 타입이 varint(wire_type=0)이면
태그 바이트 = (2 << 3) | 0 = 0x10 값 = 123400 의 varint 인코딩
Plain Text
복사
필드 이름 "amount"(6글자+) 대신 1바이트 태그만 쓰는 셈이다.
필드 번호 1~15는 태그가 1바이트로 끝나므로,
자주 쓰는 필드를 앞번호에 두는 것도 실무 팁이다.
message Order { // hot path 필드를 1~15에 int32 user_id = 1; int32 amount = 2; string status = 3; // 덜 쓰는 큰 필드는 뒤로 string debug_note = 16; }
Protobuf
복사

10. 정리 체크리스트

직렬화 = 메모리 객체 → 바이트 배열 (전송/저장용)
JSON이 큰 이유: 키 문자열 반복 + 숫자를 문자로 + 문법 문자
Protobuf가 작은 이유: 필드 번호 + 바이너리/varint + 구분자 없음
속도: JSON은 텍스트 파싱, Protobuf는 바이트 직접 해석
단점: 디버깅 난이도, 필드 번호 스키마 규율
실무: 내부=Protobuf / 외부=JSON 이 영상(DEV CORE)의 결론

참고

Protocol Buffers 공식: https://protobuf.dev/