본문으로 건너뛰기
뒤로가기

[C++] Packet Tool: JSON 스키마 기반 패킷 코드 자동화

TL;DR — 패킷마다 C++, C#의 DTO, 필드 배치와 Encode/Decode를 함께 작성하던 작업을 줄이기 위해 JSON 스키마 기반 Packet Tool을 만들었습니다. 공통 Descriptor에서 언어별 코드를 생성하여 같은 바이트 규칙을 사용하게 하고, 서버와 클라이언트는 생성된 타입과 codec을 사용할 수 있도록 구성했습니다.

Table of contents

Open Table of contents

들어가며

PrivateServer에서 새로운 패킷을 추가할 때는 패킷에 담을 데이터뿐 아니라 C++ 서버와 C# 클라이언트의 DTO, 필드 offset, payload 크기와 Encode/Decode를 함께 작성해야 했습니다. 패킷을 사용하는 테스트와 실행 코드도 연결해야 하므로, 하나의 패킷 변경이 여러 파일의 수정으로 이어졌습니다.

패킷의 구조는 일정한 규칙으로 반복되는 부분이 많았기에 이 부분을 자동화하기 위한 별도의 Packet Tool에 대한 생각을 가지고 있었고, 이를 Protobuf의 코드 생성 구조를 참고하여 JSON 기반 Packet Tool로 만든 흐름을 정리합니다.

이 글에서 다루는 내용:


1. 패킷을 추가할 때 반복되는 작업

1.1 공통화한 부분과 남아 있는 비용

PrivateServer의 패킷 처리가 모두 개별 구현으로 흩어져 있던 것은 아닙니다. C++의 WorldProtocolWireCodec과 C#의 V1WireCodec은 정수를 little-endian으로 읽고 쓰는 기본 연산을 제공합니다. NetworkRuntime은 TCP stream에서 frame을 복원하고, World의 codec은 그 안에 들어 있는 payload를 해석하도록 책임을 나누었습니다. 테스트도 실제 패킷 codec을 재사용합니다.

그런데 기본 연산과 호출 경로를 공통화한 뒤에도 어떤 필드를 어느 위치에 배치할 것인가는 패킷마다 사람이 다시 작성해야 했습니다.

작업직접 관리하던 내용
데이터 타입 정의C++ struct와 C# record에 같은 의미의 필드 작성
바이트 배치필드 순서, offset, 전체 payload 크기 계산
Encode/Decode각 필드에 맞는 읽기·쓰기 함수와 offset 연결
프로토콜 검증언어별 결과가 같은 바이트 규칙을 따르는지 확인
소비 코드 연결패킷을 만드는 테스트, 송수신 경로와 프로젝트 입력 연결

즉, 패킷을 새로 추가하는 경우뿐만 아니라 이미 존재하는 패킷의 필드 하나를 추가하려고 해도 해당 필드의 변경 외에 offset, payload, Encode/Decode와 같은 검증 작업이 따라붙는 구조였습니다. 그래서 패킷과 관련된 기본 연산은 재사용할 수 있지만, 그 연산을 이용하여 패킷의 구조를 코드로 옮기는 작업은 반복되고 있었습니다.

1.2 패킷 정의와 사용 코드를 나누는 방향

반복되는 작업 중 필드의 타입과 순서에서 기계적으로 결정할 수 있는 부분을 생성 대상으로 삼았습니다. 사람이 패킷의 정의를 작성하면 Packet Tool이 DTO와 codec을 만들고, 서버와 클라이언트는 그 결과를 사용하는 방식입니다. 이렇게 나누면 패킷의 바이트 표현을 수정하는 기준을 한 곳으로 모을 수 있습니다.


2. Protobuf에서 참고한 코드 생성 구조

2.1 공통 정의에서 언어별 코드로

Protobuf에서 참고한 부분은 공통 메시지 정의를 기반으로 대상 언어의 코드를 생성하는 방식입니다. .proto에 메시지의 이름과 필드를 정의하면 protoc이 각 언어에서 사용할 타입과 직렬화 코드를 생성합니다. 소비자는 생성된 타입에 값을 담고, 함께 제공되는 메서드로 바이트를 기록하거나 읽습니다. Protocol Buffers Overview

Packet Tool에도 메시지 정의와 언어별 코드 생성의 분리를 적용했습니다. 패킷의 구조는 한 번 정의하고, 이를 각 언어의 타입과 바이트 변환 코드로 옮기는 작업은 Generator가 담당하도록 구성했습니다.

공통 메시지 정의
    -> 언어별 Generator
        -> 각 애플리케이션에서 타입과 codec 사용

2.2 기존 바이트 규칙에 맞춘 적용

PrivateServer는 필드를 정해진 위치에 배치하는 fixed-layout payload를 사용하고 있었습니다. 그래서 기존의 바이트 규칙을 유지하면서 코드 작성을 자동화하는 데 범위를 맞췄습니다.

Packet Tool에서는 패킷 정의를 JSON으로 작성합니다. JSON의 객체와 배열을 이용해 패킷 이름, ID, 버전과 순서가 있는 필드 목록을 표현하고, 이를 C++ 또는 C# 코드로 변환합니다. 패킷의 바이트 변환 과정 최적화에 초점을 맞춘 것은 아니기 때문에 JSON 스키마를 이용해 파싱 과정에서 좀 더 편리하게 코드를 작성할 수 있게 설정했습니다.

여기서 JSON은 Generator에 전달하는 입력 형식입니다. 실제 소비자의 통신에는 생성된 codec이 만든 fixed-layout 바이트를 사용합니다.


3. JSON 스키마에서 생성 코드까지

3.1 공통 처리와 언어별 생성의 분리

Packet Tool의 생성 흐름은 다음과 같습니다. CLI는 설정과 입력 경로를 정리하고, 실제 스키마 처리와 코드 생성은 공통 compiler가 담당합니다.

