서버 패킷 구조체 변경을 자동 동기화하는 gRPC Protobuf·클라이언트 역직렬화 파이프라인

서버 패킷 구조체 변경 때 발생하는 gRPC Protobuf와 클라이언트 역직렬화 불일치를 해결합니다. 단일 스키마, 자동 코드 생성, 호환성 검사와 CI 검증을 적용해 멀티플레이 배포 오류와 보안 위험을 줄이는 실전 파이프라인을 설명합니다.

✓ 복사 완료!
챕터넷코드 & 상태 동기화 (Client Prediction/Lag)
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
서버 패킷 구조체 변경을 자동 동기화하는 gRPC Protobuf·클라이언트 역직렬화 파이프라인

핵심 요약

서버와 클라이언트가 각각 패킷 구조체와 역직렬화 코드를 관리하면 필드 추가·삭제 때 프로토콜 불일치가 발생합니다. .proto 파일을 단일 계약으로 두고 protoc 또는 buf generate로 gRPC 스텁과 클라이언트 모델을 생성하면 구조체 변경을 양쪽 코드에 자동 반영할 수 있습니다. 여기에 필드 번호 규칙, buf breaking 검사, 생성 코드 diff 검증과 입력값 검증을 CI에 포함하면 배포 오류와 악의적인 패킷 위험까지 줄일 수 있습니다.

서버 패킷 구조체 변경에서 동기화 문제가 생기는 이유

멀티플레이 게임의 패킷은 다음 계층을 거칩니다.

계층역할변경 시 흔한 문제
도메인 모델서버 내부 게임 상태 표현네트워크 계약과 강하게 결합됨
Protobuf 메시지바이너리 wire format과 API 계약필드 번호 변경으로 호환성 손상
gRPC 스텁RPC 호출과 메시지 직렬화·역직렬화서버와 클라이언트 생성 코드 버전 불일치
클라이언트 모델Unity, Unreal 또는 자체 클라이언트에서 사용하는 타입수동 파서 누락, 기본값 오해

예를 들어 서버의 PlayerState에 server_tick 필드를 추가했는데 클라이언트의 역직렬화 코드가 이전 구조체를 기준으로 작성되어 있다고 가정해 보겠습니다. 수동 바이너리 포맷에서는 필드 순서와 길이를 잘못 계산해 뒤쪽 값이 모두 깨질 수 있습니다. Protobuf를 사용하면 기존 클라이언트는 알 수 없는 필드를 건너뛰고 기존 필드만 읽을 수 있지만 비즈니스 로직이 새 필드를 반드시 요구한다면 별도의 버전 정책이 필요합니다.

핵심은 서버의 C# 또는 C++ 구조체를 네트워크 계약의 원본으로 삼지 않는 것입니다. 내부 구조체는 최적화와 도메인 요구에 따라 바뀔 수 있고 외부 계약은 장기간 호환성을 유지해야 합니다.

gRPC Protobuf에서는 무엇을 단일 원본으로 삼아야 하는가?

.proto 파일을 API와 패킷 구조의 단일 원본으로 관리합니다. 서버 내부 구조체와 클라이언트 표시용 모델은 Protobuf 메시지와 직접 동일하게 만들기보다 명시적인 매핑 계층을 두는 편이 안전합니다.

syntax = "proto3";

package game.v1;
option csharp_namespace = "Game.Protocol.V1";

message PlayerState {
  uint64 player_id = 1;
  optional int32 hp = 2;
  repeated uint32 active_skill_ids = 3;
  uint64 server_tick = 4;
}

message SyncStateRequest {
  uint64 match_id = 1;
  uint64 last_server_tick = 2;
}

message SyncStateResponse {
  PlayerState state = 1;
}

service GameSync {
  rpc SyncState(SyncStateRequest) returns (SyncStateResponse);
}

Protobuf에서 중요한 규칙은 필드 이름이 아니라 필드 번호입니다.

  • 이미 배포한 필드 번호는 다른 의미로 재사용하지 않습니다.
  • 삭제한 필드 번호와 이름은 reserved로 보존합니다.
  • 새 필드는 기존 번호와 겹치지 않게 추가합니다.
  • 값의 존재 여부가 중요하면 proto3의 optional 또는 oneof를 사용합니다.
  • 큰 문자열, 반복 필드, 중첩 메시지는 최대 크기와 개수를 별도로 제한합니다.
message PlayerState {
  reserved 5, 6;
  reserved "legacy_name", "old_status";

  uint64 player_id = 1;
  optional int32 hp = 2;
  repeated uint32 active_skill_ids = 3;
  uint64 server_tick = 4;
}

hp가 0인지와 필드가 전혀 오지 않았는지를 구분해야 한다면 일반적인 proto3 scalar보다 optional int32 hp가 적합합니다. 반대로 단순한 기본값으로 충분한 필드는 일반 scalar로 유지할 수 있습니다.

Protobuf 코드 생성은 서버와 클라이언트를 어떻게 자동 동기화하는가?

protoc 또는 buf가 같은 .proto 파일을 읽어 언어별 코드를 생성합니다. C#에서는 Protobuf 메시지 클래스와 gRPC 클라이언트·서버 베이스 클래스가 함께 생성되므로 수동 역직렬화 코드를 작성할 필요가 줄어듭니다.

proto/game/v1/game.proto
        │
        ▼
buf lint → buf breaking → buf generate
        │
        ├─ 서버용 C# 또는 C++ 메시지·서비스 코드
        └─ 클라이언트용 C# 메시지·gRPC 스텁

Protobuf 스키마에서 서버와 클라이언트 코드가 자동 생성되는 CI 파이프라인

예시로 buf.gen.yaml을 다음처럼 구성할 수 있습니다.

version: v2
plugins:
  - remote: buf.build/protocolbuffers/csharp
    out: src/Generated/Protocol
  - remote: buf.build/grpc/csharp
    out: src/Generated/Grpc

팀의 빌드 환경에 따라 원격 플러그인 대신 로컬 protoc 플러그인을 고정해도 됩니다. 중요한 것은 개발자마다 다른 protoc 버전을 사용하는 상황을 피하는 것입니다. 생성 도구와 플러그인의 버전은 저장소 설정 또는 빌드 컨테이너에서 고정합니다.

서버와 클라이언트 모델을 직접 매핑하기

서버 도메인 모델이 다음과 같다고 가정합니다.

public sealed class PlayerStateDomain
{
    public ulong PlayerId { get; init; }
    public int Health { get; init; }
    public IReadOnlyList<uint> SkillIds { get; init; } = Array.Empty<uint>();
    public ulong Tick { get; init; }
}

Protobuf 메시지로 변환하는 코드는 한 곳에 둡니다.

using Game.Protocol.V1;

public static class PlayerStateMapper
{
    public static PlayerState ToProto(PlayerStateDomain source)
    {
        var result = new PlayerState
        {
            PlayerId = source.PlayerId,
            Hp = source.Health,
            ServerTick = source.Tick
        };

        result.ActiveSkillIds.Add(source.SkillIds);
        return result;
    }
}

이렇게 하면 내부 필드명을 Health에서 CurrentHealth로 바꾸더라도 외부 계약인 hp = 2는 그대로 유지할 수 있습니다. 반대로 네트워크 계약을 변경할 때는 .proto와 매핑 코드, 생성 결과를 함께 검토하게 됩니다.

클라이언트 역직렬화 코드는 어떻게 자동 동기화할까?

gRPC 호출에서는 생성된 스텁이 메시지 직렬화와 역직렬화를 처리합니다.

var response = await gameSyncClient.SyncStateAsync(
    new SyncStateRequest
    {
        MatchId = matchId,
        LastServerTick = lastTick
    },
    deadline: DateTime.UtcNow.AddMilliseconds(200));

PlayerState state = response.State;

UDP 또는 WebSocket 페이로드에 Protobuf 바이트를 직접 넣는 경우에도 생성된 MessageParser<T>를 사용합니다.

using Google.Protobuf;
using Game.Protocol.V1;

public static PlayerState DeserializeState(ReadOnlySpan<byte> payload)
{
    if (payload.Length == 0 || payload.Length > 64 * 1024)
        throw new InvalidDataException("Invalid packet size");

    var state = PlayerState.Parser.ParseFrom(payload.ToArray());

    if (state.PlayerId == 0)
        throw new InvalidDataException("Missing player id");

    if (state.Hp < 0 || state.Hp > 10000)
        throw new InvalidDataException("Invalid health value");

    if (state.ActiveSkillIds.Count > 32)
        throw new InvalidDataException("Too many skills");

    return state;
}

이 구조에서는 server_tick을 추가해도 클라이언트 코드는 생성된 PlayerState.ServerTick 속성을 통해 같은 스키마에서 다시 생성됩니다. 수동으로 바이트 오프셋을 계산하는 코드가 없으므로 필드 순서 변경이나 새 필드 추가가 곧바로 클라이언트 파서 전체를 깨뜨리지 않습니다.

단, 자동 생성이 입력값 검증까지 대신해 주지는 않습니다. Protobuf 파서는 형식이 맞는 바이트를 읽을 뿐이며 플레이어 ID의 소유권, 체력 범위, 스킬 개수, 매치 참가 여부 같은 게임 규칙은 서버에서 다시 검증해야 합니다.

패킷 구조체 변경을 안전하게 적용하는 5단계

1단계: 변경을 호환성 기준으로 분류한다

변경기본 호환성권장 처리
새 optional 필드 추가대체로 안전서버와 클라이언트의 기본값 정의
기존 필드 삭제조건부 안전번호와 이름을 reserved로 지정
필드 번호 변경위험새 필드 번호로 추가하고 이전 필드는 유지
필드 타입 변경위험새 필드로 추가하거나 메시지 버전 분리
필드 의미 변경위험이름과 번호를 유지하지 말고 새 필드로 모델링
필수 규칙 추가형식상 안전하지만 의미상 위험기능 플래그와 서버 검증 사용

Protobuf의 wire 호환성과 게임 기능의 호환성은 다릅니다. 예를 들어 새 필드가 없어도 파싱은 성공할 수 있지만 클라이언트가 그 필드 없이 동작할 수 없다면 기능 호환성은 깨집니다.

2단계: 스키마와 생성 도구를 고정한다

저장소에는 최소한 다음 항목을 함께 둡니다.

proto/
buf.yaml
buf.gen.yaml
tools/protoc-version.txt
src/Generated/

생성 코드를 저장소에 커밋하는 방식과 CI에서 매번 생성하는 방식 모두 사용할 수 있습니다.

방식장점주의점
생성 코드 커밋클라이언트 빌드가 단순함생성 결과 diff를 반드시 리뷰해야 함
CI 생성원본과 결과가 항상 일치함빌드 환경에 도구 설치가 필요함
패키지 배포여러 저장소와 플랫폼에 적합함패키지 버전 정책이 필요함

Unity 클라이언트처럼 별도 저장소에서 관리하는 경우에는 Game.Protocol.V1 같은 NuGet 패키지 또는 사내 바이너리 패키지를 발행하는 방식을 사용할 수 있습니다. 서버와 클라이언트가 같은 커밋의 스키마에서 생성되었는지 패키지 메타데이터로 추적하면 롤백도 쉬워집니다.

3단계: 로컬과 CI에서 lint와 breaking 검사를 실행한다

buf lint
buf breaking --against '.git#branch=main'
buf generate
git diff --exit-code -- src/Generated

buf lint는 이름 규칙과 문법 오류를 찾고 buf breaking은 기존 스키마와 호환되지 않는 변경을 검사합니다. 마지막 diff 검사는 개발자가 .proto를 수정하고 생성 코드를 갱신하지 않은 경우를 잡습니다.

생성 파일을 커밋하지 않는다면 git diff 대신 CI에서 생성 결과를 임시 디렉터리와 비교하거나 생성된 패키지의 체크섬을 검증합니다.

4단계: 런타임 버전 협상과 기능 플래그를 둔다

서버를 먼저 배포해야 하는 경우 새 필드는 기존 클라이언트에서 무시할 수 있게 설계합니다. 새 기능이 필수라면 다음과 같은 정보를 별도로 교환합니다.

  • 프로토콜 주 버전과 부 버전
  • 클라이언트가 지원하는 기능 목록
  • 서버가 활성화한 기능 플래그
  • 최소 지원 클라이언트 빌드 번호

단순히 메시지에 version 정수 하나를 계속 증가시키는 방식보다 기능 단위의 capability 협상이 점진적 배포에 유리합니다. 다만 보안과 권한을 결정하는 값은 클라이언트가 보낸 capability만 믿어서는 안 됩니다.

5단계: 테스트 패킷과 관측 지표를 함께 관리한다

스키마 변경마다 다음 테스트를 추가합니다.

  1. 이전 클라이언트가 새 서버 응답을 읽는 테스트
  2. 새 클라이언트가 이전 서버 응답을 읽는 테스트
  3. 필드가 누락되었을 때 기본값과 presence가 의도대로 동작하는 테스트
  4. 잘린 패킷, 과도한 반복 필드, 비정상 길이의 패킷을 거부하는 테스트
  5. 직렬화 후 역직렬화했을 때 핵심 필드가 유지되는 round-trip 테스트

운영에서는 unknown field 자체보다 파싱 실패율, 메시지 크기, RPC deadline 초과, 클라이언트별 프로토콜 버전을 수집하는 편이 유용합니다. 로그에는 토큰이나 전체 페이로드를 남기지 말고 메시지 종류, 버전, 크기, 실패 이유처럼 필요한 메타데이터만 기록합니다.

