같은 데이터를 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/
•
언어별 튜토리얼: https://protobuf.dev/getting-started/