C++에서 Protocol Buffers 쓰기: .proto 정의, 스키마 진화와 필드 번호, Arena 할당

C++ 서비스끼리, 혹은 C++과 Python, Go 서비스가 데이터를 주고받을 때 가장 먼저 떠오르는 형식은 JSON입니다. 사람이 읽을 수 있고 어느 언어에서나 다룰 수 있다는 장점이 크지만, 메시지 양이 많아지면 약점도 드러납니다. 숫자를 텍스트로 바꾸고 다시 파싱하는 비용이 들고, 필드 이름이 메시지마다 반복되어 크기가 커지며, 스키마가 없어서 한쪽이 필드 이름이나 타입을 바꾸면 다른 쪽은 실행 중에야 알게 됩니다.

Protocol Buffers(Protobuf)는 .proto 파일에 메시지 구조를 정의하고, protoc 컴파일러로 각 언어의 코드를 생성해 쓰는 방식입니다. 직렬화 결과는 필드 이름 대신 필드 번호와 가변 길이 정수(varint)로 인코딩된 바이너리라서 대체로 JSON보다 작고 파싱이 빠릅니다. 문자열 위주의 메시지라면 크기 차이는 줄어듭니다. 무엇보다 필드 번호 규칙만 지키면 구버전과 신버전 코드가 서로의 메시지를 읽을 수 있어서, 서비스를 한꺼번에 배포하지 않아도 됩니다. 이 글은 C++에서 Protobuf를 설정하고, 메시지를 정의하고, 직렬화하고, 스키마를 안전하게 바꾸는 방법을 다룹니다.

sequenceDiagram
  participant App as C++ 애플리케이션
  participant Msg as Message 객체
  participant Wire as 바이너리 버퍼
  App->>Msg: set_*() 필드 설정
  Msg->>Wire: SerializeToString()
  Wire->>App: 파일/네트워크 전송
  App->>Msg: ParseFromString()
  Msg->>App: 접근자로 필드 읽기

설치와 CMake 설정

패키지 매니저로 설치하는 것이 가장 간단합니다.

vcpkg install protobuf     # 또는
brew install protobuf      # macOS

최근 Protobuf 릴리스(버전 22 이후)는 Abseil에 의존하고, C++ 최소 요구 버전도 올라가는 추세입니다(최신 릴리스는 C++17 필요). 컴파일러가 오래되었다면 사용할 Protobuf 버전의 지원 플랫폼 문서를 먼저 확인합니다. 또한 protoc 버전과 링크하는 libprotobuf 버전이 맞아야 합니다. 생성된 .pb.h는 특정 런타임 버전을 확인하는 코드를 포함하므로, 시스템에 설치된 protoc와 vcpkg의 라이브러리를 섞어 쓰면 컴파일 오류가 납니다.

cmake_minimum_required(VERSION 3.16)
project(protobuf_demo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)

find_package(protobuf CONFIG REQUIRED)

add_executable(protobuf_demo main.cpp)
target_sources(protobuf_demo PRIVATE proto/person.proto)
target_link_libraries(protobuf_demo PRIVATE protobuf::libprotobuf)

# proto/person.proto → ${CMAKE_CURRENT_BINARY_DIR}/person.pb.{h,cc} 생성 후 타깃에 추가
protobuf_generate(
  TARGET protobuf_demo
  IMPORT_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/proto
  PROTOC_OUT_DIR ${CMAKE_CURRENT_BINARY_DIR})
target_include_directories(protobuf_demo PRIVATE ${CMAKE_CURRENT_BINARY_DIR})

protobuf_generate는 Protobuf가 설치한 CMake 설정 파일이 제공하는 함수로, 타깃에 등록된 .proto 파일마다 코드를 생성해 그 타깃의 소스에 추가합니다. 예전에 많이 쓰던 protobuf_generate_cpp는 CMake 내장 FindProtobuf 모듈의 함수이고, 생성 위치가 현재 빌드 디렉터리로 고정되어 있습니다. 수동으로 생성해 보려면 다음처럼 실행합니다.

protoc -I proto --cpp_out=generated proto/person.proto
# generated/person.pb.h  : 메시지 클래스 선언
# generated/person.pb.cc : 직렬화, 파싱, 접근자 구현

