Protocol Buffers와 gRPC로 게임 패킷과 백엔드 통신 설계하기

Protocol Buffers와 gRPC로 게임 패킷과 백엔드 통신 설계하기

Protocol Buffers의 스키마 진화 규칙과 gRPC 통신 구조를 바탕으로 게임 클라이언트와 백엔드 사이의 메시지를 안전하게 설계하는 방법을 정리합니다. Unity와 C++ 서버 환경에서 바로 적용할 수 있는 예제를 함께 다룹니다.

왜 게임 통신에 Protocol Buffers와 gRPC를 쓰는가

게임 클라이언트와 백엔드가 주고받는 데이터는 오래 유지되는 인터페이스다. 로그인 응답, 인벤토리 동기화, 매치 결과처럼 한 번 정한 메시지는 클라이언트와 서버의 배포 시점이 달라도 계속 해석할 수 있어야 한다.

Protocol Buffers는 .proto 파일로 메시지 구조를 정의하고 이를 언어별 코드로 생성한다. 필드 이름 대신 숫자 태그를 중심으로 직렬화하므로 JSON보다 전송 크기를 줄이기 쉽고 스키마 변경 규칙도 비교적 명확하다. gRPC는 이 메시지를 요청과 응답으로 교환하는 RPC 계층을 제공한다.

다만 gRPC가 모든 실시간 게임 패킷을 대체하는 것은 아니다. HTTP/2 기반 요청·응답 통신에 잘 맞으므로 인증, 로비, 결제 검증, 우편함, 친구 목록처럼 백엔드 API 성격이 강한 기능에 특히 적합하다. 프레임 단위 입력이나 고빈도 위치 동기화는 UDP 기반 전용 프로토콜, 신뢰성 전송 계층, WebSocket 등을 요구사항에 맞춰 별도로 검토하는 편이 좋다.

flowchart LR
    Client[게임 클라이언트] -->|gRPC 요청| Gateway[API 또는 게임 백엔드]
    Gateway -->|Protobuf 메시지| Auth[인증 서비스]
    Gateway -->|Protobuf 메시지| Game[게임 서비스]
    Game --> DB[(게임 데이터베이스)]
    Gateway -->|gRPC 응답| Client

메시지부터 설계한다

proto3 문법에서는 각 필드에 숫자 태그를 부여한다. 이 숫자는 직렬화 형식의 일부이므로 공개한 뒤에는 의미를 바꾸면 안 된다. 예를 들어 1번 필드가 user_id였다면 다음 버전에서 1번을 display_name으로 재사용하면 구버전 프로그램이 데이터를 잘못 읽을 수 있다.

아래 예제는 로그인과 프로필 조회를 위한 최소한의 계약이다.

syntax = "proto3";

package game.account.v1;

option csharp_namespace = "Game.Account.V1";

service AccountService {
  rpc Login(LoginRequest) returns (LoginResponse);
  rpc GetProfile(GetProfileRequest) returns (ProfileResponse);
}

message LoginRequest {
  string platform_token = 1;
  string client_version = 2;
}

message LoginResponse {
  string access_token = 1;
  int64 user_id = 2;
  Profile profile = 3;
}

message GetProfileRequest {
  int64 user_id = 1;
}

message ProfileResponse {
  Profile profile = 1;
}

message Profile {
  int64 user_id = 1;
  string nickname = 2;
  uint32 level = 3;
  optional uint64 last_login_unix_ms = 4;
}

int64uint64는 언어별 표현 방식 차이를 확인해야 한다. 특히 JavaScript 환경에서는 큰 정수를 number로 다루면 정밀도를 잃을 수 있다. 게임 식별자를 문자열로 전달할지 각 클라이언트 언어에서 64비트 정수를 안전하게 지원하는지 계약 단계에서 결정한다.

optional은 값이 없다는 상태와 기본값을 구분해야 할 때 유용하다. 위 예제에서 마지막 로그인 시간이 아직 기록되지 않았다면 last_login_unix_ms의 존재 여부를 확인할 수 있다. 반대로 레벨 0은 보통 유효한 값이므로 단순한 uint32로 충분하다.

스키마 변경 규칙

운영 중인 게임에서는 클라이언트와 서버가 동시에 갱신되지 않는다. 따라서 새 필드를 추가하는 방식으로 호환성을 유지하는 습관이 중요하다.

  • 새 필드는 사용하지 않은 태그 번호로 추가한다.
  • 삭제한 필드 번호와 이름은 reserved로 막아 재사용을 방지한다.
  • 기존 필드의 태그 번호와 의미를 바꾸지 않는다.
  • 숫자 형식, 문자열 형식처럼 wire type이 달라지는 변경은 피한다.
  • 서버는 새 필드가 없는 구버전 요청을 처리할 수 있어야 한다.

예를 들어 더 이상 사용하지 않는 tutorial_completed 필드를 제거할 때는 다음처럼 남긴다.

message Profile {
  reserved 5;
  reserved "tutorial_completed";

  int64 user_id = 1;
  string nickname = 2;
  uint32 level = 3;
  optional uint64 last_login_unix_ms = 4;
}

필드 번호는 작은 값일수록 인코딩 비용이 작지만 대부분의 게임 API에서는 번호 최적화보다 안정적인 관리가 더 중요하다. 팀 규칙을 정해 기능 단위로 번호 범위를 배정하거나 스키마 리뷰에서 중복과 재사용 여부를 확인하는 것이 효과적이다.

C++ 서버 구현의 핵심

생성된 gRPC 서비스 클래스를 상속한 뒤 RPC 메서드를 구현할 수 있다. 아래 코드는 동기식 C++ 서버 구현의 형태를 단순화한 예시다. 실제 인증 토큰 검증과 데이터 조회는 별도 서비스로 분리하는 편이 일반적이다.