Protobuf 역직렬화에서 반드시 확인할 보안 항목

자동 동기화는 개발 편의성을 높이지만 패킷을 신뢰해도 된다는 뜻은 아닙니다.

보안 항목서버에서 확인할 내용
인증연결된 계정과 세션 토큰이 유효한가
권한요청한 플레이어와 매치의 소유권이 일치하는가
크기 제한gRPC 최대 메시지 크기와 UDP 페이로드 상한을 넘지 않는가
반복 필드배열 개수와 중첩 깊이가 제한되어 있는가
값 범위체력, 좌표, 틱, ID가 허용 범위인가
재전송nonce, 시퀀스 또는 서버 틱으로 replay를 탐지하는가
전송 보안외부 구간에서 TLS 또는 mTLS를 사용하는가
장애 격리파싱 실패가 게임 스레드 전체를 중단시키지 않는가

특히 Protobuf의 알 수 없는 필드를 보존한 뒤 다른 서버로 그대로 전달하는 프록시 구조에서는 주의가 필요합니다. 중계 서버가 검증하지 않은 메시지를 재전송하면 새 필드를 이용한 우회 입력이 생길 수 있습니다. 경계에서 파싱하고 검증한 뒤 내부 명령 객체로 변환하는 구조가 안전합니다.

gRPC Protobuf 자동 동기화의 권장 저장소 구조

repo/
├─ proto/game/v1/game.proto
├─ buf.yaml
├─ buf.gen.yaml
├─ server/
│  ├─ Domain/
│  ├─ ProtocolMapping/
│  └─ GrpcServices/
├─ client/
│  └─ GeneratedProtocol/
├─ tests/
│  └─ ProtocolCompatibilityTests/
└─ .github/workflows/protocol.yml

팀 규모가 작다면 서버와 클라이언트를 하나의 저장소에 두고 생성 결과 diff를 CI에서 검사하는 방법이 가장 단순합니다. 저장소가 분리되어 있다면 .proto 저장소에서 버전이 붙은 패키지를 발행하고 클라이언트와 서버는 같은 프로토콜 패키지 버전을 명시적으로 참조합니다.

중요한 것은 다음 세 가지를 구분하는 것입니다.

  • .proto: 외부 통신 계약
  • 생성 코드: 계약에서 파생된 구현물
  • 도메인 모델: 게임 규칙을 표현하는 내부 객체

이 경계를 지키면 서버 내부 최적화가 클라이언트 패킷 포맷을 불필요하게 흔들지 않습니다.

자주 묻는 질문(FAQ)

기존 클라이언트가 새 Protobuf 필드를 읽지 못하면 오류가 발생하나요?

일반적으로 새 필드는 기존 클라이언트가 알 수 없는 필드로 처리해 건너뜁니다. 그러나 새 필드가 없으면 게임 기능이 성립하지 않는 경우에는 capability 협상이나 최소 클라이언트 버전 검사를 추가해야 합니다.

서버 구조체를 그대로 Protobuf 메시지로 사용해도 되나요?

작은 프로젝트에서는 가능하지만 장기 운영에는 권장하지 않습니다. 서버 도메인 모델과 네트워크 DTO를 분리해야 내부 필드 변경, 보안 필드 제외, API 버전 관리를 독립적으로 수행할 수 있습니다.

생성 코드가 있는데도 클라이언트 역직렬화 오류가 발생하는 이유는 무엇인가요?

대부분 생성 파일이 오래되었거나 서버와 클라이언트가 서로 다른 프로토콜 패키지 버전을 사용하기 때문입니다. CI에서 buf breaking, 생성 결과 diff, 패키지 버전을 함께 검사하고 런타임에 메시지 버전과 빌드 번호를 기록하면 원인을 빠르게 좁힐 수 있습니다.

정리

서버 패킷 구조체 변경을 안전하게 동기화하려면 .proto를 단일 계약으로 삼고 서버와 클라이언트 코드를 같은 스키마에서 생성해야 합니다. buf lint와 buf breaking으로 호환성을 검사하고 생성 결과 검증과 이전·이후 버전 테스트를 CI에 추가하면 수동 역직렬화 코드에서 발생하는 불일치를 크게 줄일 수 있습니다.

마지막으로 Protobuf가 보장하는 것은 메시지 형식의 호환성입니다. 인증, 권한, 값 범위, 크기 제한과 replay 방어는 반드시 서버 런타임 검증으로 완성해야 합니다.