flowchart TD
    Config["INI 설정
    언어 · 입력 · 출력 · namespace"] --> Cli["CLI
    설정 해석과 입력 경로 수집"]
    Json["JSON 스키마"] --> Schema["Schema Compiler
    parsing · 검증 · layout 계산"]
    Cli --> Schema
    Schema --> Descriptor["PacketDescriptor
    공통 패킷 정보와 필드 배치"]
    Descriptor --> Cpp["C++ Generator"]
    Descriptor --> CSharp["C# Generator"]
    Cpp --> CppSource[".generated.h"]
    CSharp --> CSharpSource[".generated.cs"]
    CppSource --> CppConsumer["C++ 소비 코드
    Common · codec support header"]
    CSharpSource --> CSharpConsumer["C# 소비 코드
    C# codec support source"]

CLI는 단일 JSON 파일이나 디렉터리를 입력으로 받습니다. 디렉터리라면 하위 JSON 파일까지 수집하고 경로를 정렬한 뒤 compiler에 전달합니다. 한 번의 실행은 선택한 언어 하나에 대한 패킷 코드를 생성합니다.

Compiler 내부에서는 다음 책임을 분리했습니다.

단계책임
JSON parsing과 schema 검증필수 속성, 버전, 필드 타입과 중복 정의 확인
Descriptor 구성패킷 정보와 필드별 offset·크기 확정
언어별 GeneratorDescriptor를 대상 언어의 DTO와 codec source로 변환
파일 기록생성 결과를 파일로 저장하고 동일한 내용의 재기록 생략

알 수 없는 속성이나 지원하지 않는 타입, 한 batch 안의 중복 패킷 ID·이름은 생성 전에 오류로 처리합니다. 이는 Generator가 임의로 해석하거나 일부 필드를 무시하지 않도록 한 것입니다.

3.2 중간 표현: PacketDescriptor

PacketDescriptor는 Generator가 바로 사용할 수 있도록 JSON을 해석해 C++ 코드로 패킷 ID와 버전, payload 크기, 각 필드의 타입, offset과 크기를 정리한 중간 표현 계층 역할을 담당합니다.

Payload의 처음 두 바이트에는 uint16인 payload version을 넣고, 그 뒤에 스키마의 필드를 선언 순서대로 배치합니다. 각 필드가 차지하는 크기를 누적하여 다음 offset과 전체 크기를 계산합니다.

따라서 C++ Generator와 C# Generator가 각각 layout을 다시 계산할 필요가 없습니다. 두 Generator는 같은 Descriptor에 기록된 위치와 타입을 사용하고, 대상 언어의 표현만 선택합니다.

class PacketCodeGenerator
{
public:
    virtual ~PacketCodeGenerator() = default;

    virtual TkResult Generate(
        const PacketDescriptor& descriptor,
        GeneratedFile* outFile) const = 0;
};TkPacketCodeGenerator.h

공통 compiler는 이 인터페이스를 통해 생성 결과를 받습니다. JSON 처리와 파일 기록은 공통 흐름에 남고, C++와 C#의 문법 차이는 각각의 Generator가 소유합니다.

현재 스키마가 다루는 필드는 8, 16, 32, 64-bit의 signed/unsigned 정수입니다. 문자열, 가변 길이 배열이나 중첩 메시지까지 자동으로 표현하는 계약은 현재 포함하지 않습니다.

3.3 실행 시 필요한 것은 생성 코드와 언어별 support

C++ 결과는 패킷별 .generated.h이며, C# 결과는 .generated.cs입니다. C++은 DTO의 Encode·Decode member와 TkResult를 사용하고, C#은 readonly record struct의 static TryEncode·TryDecode를 사용합니다.

언어 간에 공유하는 것은 패킷 ID, payload version, 필드 배치와 바이트입니다. 반환 타입이나 메서드 모양까지 같게 만들지는 않았습니다.

생성 시점에는 CLI가 ToolKit shared library를 호출합니다. 생성 결과를 사용하는 게임 runtime에는 그 compiler library를 링크할 필요가 없습니다. (필요하다면 library를 링크하여 직접 사용할 수 있습니다.) C++은 Common과 codec support header를 include하고, C#은 생성 source와 TkPacketCodecSupport.cs를 함께 컴파일합니다.

이 의존성 분리는 generated-code consumer 계약에 정리했습니다.


4. PrivateServer의 반복 작업을 줄이는 흐름

이제 실제 PrivateServer에서 사용 중인 패킷을 예시로 들어 어떻게 Packet Tool을 이용한 자동화가 가능한지 살펴보겠습니다.

4.1 MovementInput의 수기 바이트 배치

MovementInput은 클라이언트가 조작 중인 entity의 generation, 목표 server tick과 두 축의 이동 입력을 전달하는 패킷입니다. 기존 C++ codec에서는 다음과 같이 각 필드를 대응하는 offset에 직접 연결했습니다.

WorldProtocolWireCodec::WriteU16(
    PayloadVersion, Wire::PayloadVersionOffset, output);
WorldProtocolWireCodec::WriteU32(
    value.controlledEntityGeneration,
    Wire::ControlledEntityGenerationOffset, output);
WorldProtocolWireCodec::WriteU32(
    value.targetServerTick, Wire::TargetServerTickOffset, output);
WorldProtocolWireCodec::WriteI16(
    value.moveX, Wire::MoveXOffset, output);
WorldProtocolWireCodec::WriteI16(
    value.moveY, Wire::MoveYOffset, output);MovementInput.h

C#에서도 같은 필드를 같은 위치에 기록합니다.

V1WireCodec.WriteVersion(output);
V1WireCodec.WriteU32(ControlledEntityGeneration, 2, output);
V1WireCodec.WriteU32(TargetServerTick, 6, output);
V1WireCodec.WriteI16(MoveX, 10, output);
V1WireCodec.WriteI16(MoveY, 12, output);MovementInput.cs

각 언어별로 패킷의 필드를 정의하고, 이에 따른 별도 검증 과정 및 Encode/Decode 코드를 수기로 작성하는 것을 확인할 수 있습니다.

4.2 공통 스키마와 C++·C# 생성 결과

이 패킷을 다음 공통 스키마로 정의할 수 있습니다. 필드의 이름과 타입, 순서를 한 곳에 작성하고 C++와 C# Generator가 같은 정의를 사용합니다.

{
  "schemaVersion": 1,
  "packet": {
    "name": "MovementInput",
    "id": 257,
    "payloadVersion": 1,
    "fields": [
      { "name": "controlledEntityGeneration", "type": "uint32" },
      { "name": "targetServerTick", "type": "uint32" },
      { "name": "moveX", "type": "int16" },
      { "name": "moveY", "type": "int16" }
    ]
  }
}MovementInput.json

이 스키마를 각 언어로 변환하면 다음 코드가 생성됩니다.

C++ 생성 결과

namespace GeneratedPackets
{
struct MovementInput
{
    static constexpr std::uint16_t PacketId = 257;
    static constexpr std::uint16_t PayloadVersion = 1;
    static constexpr std::size_t PayloadBytes = 14;

    std::uint32_t controlledEntityGeneration{};
    std::uint32_t targetServerTick{};
    std::int16_t moveX{};
    std::int16_t moveY{};

    TkResult Encode(TkMutableByteView output,
                    TkDiagnosticCallbackInfo diagnosticCallback = {}) const
    {
        std::array<std::uint8_t, PayloadBytes> encoded{};
        pstk::packet::detail::WriteLittleEndian<std::uint16_t>(
            PayloadVersion, encoded.data() + 0);
        pstk::packet::detail::WriteLittleEndian<std::uint32_t>(
            controlledEntityGeneration, encoded.data() + 2);
        pstk::packet::detail::WriteLittleEndian<std::uint32_t>(
            targetServerTick, encoded.data() + 6);
        pstk::packet::detail::WriteLittleEndian<std::int16_t>(
            moveX, encoded.data() + 10);
        pstk::packet::detail::WriteLittleEndian<std::int16_t>(
            moveY, encoded.data() + 12);
        std::copy(encoded.begin(), encoded.end(), output.data);
        return TK_SUCCESS;
    }

    // Decode 본문은 생략했습니다.
};
} // namespace GeneratedPacketsMovementInput.generated.h

C# 생성 결과

namespace GeneratedPackets
{
    public readonly record struct MovementInput(
        uint controlledEntityGeneration,
        uint targetServerTick,
        short moveX,
        short moveY)
    {
        public const ushort PacketId = 257;
        public const ushort PayloadVersion = 1;
        public const int PayloadBytes = 14;

        public static bool TryEncode(MovementInput value, Span<byte> output)
        {
            // output 크기 검증 부분은 생략했습니다.

            global::Pstk.Packet.TkPacketCodecSupport.WriteUInt16LittleEndian(
                output,
                0,
                PayloadVersion);
            global::Pstk.Packet.TkPacketCodecSupport.WriteUInt32LittleEndian(
                output,
                2,
                value.controlledEntityGeneration);
            global::Pstk.Packet.TkPacketCodecSupport.WriteUInt32LittleEndian(
                output,
                6,
                value.targetServerTick);
            global::Pstk.Packet.TkPacketCodecSupport.WriteInt16LittleEndian(
                output,
                10,
                value.moveX);
            global::Pstk.Packet.TkPacketCodecSupport.WriteInt16LittleEndian(
                output,
                12,
                value.moveY);
            return true;
        }

        // TryDecode 본문은 생략했습니다.
    }
}MovementInput.generated.cs

스키마의 속성과 생성 결과를 대응시키면 다음과 같습니다.

스키마 속성생성 코드에 반영되는 내용
schemaVersion: 1Packet Tool이 입력 형식을 검증하는 값이며, 패킷의 필드나 상수로 생성하지 않습니다.
name: "MovementInput"두 언어의 타입 이름과 MovementInput.generated.h, MovementInput.generated.cs 파일 이름이 됩니다.
id: 257두 언어에서 PacketId = 257로 생성됩니다.
payloadVersion: 1PayloadVersion = 1로 생성되고, offset 0에 uint16으로 기록됩니다. Decode에서도 이 값과 비교합니다.
필드의 nametype같은 이름의 필드로 생성됩니다. uint32는 C++의 std::uint32_t와 C#의 uint, int16std::int16_tshort로 표현됩니다.
fields의 선언 순서version 뒤의 offset 2, 6, 10, 12와 전체 PayloadBytes = 14를 결정합니다.

Payload version은 Packet Tool이 처음에 삽입하므로 fields에 다시 적지 않습니다. 필드마다 바이트 위치와 변환 코드를 직접 작성하던 부분이 스키마의 타입과 순서에서 함께 생성되는 것을 확인할 수 있습니다.

4.3 CLI 실행과 생성 코드 사용

같은 스키마 디렉터리에서 C++와 C#을 생성하려면 언어별 INI를 준비합니다.

[packet]
language=cpp
input=./schemas
output=./generated/cpp
namespace=GeneratedPacketspacket-cpp.ini
[packet]
language=csharp
input=./schemas
output=./generated/csharp
namespace=GeneratedPacketspacket-csharp.ini

inputoutput의 상대 경로는 INI 파일이 있는 디렉터리를 기준으로 해석합니다. namespace는 생략하거나 비워 둘 수 있으며, 이 경우 namespace wrapper 없이 생성합니다. 여기서는 두 언어에서 함께 사용할 이름으로 GeneratedPackets를 지정했습니다.

CLI가 실행 경로에 준비되어 있다면 다음과 같이 생성할 수 있습니다.

pstk-packet packet-cpp.ini
pstk-packet packet-csharp.ini

이 설정에서는 MovementInput.generated.hMovementInput.generated.cs가 각각의 출력 디렉터리에 만들어집니다. 두 결과는 PacketId, PayloadVersion, PayloadBytes를 공개하고, 필드별 offset은 codec 내부에서 사용합니다.

사용자는 생성된 헤더를 바로 include하여 제공된 타입과 메서드를 사용하게 됩니다.

#include <MovementInput.generated.h>

#include <array>
#include <cstdint>

const GeneratedPackets::MovementInput input{
    0x01020304, 0x11121314, 0x1234, -2
};
std::array<std::uint8_t, GeneratedPackets::MovementInput::PayloadBytes> payload{};

const TkResult result = input.Encode({payload.data(), payload.size()});

C#도 같은 필드 값으로 생성된 codec을 호출할 수 있습니다.

using System;
using GeneratedPackets;

MovementInput input = new MovementInput(
    0x01020304, 0x11121314, 0x1234, -2);
Span<byte> payload = stackalloc byte[MovementInput.PayloadBytes];

bool encoded = MovementInput.TryEncode(input, payload);

전송은 C++의 result == TK_SUCCESS 또는 C#의 encoded == true를 확인한 뒤 진행합니다. 생성된 codec은 payload만 기록하므로, 패킷 ID와 payload를 기존 송신 경로에 전달하고 transport header를 붙이는 일은 소비자가 담당합니다.


5. 생성된 패킷 코드의 활용

PrivateServer에서 생성된 패킷 코드를 활용하는 흐름은 단순하게 볼 수 있습니다. 패킷의 정의는 스키마에서 관리하고, 실행 코드와 테스트는 같은 생성 결과를 사용합니다.

패킷 정의를 스키마에 작성
    -> Packet Tool로 C++·C# 코드 생성
    -> 실행 코드와 테스트에서 생성된 타입과 codec 사용

이 흐름에서 개발자가 관리하는 내용은 다음과 같이 달라집니다.

구분수기로 관리하는 구조생성된 코드를 사용하는 구조
패킷 정의C++와 C#의 DTO와 필드 배치를 각각 작성합니다.공통 스키마에 필드의 이름, 타입과 순서를 작성합니다.
바이트 변환패킷마다 Encode/Decode와 offset을 맞춥니다.Generator가 만든 타입과 codec을 사용합니다.
테스트와 실행 코드직접 작성한 패킷 코드를 사용합니다.동일한 생성 결과를 사용하며, 확인하려는 동작과 입력을 작성합니다.

이렇게 하면 패킷을 추가하거나 필드를 변경할 때 각 언어의 DTO와 codec을 다시 맞추는 작업을 줄일 수 있습니다. 패킷의 구조에서 결정되는 코드는 Packet Tool이 생성하고, 소비자는 그 패킷을 언제 만들고 어떻게 처리할지에 집중할 수 있습니다.

Packet Tool이 맡는 범위는 패킷의 표현과 바이트 변환입니다. 기존 통신 경로와 게임 로직은 소비자의 책임으로 남으며, 생성된 codec을 사용하더라도 게임 규칙과 기존 통신 규약의 호환성을 확인하는 테스트는 필요합니다.


정리하며

Packet Tool을 만든 목적은 새로운 패킷마다 반복하던 데이터 표현과 바이트 변환 코드를 한 곳에서 생성하기 위해서였습니다. 스키마에는 패킷의 구조를 작성하고, 공통 Descriptor에서 C++와 C# 코드를 만들어 동일한 wire 계약을 사용하도록 구성했고, PrivateServer에서는 이 결과를 소비하는 방식으로 패킷별 수기 작업을 줄일 수 있습니다. 서버와 클라이언트는 생성된 타입과 codec을 사용하고, 개발자는 패킷이 게임 안에서 어떤 의미를 가지며 어떤 상황에 허용되는지에 집중할 수 있습니다.

핵심 요약:

참고 자료


이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.


공유하기:

이전 글
[C++] Templates: 기초부터 컴파일 타임 타입 검증까지
다음 글
[C++] 결과 반환과 상세 진단을 위한 공통 파이프라인