패킷 패킹 & 직렬화: Protocol Buffers로 네트워크 대역폭 줄이기
게임 네트워크 패킷에서 실제로 바이트를 줄이는 Protocol Buffers 설계 원칙을 살펴본다. 필드 번호, varint, 반복 필드, 델타 전송과 호환성까지 실전 관점으로 정리한다.

먼저 측정할 대상: 페이로드와 패킷은 다르다
Protocol Buffers는 메시지의 직렬화된 페이로드를 작게 만드는 도구다. 실제 네트워크 전송량에는 UDP/IP 또는 TCP/IP 헤더, 신뢰성 계층의 헤더, 암호화·압축 오버헤드도 포함된다. 따라서 Protobuf를 도입한 뒤에는 메시지 바이트 수와 실제 송신 바이트 수를 분리해 측정해야 한다.
게임에서는 작은 메시지를 자주 보내는 경우가 많다. 이때 JSON처럼 필드 이름을 매번 전송하는 형식보다 숫자 태그와 이진 값으로 표현하는 Protobuf가 유리하다. 다만 이미 10바이트 안팎인 메시지라면 헤더 비중이 더 클 수 있으므로 단순히 직렬화 형식만 바꾸는 것으로 충분하지 않을 수 있다.

Protobuf가 바이트를 쓰는 방식
각 필드는 대체로 태그 + 값으로 기록된다. 태그에는 필드 번호와 wire type이 들어가며 정수는 값의 크기에 따라 가변 길이로 저장될 수 있다.
syntax = "proto3";
message PlayerState {
uint32 entity_id = 1;
sint32 pos_x_cm = 2;
sint32 pos_y_cm = 3;
sint32 hp = 4;
}
위 메시지에서 entity_id = 1처럼 작은 필드 번호는 태그 표현이 짧다. 값도 작을수록 varint 인코딩에서 적은 바이트를 사용한다. 예를 들어 양수 127은 1바이트로 표현되지만 128부터는 2바이트가 필요하다.
반면 int32에 음수를 넣으면 varint 특성상 매우 길어질 수 있다. 좌표 차이처럼 음수가 가능한 작은 정수에는 sint32를 선택한다. sint32는 ZigZag 인코딩으로 음수와 양수 모두 작은 값일 때 짧게 표현한다.
message InputCommand {
uint32 sequence = 1;
sint32 move_x = 2; // -1, 0, 1 같은 값에 적합
sint32 move_y = 3;
uint32 buttons = 4; // 비트 플래그
}
float와 fixed32는 항상 4바이트다. 값의 범위가 제한된 위치나 각도라면 부동소수점 대신 고정 단위 정수로 양자화하는 편이 더 작고 결정적인 표현이 될 수 있다. 예를 들어 위치를 센티미터 단위 정수로 보내면 float 3개 대신 작은 sint32 3개를 사용할 여지가 생긴다. 단, 필요한 거리 범위와 정밀도를 먼저 정해야 한다.
스키마 설계에서 바로 줄일 수 있는 바이트
자주 전송하는 필드에 낮은 번호를 배정한다
필드 번호 1~15는 태그가 1바이트로 인코딩된다. 16 이상부터는 태그가 길어질 수 있다. 이동 입력, 엔티티 ID, 좌표처럼 빈도가 높은 필드를 이 구간에 배치하는 것이 좋다.
필드 번호는 한번 배포하면 사실상 프로토콜 계약이 된다. 삭제한 번호를 다른 의미로 재사용하면 구버전 클라이언트가 데이터를 잘못 해석할 수 있다. 사용을 중단한 번호와 이름은 reserved로 막아 둔다.
message PlayerState {
reserved 5, 6;
reserved "legacy_name";
uint32 entity_id = 1;
sint32 pos_x_cm = 2;
sint32 pos_y_cm = 3;
sint32 pos_z_cm = 4;
uint32 state_flags = 7;
}
불리언을 여러 개 보내지 말고 비트 플래그를 검토한다
독립적인 bool 필드는 각각 태그와 값을 가진다. 동시에 자주 전송되는 상태라면 비트 플래그 하나로 합치는 편이 낫다.
message CharacterFlags {
uint32 flags = 1;
}
C++에서는 비트 연산으로 읽고 쓸 수 있다.
constexpr uint32_t kJumping = 1u << 0;
constexpr uint32_t kGrounded = 1u << 1;
constexpr uint32_t kSprinting = 1u << 2;
uint32_t flags = 0;
flags |= kJumping;
if ((flags & kJumping) != 0) {
// 점프 상태
}
단, 모든 불리언을 무조건 합칠 필요는 없다. 갱신 주기와 수신자가 다른 값까지 한 메시지에 묶으면 불필요한 전송이 늘어난다.
반복 숫자 필드는 packed 인코딩을 확인한다
반복되는 숫자 필드는 원소마다 태그를 반복하지 않고 값들을 연속 저장하는 packed 인코딩을 사용할 수 있다. proto3의 반복 기본 숫자형 필드는 기본적으로 packed다. proto2를 유지하는 프로젝트라면 명시적으로 설정해야 한다.
// proto2 예시
repeated uint32 changed_entity_ids = 1 [packed = true];
메시지 목록 자체를 반복하는 경우에는 각 원소가 길이 정보와 내부 필드를 가져야 하므로 packed가 적용되지 않는다. 엔티티마다 서로 다른 필드가 필요한 상태 동기화에는 일반적인 반복 메시지가 더 자연스럽다.
직렬화 전에 바꿔야 할 것: 전체 상태 대신 변화량
대역폭 절감의 핵심은 같은 상태를 다시 보내지 않는 데 있다. Protobuf는 표현을 효율화하지만 전송 정책까지 대신 결정하지는 않는다. 일반적으로 서버는 클라이언트가 마지막으로 확인한 상태를 기준으로 변경된 필드만 보낸다.
flowchart LR
A[게임 상태 변경] --> B{수신자가 아는 상태와 다른가?}
B -- 아니오 --> C[전송하지 않음]
B -- 예 --> D[변경 필드만 메시지에 기록]
D --> E[Protobuf 직렬화]
E --> F[채널별 전송]
예를 들어 60Hz 시뮬레이션을 한다고 해서 모든 플레이어의 전체 상태를 60Hz로 전송할 필요는 없다. 조작 입력은 짧고 자주 보내되 원격 객체의 상태는 관심 영역과 거리, 중요도에 따라 낮은 빈도로 갱신할 수 있다. 위치는 마지막 기준점과의 차이로 보내고 일정 범위를 벗어났을 때만 절대 좌표를 보내는 방식도 흔하다.