undefined reference 같은 링크 오류가 나면 대부분 .pb.cc를 빌드에 넣지 않았거나 protobuf::libprotobuf를 링크하지 않은 경우입니다.


.proto 정의

syntax = "proto3";
package myapp;

message Person {
  string name = 1;
  int32 id = 2;
  optional string email = 3;   // 설정 여부를 구분해야 하는 필드
  repeated string phones = 4;

  message Address {
    string street = 1;
    string city = 2;
    string zip_code = 3;
  }
  Address address = 5;

  enum PhoneType {
    PHONE_TYPE_UNSPECIFIED = 0;
    PHONE_TYPE_MOBILE = 1;
    PHONE_TYPE_HOME = 2;
    PHONE_TYPE_WORK = 3;
  }
  message PhoneNumber {
    string number = 1;
    PhoneType type = 2;
  }
  repeated PhoneNumber phone_numbers = 6;
}

message Event {
  string event_id = 1;
  int64 timestamp = 2;
  oneof payload {           // 셋 중 하나만 설정됨
    string text_message = 3;
    bytes binary_data = 4;
    int32 numeric_value = 5;
  }
}

message Config {
  map<string, string> env_vars = 1;
  map<int32, string> error_codes = 2;
}

각 필드 뒤의 숫자가 필드 번호이며, 바이너리에는 이 번호가 기록됩니다. 번호와 와이어 타입을 합친 태그는 varint로 인코딩되므로, 1부터 15까지는 1바이트, 16부터 2047까지는 2바이트를 차지합니다. 메시지마다 거의 항상 들어가는 필드나 repeated 필드의 원소처럼 자주 등장하는 필드에 작은 번호를 주면 크기를 조금 줄일 수 있습니다. 19000~19999는 Protobuf 구현이 예약한 번호라서 쓸 수 없습니다. 같은 메시지 안에서 번호를 중복으로 쓰면 protoc가 코드 생성 단계에서 오류를 내므로 런타임까지 가지 않습니다.

proto3의 일반 스칼라 필드는 기본값(문자열은 빈 문자열, 숫자는 0)과 “설정하지 않음”을 구분하지 않습니다. 0이나 빈 문자열은 직렬화하지 않고, 받는 쪽에서는 기본값으로 보입니다. “값이 0인 것”과 “값이 오지 않은 것”을 구분해야 하는 필드에는 위의 email처럼 optional을 붙입니다(protoc 3.15 이상). 그러면 has_email()과 clear_email()이 생성됩니다. 메시지 타입 필드(address)에는 optional 없이도 항상 has_address()가 있습니다.

enum의 첫 값은 반드시 0이어야 하고, 이 값이 기본값이 됩니다. UNSPECIFIED라는 의미의 값을 0으로 두는 관례는 “설정하지 않음”과 실제 의미 있는 값을 구분하기 위한 것입니다.


직렬화와 파싱

#include "person.pb.h"
#include <iostream>
#include <string>

int main() {
    myapp::Person person;
    person.set_name("홍길동");
    person.set_id(12345);
    person.set_email("[email protected]");
    person.add_phones("010-1234-5678");

    auto* addr = person.mutable_address();      // 없으면 생성해서 포인터 반환
    addr->set_city("서울");

    auto* phone = person.add_phone_numbers();
    phone->set_number("02-333-4444");
    phone->set_type(myapp::Person::PHONE_TYPE_WORK);

    std::string bytes;
    if (!person.SerializeToString(&bytes)) {
        std::cerr << "직렬화 실패\n";
        return 1;
    }

    myapp::Person parsed;
    if (!parsed.ParseFromString(bytes)) {
        std::cerr << "파싱 실패\n";
        return 1;
    }
    std::cout << parsed.name() << " (" << parsed.id() << ")\n";
    if (parsed.has_email()) std::cout << "email: " << parsed.email() << "\n";
    if (parsed.has_address()) std::cout << "city: " << parsed.address().city() << "\n";
    for (const auto& p : parsed.phone_numbers()) {
        std::cout << p.number() << " type=" << myapp::Person::PhoneType_Name(p.type()) << "\n";
    }
}

접근자 규칙은 단순합니다. name()은 const 참조로 읽기, set_name()은 값 설정, mutable_address()는 수정 가능한 포인터를 반환합니다. 하위 메시지를 읽기만 할 때 mutable_*를 호출하면 안 됩니다. 그 호출만으로 하위 메시지가 생성되어 has_address()가 true가 되고, 직렬화 결과에도 빈 메시지가 들어갑니다. repeated 필드는 add_*()로 추가하고, 범위 기반 for로 순회하거나 phones(i), phones_size()로 접근합니다.

ParseFromString이 false를 반환하는 경우는 데이터가 잘렸거나 손상된 경우, 다른 메시지 타입의 바이트이거나 JSON 텍스트 같은 엉뚱한 데이터를 넣은 경우, 그리고 proto2의 required 필드가 빠진 경우입니다. 반대로 다른 메시지 타입의 바이트라도 와이어 형식상 올바르면 파싱이 성공할 수 있다는 점에 주의해야 합니다. Protobuf 바이너리에는 메시지 타입 정보가 없기 때문에, 어떤 타입인지는 주고받는 쪽이 약속해야 합니다.

oneof와 map

void handle(const myapp::Event& event) {
    switch (event.payload_case()) {
        case myapp::Event::kTextMessage:
            std::cout << "text: " << event.text_message() << "\n";
            break;
        case myapp::Event::kBinaryData:
            std::cout << "binary: " << event.binary_data().size() << " bytes\n";
            break;
        case myapp::Event::kNumericValue:
            std::cout << "number: " << event.numeric_value() << "\n";
            break;
        case myapp::Event::PAYLOAD_NOT_SET:
            break;
    }
}

void fill_config(myapp::Config& config) {
    (*config.mutable_env_vars())["HOME"] = "/home/user";
    (*config.mutable_error_codes())[404] = "Not Found";
    for (const auto& [key, value] : config.env_vars()) {
        std::cout << key << "=" << value << "\n";
    }
}

oneof의 한 필드를 설정하면 같은 oneof의 다른 필드는 자동으로 지워집니다. 그래서 set_text_message() 뒤에 set_binary_data()를 호출하면 텍스트는 사라집니다. 이미 얻어 둔 mutable_* 포인터가 있었다면 그 포인터는 해제된 객체를 가리키게 되므로, oneof 필드를 바꾼 뒤에는 이전 포인터를 쓰면 안 됩니다. map 필드의 순회 순서는 정해져 있지 않으며, 직렬화된 바이트의 순서도 실행마다 같다는 보장이 없습니다. 직렬화 결과를 해시하거나 캐시 키로 쓰려면 이 점을 고려해야 합니다(직렬화 자체가 결정적이라는 보장이 없습니다).


파일과 스트림에 쓰기

메시지 하나

#include "person.pb.h"
#include <fstream>

bool save(const myapp::Person& person, const std::string& path) {
    std::ofstream ofs(path, std::ios::binary | std::ios::trunc);
    return ofs && person.SerializeToOstream(&ofs);
}

bool load(const std::string& path, myapp::Person* person) {
    std::ifstream ifs(path, std::ios::binary);
    return ifs && person->ParseFromIstream(&ifs);
}

ParseFromIstream은 스트림 끝까지를 메시지 하나로 읽습니다. Protobuf 바이너리에는 메시지의 끝을 나타내는 표시가 없기 때문에, 한 파일이나 한 TCP 스트림에 메시지를 여러 개 이어 쓰면 경계를 알 수 없습니다.

여러 메시지: length-delimited 형식

여러 메시지를 이어 쓰려면 각 메시지 앞에 바이트 길이를 varint로 붙이는 것이 표준적인 방법입니다. Java의 writeDelimitedTo/parseDelimitedFrom과 같은 형식이고, C++에서는 delimited_message_util.h가 같은 기능을 제공합니다.

#include "person.pb.h"
#include <google/protobuf/io/zero_copy_stream_impl.h>
#include <google/protobuf/util/delimited_message_util.h>
#include <fstream>
#include <iostream>
#include <vector>

using google::protobuf::io::IstreamInputStream;
using google::protobuf::util::ParseDelimitedFromZeroCopyStream;
using google::protobuf::util::SerializeDelimitedToOstream;

bool write_all(const std::vector<myapp::Person>& people, const std::string& path) {
    std::ofstream ofs(path, std::ios::binary);
    for (const auto& p : people) {
        if (!SerializeDelimitedToOstream(p, &ofs)) return false;
    }
    return static_cast<bool>(ofs);
}