class AccountServiceImpl final : public game::account::v1::AccountService::Service {
 public:
  grpc::Status Login(
      grpc::ServerContext* context,
      const game::account::v1::LoginRequest* request,
      game::account::v1::LoginResponse* response) override {
    if (request->platform_token().empty()) {
      return grpc::Status(
          grpc::StatusCode::INVALID_ARGUMENT,
          "platform_token is required");
    }

    const int64_t user_id = VerifyPlatformToken(request->platform_token());
    if (user_id == 0) {
      return grpc::Status(
          grpc::StatusCode::UNAUTHENTICATED,
          "invalid platform token");
    }

    response->set_access_token(IssueAccessToken(user_id));
    response->set_user_id(user_id);

    auto* profile = response->mutable_profile();
    profile->set_user_id(user_id);
    profile->set_nickname(LoadNickname(user_id));
    profile->set_level(LoadLevel(user_id));

    return grpc::Status::OK;
  }
};

gRPC 상태 코드는 전송 실패와 업무 규칙 실패를 구분하는 기준이 된다. 요청 형식이 잘못되면 INVALID_ARGUMENT, 인증 실패면 UNAUTHENTICATED, 권한 부족이면 PERMISSION_DENIED를 사용할 수 있다. 이미 존재하는 닉네임처럼 게임 규칙상 충돌한 경우에는 ALREADY_EXISTS 또는 응답 메시지의 명시적인 오류 코드를 팀 규약에 따라 선택한다.

클라이언트에 데이터베이스 오류나 내부 예외 내용을 그대로 전달하지 않는 것도 중요하다. 서버 로그에는 원인과 요청 추적 정보를 남기고 외부 응답에는 안전하고 일관된 오류 메시지만 제공한다.

Unity 클라이언트에서 호출하기

C# 코드를 생성하면 요청 메시지와 AccountServiceClient를 사용할 수 있다. Unity에서 gRPC를 적용할 때는 사용할 런타임과 대상 플랫폼에서 HTTP/2 및 TLS 구성이 가능한지 먼저 확인해야 한다. 플랫폼별 제약이 있다면 gRPC-Web 프록시나 별도 HTTP API가 필요한 경우도 있다.

using System;
using System.Threading.Tasks;
using Grpc.Core;
using Game.Account.V1;

public sealed class AccountApi
{
    private readonly AccountService.AccountServiceClient _client;

    public AccountApi(AccountService.AccountServiceClient client)
    {
        _client = client;
    }

    public async Task<LoginResponse> LoginAsync(string platformToken)
    {
        var request = new LoginRequest
        {
            PlatformToken = platformToken,
            ClientVersion = Application.version
        };

        try
        {
            return await _client.LoginAsync(request).ResponseAsync;
        }
        catch (RpcException exception) when (
            exception.StatusCode == StatusCode.Unauthenticated)
        {
            throw new InvalidOperationException("로그인이 만료되었거나 인증에 실패했습니다.", exception);
        }
    }
}

Unity의 메인 스레드에서 UI를 갱신해야 한다면 await 이후의 실행 컨텍스트를 프로젝트 설정에 맞게 다뤄야 한다. 네트워크 요청을 시작한 화면이 이미 닫혔을 수도 있으므로 화면 수명과 연결된 취소 토큰을 전달하는 방식도 고려할 만하다.

패킷 직렬화와 보안에서 놓치기 쉬운 점

Protobuf로 직렬화했다고 해서 요청을 신뢰할 수 있는 것은 아니다. 클라이언트가 보내는 레벨, 재화 수량, 사용자 ID는 모두 조작 가능하다고 가정해야 한다. 서버는 인증된 사용자 ID를 토큰이나 세션에서 결정하고 재화 지급이나 구매 결과 같은 권위 데이터는 서버에서 계산해야 한다.

다음 항목을 기본 점검 목록으로 삼을 수 있다.

  • 운영 환경에서는 TLS를 적용하고 인증 토큰을 안전한 메타데이터로 전달한다.
  • 요청 크기와 목록 길이에 상한을 둬 과도한 메시지를 차단한다.
  • RPC마다 마감 시간과 취소 처리를 설정해 무한 대기를 막는다.
  • 재시도 가능한 읽기 요청과 중복 실행되면 안 되는 구매 요청을 구분한다.
  • 로그에는 요청 ID와 오류 상태를 남기되 토큰이나 개인정보는 기록하지 않는다.

특히 재시도는 신중해야 한다. 네트워크 연결이 끊겼다고 해서 구매 RPC가 서버에서 실행되지 않았다고 단정할 수 없다. 구매, 보상 수령, 아이템 소모 같은 요청에는 클라이언트가 생성한 멱등성 키를 포함하고 서버가 같은 키의 처리 결과를 재사용하도록 설계하면 중복 지급 위험을 줄일 수 있다.

마무리

Protocol Buffers는 메시지 크기만 줄이는 도구가 아니라 클라이언트와 서버가 공유하는 계약을 관리하는 방법이다. gRPC는 그 계약 위에서 인증, 로비, 계정, 인벤토리 같은 백엔드 기능을 일관된 호출 방식으로 구성하게 해 준다.

처음에는 로그인이나 프로필 조회처럼 단순한 unary RPC부터 도입하고 배포 버전이 다른 클라이언트도 정상 동작하는지 호환성 테스트를 추가하는 것이 좋다. 이후 오류 규약, 인증 메타데이터, 멱등성 처리까지 확장하면 운영 환경에서도 예측 가능한 통신 계층을 만들 수 있다.

#Protocol Buffers#gRPC#Unity#C++#백엔드

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs