gRPC와 Protocol Buffers로 실시간 게임 네트워크 패킷 통신 구현하기
gRPC와 Protocol Buffers로 멀티플레이 게임의 패킷 직렬화·양방향 스트리밍을 구현한다. proto3 설계, 서버 스트림 처리, 순서 보장과 재접속, 성능 측정 기준까지 실전 관점으로 정리한다.

실시간 게임에서 gRPC는 HTTP/2 위에서 Protocol Buffers 바이너리 메시지를 교환하므로 JSON 기반 API보다 패킷 크기와 직렬화 비용을 줄이기 쉽다. 다만 gRPC는 신뢰성·순서 보장이 필요한 상태 동기화에 적합하며 초저지연 입력 전송까지 무조건 대체하는 기술은 아니다. 이 글에서는 .NET 8, grpc-dotnet, proto3를 기준으로 양방향 스트리밍 패킷 채널을 최소 구성으로 구현한다.
gRPC와 Protocol Buffers를 게임 패킷에 사용하는 이유는 무엇인가?
Protocol Buffers는 메시지 구조를 .proto 파일에 정의하고 이를 언어별 코드로 생성한다. 필드 이름이 아닌 숫자 태그로 직렬화하기 때문에 JSON보다 전송 크기가 작고 C#·Go·C++ 서버와 Unity 클라이언트 사이에서 같은 계약을 공유하기 좋다.
| 항목 | JSON REST | gRPC + Protocol Buffers |
|---|---|---|
| 전송 형식 | 텍스트 | 바이너리 |
| 연결 방식 | 요청/응답 중심 | 단항 호출·서버 스트림·양방향 스트림 |
| 스키마 검증 | 런타임 관례에 의존 | .proto 계약과 코드 생성 |
| 순서 보장 | 구현에 따라 다름 | HTTP/2 스트림 안에서 메시지 순서 보장 |
| 적합한 게임 데이터 | 로그인, 상점, 설정 | 로비, 채팅, 매치 이벤트, 상태 동기화 |
HTTP/2는 하나의 TCP 연결에 여러 스트림을 다중화한다. 따라서 로그인 RPC와 게임 상태 스트림을 분리해도 연결을 여러 개 열 필요가 없다. 반면 TCP의 헤드 오브 라인 블로킹은 남아 있으므로 프레임 단위 조작 입력처럼 유실 허용이 가능한 매우 빈번한 데이터에는 UDP 기반 전송도 함께 검토해야 한다.

Protocol Buffers 패킷 스키마는 어떻게 설계할까?
먼저 자주 바뀌는 게임 규칙보다 전송 계약을 안정적으로 유지한다. oneof로 패킷 종류를 명시하고 클라이언트가 보낸 순번 sequence과 서버 틱 server_tick을 분리한다. 이미 배포한 필드 번호는 재사용하지 않는다.
syntax = "proto3";
option csharp_namespace = "Game.Network";
package game.network;
service RealtimeGame {
rpc Connect(stream ClientPacket) returns (stream ServerPacket);
}
message ClientPacket {
uint64 sequence = 1;
oneof payload {
JoinRequest join = 10;
PlayerInput input = 11;
}
}
message ServerPacket {
uint64 server_tick = 1;
oneof payload {
JoinAccepted joined = 10;
WorldSnapshot snapshot = 11;
ErrorMessage error = 12;
}
}
message JoinRequest {
string access_token = 1;
}
message PlayerInput {
sint32 move_x = 1;
sint32 move_y = 2;
uint32 client_tick = 3;
}
message JoinAccepted {
uint64 player_id = 1;
}
message WorldSnapshot {
repeated EntityState entities = 1;
}
message EntityState {
uint64 entity_id = 1;
sint32 x_mm = 2;
sint32 y_mm = 3;
}
message ErrorMessage {
string code = 1;
string message = 2;
}
좌표를 float 대신 밀리미터 단위 정수인 x_mm, y_mm로 보내면 플랫폼별 부동소수점 오차를 줄이고 메시지 의미도 명확해진다. sint32는 ZigZag 인코딩을 사용하므로 0 근처의 음수 좌표나 이동 벡터에 유리하다.
필드 번호와 호환성 규칙
- 필드 번호
1부터15는 작은 태그 인코딩을 사용하므로 자주 전송하는 필드에 우선 배정한다. - 삭제한 번호는
reserved로 막아 이후 메시지에서 재사용하지 않는다. - 새 필드는 optional한 의미로 추가하고 오래된 클라이언트가 모르는 필드는 무시될 수 있음을 전제로 서버 기본값을 둔다.
message PlayerInput {
reserved 4, 5;
reserved "legacy_direction";
sint32 move_x = 1;
sint32 move_y = 2;
uint32 client_tick = 3;
}
gRPC 양방향 스트리밍 서버를 어떻게 구현할까?
양방향 스트리밍은 하나의 RPC에서 클라이언트 패킷을 계속 읽고 서버 패킷을 계속 쓴다. 읽기 루프에서 게임 상태를 직접 오래 처리하면 다음 패킷을 받지 못하므로 수신 검증과 게임 틱 처리를 분리하는 편이 안전하다.
using System.Threading.Tasks;
using Grpc.Core;
using Game.Network;
public sealed class RealtimeGameService : RealtimeGame.RealtimeGameBase
{
public override async Task Connect(
IAsyncStreamReader<ClientPacket> requestStream,
IServerStreamWriter<ServerPacket> responseStream,
ServerCallContext context)
{
ulong lastSequence = 0;
ulong playerId = 0;
await foreach (var packet in requestStream.ReadAllAsync(context.CancellationToken))
{
if (packet.Sequence <= lastSequence)
continue;
lastSequence = packet.Sequence;
switch (packet.PayloadCase)
{
case ClientPacket.PayloadOneofCase.Join:
playerId = ValidateTokenAndCreatePlayer(packet.Join.AccessToken);
await responseStream.WriteAsync(new ServerPacket
{
Joined = new JoinAccepted { PlayerId = playerId }
});
break;
case ClientPacket.PayloadOneofCase.Input when playerId != 0:
EnqueueInput(playerId, packet.Input);
break;
}
}
}
private static ulong ValidateTokenAndCreatePlayer(string token) => 1;
private static void EnqueueInput(ulong playerId, PlayerInput input) { }
}
실제 프로덕션에서는 ValidateTokenAndCreatePlayer에서 인증 토큰의 서명·만료·대상을 검증해야 한다. 토큰 문자열만 존재한다는 사실을 인증으로 취급하면 안 된다. 또한 같은 responseStream에는 하나의 송신 작업만 쓰도록 보장해 메시지 쓰기 경쟁을 막는다.
flowchart LR
C[클라이언트 입력] --> V[sequence·인증 검증]
V --> Q[플레이어 입력 큐]
Q --> T[고정 게임 틱]
T --> S[월드 스냅샷 생성]
S --> G[gRPC 서버 스트림]
G --> C
게임 틱과 월드 스냅샷 전송 주기는 어떻게 정할까?
서버 시뮬레이션 틱과 스냅샷 전송 주기는 분리한다. 예를 들어 서버를 60Hz로 시뮬레이션하고 20Hz로 스냅샷을 전송하면 각 클라이언트는 매초 최대 20개의 권위 상태를 수신한다. 입력은 도착 즉시 큐에 넣고 다음 시뮬레이션 틱에서 처리한다.
20Hz 스냅샷의 간격은 50ms다. 대략적인 송신 대역폭은 다음처럼 계산할 수 있다.
예를 들어 100명에게 평균 400바이트 스냅샷을 20Hz로 전송하면 초당 약 800,000바이트다. 이 값에는 HTTP/2·TLS·gRPC 프레이밍 오버헤드가 포함되지 않으므로 패킷 캡처와 실제 부하 테스트로 여유를 확인해야 한다.
전체 스냅샷보다 델타 스냅샷이 항상 좋은가?
아니다. 초기 구현은 전체 스냅샷이 더 단순하고 복구가 쉽다. 프로파일링에서 대역폭이 병목으로 확인된 뒤에만, 클라이언트별 마지막 확인 틱을 기준으로 변경된 엔티티만 보내는 델타 스냅샷을 추가한다. 델타에는 기준 틱을 반드시 포함해 누락이나 재접속 뒤의 상태 불일치를 감지해야 한다.
실시간 gRPC 패킷 통신에서 끊김과 중복은 왜 발생하는가?
모바일 네트워크 전환, 앱 백그라운드 전환, 프록시 타임아웃으로 스트림은 언제든 종료될 수 있다. TCP가 전달 순서는 보장해도, 연결이 끊긴 뒤 재접속한 클라이언트가 이전 입력을 재전송할 수 있다. 따라서 패킷 순번과 재접속 식별자는 별도로 관리한다.
- 클라이언트는 연결마다 증가하는
sequence을 패킷에 넣는다. - 서버는 플레이어별 마지막 처리 순번보다 작거나 같은 패킷을 버린다.
- 재접속 시 서버는 새 스트림에 현재 권위 스냅샷을 보내고 클라이언트는 로컬 예측 상태를 재조정한다.
sequence은 전송 순서 판별용이며 게임 시뮬레이션 시간은 아니다. 판정과 보정에는 서버가 부여한 server_tick을 기준으로 삼아야 한다.
자주 묻는 질문 (FAQ)
gRPC를 Unity 실시간 멀티플레이에 바로 써도 될까?
로비, 매치메이킹, 채팅, 턴제 명령, 상태 동기화에는 적합하다. 하지만 경쟁 액션 게임의 고빈도 조작 입력은 목표 지연 시간과 패킷 손실 허용 범위를 측정한 뒤 UDP 또는 전용 전송 계층을 함께 검토하는 편이 낫다.
Protocol Buffers 필드 번호를 바꾸면 어떻게 되는가?
같은 필드의 번호를 바꾸면 기존 클라이언트가 데이터를 다른 의미로 해석할 수 있다. 배포한 번호는 유지하고 제거한 번호는 reserved로 선언한다.
gRPC 스트림에 하트비트가 필요한가?
중간 프록시나 모바일 환경에서 유휴 연결이 끊길 수 있다. 운영 환경에서 유휴 종료가 관측되면 애플리케이션 하트비트 또는 gRPC keepalive를 추가하되 너무 짧은 간격은 서버와 네트워크 비용을 높인다.
정리
gRPC와 Protocol Buffers의 핵심은 작은 바이너리 패킷 자체보다 .proto를 단일 계약으로 삼아 서버와 클라이언트의 메시지 의미를 일치시키는 데 있다. 먼저 oneof, 필드 번호 보존, 인증 검증, 순번 중복 제거, 고정 틱 기반 스냅샷을 구현하고 실제 측정에서 병목이 확인될 때만 델타 압축과 전용 저지연 전송을 추가한다.