변화량 메시지는 기준 상태가 어긋났을 때 복구할 경로가 필요하다. 신뢰성 있는 기준점 메시지, 주기적인 전체 스냅샷, 시퀀스 번호와 확인 응답을 함께 설계해야 한다. 특히 UDP 기반의 비신뢰 채널에서는 델타 패킷 하나가 유실될 수 있다는 점을 전제로 해야 한다.
Unity와 C++에서의 직렬화 경계
Unity에서는 C#용 생성 코드와 Google.Protobuf 런타임을 사용하거나 프로젝트 요구에 맞는 호환 라이브러리를 선택할 수 있다. 서버 C++에서는 protoc가 생성한 클래스로 같은 .proto 스키마를 공유한다. 중요한 점은 게임 로직의 객체를 그대로 직렬화하지 않고 전송용 DTO를 명시적으로 만드는 것이다.
var message = new InputCommand {
Sequence = sequence,
MoveX = moveX,
MoveY = moveY,
Buttons = buttons,
};
int size = message.CalculateSize();
byte[] buffer = new byte[size];
message.WriteTo(buffer);
매 프레임 새 배열을 만들면 GC 비용이 눈에 띌 수 있다. 실제 전송 경로에서는 라이브러리가 제공하는 버퍼 기반 API, 버퍼 풀 또는 전송 계층의 재사용 버퍼를 검토한다. 다만 재사용 버퍼는 비동기 송신이 끝나기 전에 덮어쓰지 않도록 소유권을 분명히 해야 한다.
C++에서도 SerializeToArray 호출 전 ByteSizeLong()으로 필요한 크기를 확인할 수 있다. 하지만 매번 할당하는 구현부터 최적화하기보다 먼저 메시지 크기와 할당 빈도를 프로파일링하는 편이 안전하다.
압축은 마지막 단계에 둔다
작은 Protobuf 메시지는 이미 구조적 중복이 적고 압축 헤더와 CPU 비용 때문에 오히려 손해가 날 수 있다. 여러 메시지를 묶은 큰 스냅샷이나 반복 데이터가 많은 콘텐츠 전송에는 압축이 도움이 될 수 있다.
압축 적용 여부는 평균 크기만으로 결정하지 않는다. P50, P95, P99 페이로드 크기와 CPU 시간, 지연 시간, 패킷 유실 시 재전송 비용까지 함께 본다. 암호화를 사용한다면 일반적으로 압축을 먼저 수행하고 암호화를 뒤에 적용해야 압축 효과가 유지된다.
배포 전 점검 목록
- 빈도가 높은 필드가 1~15번 범위에 있는지 확인한다.
- 음수가 가능한 작은 정수에
int32대신sint32가 필요한지 점검한다. - 불리언과 작은 상태값을 전송 빈도 기준으로 묶을지 검토한다.
- proto2의 반복 숫자 필드에는 packed 설정이 필요한지 확인한다.
- 삭제한 필드 번호와 이름을 예약한다.
- 전체 상태, 델타 상태, 복구용 기준점의 역할을 구분한다.
- 실제 플레이 상황에서 메시지별 바이트 수와 초당 송신량을 기록한다.
Protobuf는 네트워크 최적화의 출발점이지 종착점은 아니다. 작은 필드 번호와 올바른 타입 선택으로 낭비를 줄인 뒤 전송 빈도와 관심 영역, 델타 정책을 함께 다듬어야 대역폭이 의미 있게 감소한다.