void read_all(const std::string& path) {
    std::ifstream ifs(path, std::ios::binary);
    IstreamInputStream input(&ifs);
    myapp::Person person;
    bool clean_eof = false;
    while (ParseDelimitedFromZeroCopyStream(&person, &input, &clean_eof)) {
        std::cout << person.name() << "\n";   // 한 번에 하나씩 처리
    }
    if (!clean_eof) {
        std::cerr << "마지막 메시지가 잘렸거나 손상됨\n";
    }
}

ParseDelimitedFromZeroCopyStream은 파싱 전에 메시지를 비우므로 같은 객체를 재사용해도 됩니다. clean_eof는 메시지 경계에서 정확히 끝났는지를 알려 주므로, 쓰는 도중 프로세스가 죽어 마지막 레코드가 잘린 경우를 구분할 수 있습니다. 이 방식은 파일 전체를 메모리에 올리지 않고 한 건씩 처리하므로, 수십만 건을 repeated 필드 하나에 담은 거대한 메시지를 한 번에 파싱하는 것보다 메모리 사용량이 훨씬 작습니다.

std::ofstream이나 std::ifstream을 google::protobuf::io::FileOutputStream에 넘기면 안 됩니다. FileOutputStream/FileInputStream은 POSIX 파일 디스크립터(int)를 받는 클래스이고, C++ 스트림용 어댑터는 OstreamOutputStream/IstreamInputStream입니다.


스키마 진화

Protobuf가 호환성을 지켜 주는 원리는 단순합니다. 파서는 모르는 번호의 필드를 만나면 건너뛰면서 unknown field로 보관하고, 기대한 필드가 없으면 기본값을 씁니다. 따라서 다음 규칙만 지키면 구버전과 신버전이 서로의 메시지를 읽을 수 있습니다.

변경안전한가설명
새 필드 추가안전구버전은 새 필드를 무시(보관), 신버전은 구 데이터에서 기본값을 봄
필드 삭제번호를 reserved로 막으면 안전삭제된 번호의 데이터는 unknown field로 취급됨
필드 이름 변경바이너리는 안전바이너리에는 이름이 없음. 단, JSON 매핑이나 리플렉션으로 이름을 쓰는 곳은 깨짐
필드 번호 변경위험사실상 삭제 후 새 필드 추가. 기존 데이터를 못 읽음
타입 변경대부분 위험int32/int64/uint32/uint64/bool끼리 등 일부만 와이어 호환되며, 범위를 넘는 값은 잘림
번호 재사용위험옛 데이터가 다른 의미의 필드로 해석됨
message Config {
  reserved 2, 5, 9 to 11;
  reserved "old_field", "deprecated_field";
  string name = 1;
  int32 new_field = 3;
}

reserved로 막아 두면 나중에 누군가 그 번호나 이름을 다시 쓰려 할 때 protoc가 오류를 냅니다. 필드를 지울 때는 항상 이렇게 남겨 두는 것이 좋습니다.

구버전 코드가 신버전 메시지를 파싱한 뒤 다시 직렬화하면, unknown field로 보관했던 새 필드도 함께 써 줍니다(proto3는 3.5 버전부터 unknown field를 보존합니다). 중간에서 메시지를 받아 일부만 고쳐 전달하는 프록시가 구버전이어도 새 필드가 사라지지 않는다는 뜻입니다.

메시지 안에 schema_version 같은 필드를 따로 두고 버전마다 분기하는 방식은 Protobuf에서는 대개 필요하지 않습니다. 필드 추가와 삭제 규칙만으로 호환성이 유지되기 때문입니다. 메시지 의미 자체가 바뀌어서 호환을 포기해야 할 때는 새 메시지 타입이나 새 패키지 이름(myapp.v2)을 만드는 편이 명확합니다.


성능

Arena 할당

일반 메시지는 하위 메시지, 문자열, repeated 필드마다 힙 할당을 합니다. 하위 메시지가 많은 메시지를 대량으로 파싱하면 이 할당과 해제가 직렬화 자체보다 더 큰 비용이 되기도 합니다. Arena는 큰 메모리 블록을 미리 잡아 두고 메시지와 그 하위 객체를 그 안에 연속으로 배치한 뒤, Arena가 파괴될 때 한꺼번에 해제합니다.

#include <google/protobuf/arena.h>

void handle_request(const std::string& bytes) {
    google::protobuf::Arena arena;
    // 버전에 따라 Arena::Create<T> 또는 (구버전) Arena::CreateMessage<T>
    auto* person = google::protobuf::Arena::Create<myapp::Person>(&arena);
    if (!person->ParseFromString(bytes)) return;

    // 하위 메시지도 자동으로 같은 Arena에 할당됨
    person->mutable_address()->set_city("Seoul");
    process(*person);
}   // arena 파괴 시 person과 모든 하위 객체를 한꺼번에 해제

Arena에 만든 메시지는 delete하면 안 되고, Arena보다 오래 쓰면 안 됩니다. Arena 메시지를 Arena 밖의 객체에 저장해야 한다면 복사해야 합니다. 서로 다른 Arena(또는 힙)에 있는 메시지 사이에서 Swap이나 set_allocated_*를 쓰면 포인터 이동이 아니라 복사가 일어나므로, 소유권을 넘긴다고 생각하고 짠 코드가 기대만큼 빠르지 않을 수 있습니다. 하위 메시지는 위처럼 mutable_*()로 만드는 것이 가장 간단하고, 부모와 같은 Arena에 자동으로 할당됩니다. Arena는 요청 하나를 처리하는 동안만 사는 메시지처럼 수명이 명확할 때 가장 잘 맞습니다.

메시지와 버퍼 재사용

Arena를 쓰지 않더라도 루프 밖에서 만든 메시지를 Clear()해 가며 재사용하면, 문자열과 repeated 필드가 이미 확보한 메모리를 다시 쓰므로 할당이 줄어듭니다. 직렬화 결과를 담을 std::string도 마찬가지로 재사용할 수 있습니다.

myapp::Person person;
std::string buffer;
for (const auto& row : rows) {
    person.Clear();
    person.set_id(row.id);
    person.set_name(row.name);
    buffer.clear();
    person.SerializeToString(&buffer);
    send(buffer);
}

다만 아주 큰 메시지를 한 번 파싱한 객체를 재사용하면 그 크기만큼의 메모리가 계속 남아 있으므로, 메시지 크기 편차가 크다면 주기적으로 새 객체로 바꾸는 편이 낫습니다.

문자열 필드 setter는 rvalue를 받으므로 person.set_name(std::move(large))처럼 넘기면 복사를 피할 수 있습니다. Descriptor와 Reflection을 통한 동적 필드 접근은 범용 도구를 만들 때 유용하지만 생성된 접근자보다 느리므로, 반복 경로에서는 생성된 접근자를 씁니다.


신뢰할 수 없는 입력 다루기

외부에서 받은 바이트를 파싱할 때는 크기와 중첩 깊이를 제한해야 합니다. 큰 길이 값을 가진 악의적인 메시지는 많은 메모리 할당을 유발할 수 있고, 하위 메시지를 깊게 중첩한 메시지는 재귀 파싱으로 스택을 소모합니다.

#include <google/protobuf/io/coded_stream.h>

bool parse_untrusted(const std::string& data, myapp::Person* out) {
    constexpr std::size_t kMaxBytes = 4 * 1024 * 1024;   // 서비스에 맞게 정함
    if (data.size() > kMaxBytes) return false;

    google::protobuf::io::CodedInputStream input(
        reinterpret_cast<const std::uint8_t*>(data.data()), static_cast<int>(data.size()));
    input.SetRecursionLimit(32);   // 기본값은 100
    return out->ParseFromCodedStream(&input) && input.ConsumedEntireMessage();
}

크기 제한은 파싱 전에 검사해야 의미가 있습니다. 스트림에서 읽는 경우라면 길이 prefix를 읽은 직후, 그 길이만큼 버퍼를 할당하기 전에 검사합니다. 재귀 한도는 기본값이 100이며, 정상 메시지가 이보다 깊이 중첩되어 recursion limit exceeded로 실패한다면 한도를 올려야 하지만 외부 입력이라면 낮게 두는 편이 안전합니다.

다음 글: C++ 시리즈 목차 이전 글: C++ gRPC 성능 다루기


참고 자료


같이 보면 좋은 글