C++ 직렬화 방식 비교: nlohmann·simdjson·Glaze JSON, 바이너리, Protobuf, C++26 리플렉션

들어가며: 구조체가 늘수록 직렬화 코드도 늘어난다

C++에는 C++23까지 Java나 C#처럼 표준 리플렉션이 없어서, 직렬화·역직렬화 코드를 타입마다 손으로 작성해야 했습니다. User, Order, Product처럼 구조체가 10개, 50개로 늘면 to_json, from_json, serialize, deserialize 함수도 그만큼 늘어납니다. 더 큰 문제는 멤버를 추가하고 직렬화 함수 수정을 잊어도 컴파일이 통과한다는 점입니다. 그 필드는 조용히 저장되지 않고, 버그는 런타임에야 드러납니다.

flowchart LR
  subgraph problem[문제 상황]
    P1[User 구조체] --> P2[to_json 수동 작성]
    P3[Order 구조체] --> P4[to_json 수동 작성]
    P5[Product 구조체] --> P6[to_json 수동 작성]
    P2 --> P7[멤버 추가 시 누락 위험]
    P4 --> P7
    P6 --> P7
  end
  subgraph solution[해결 방향]
    S1[포맷 선택] --> S2[JSON vs 바이너리 vs Protobuf]
    S2 --> S3[자동 직렬화]
    S3 --> S4[리플렉션·매크로·라이브러리]
  end

직렬화에는 코드 양 말고도 문제가 따라옵니다. 구조체를 메모리 그대로 덤프한 세이브 파일은 엔디안과 패딩이 다른 플랫폼에서 깨집니다. 서버에 필드를 추가하면 구버전 클라이언트가 새 데이터를 읽을 수 있어야 합니다. 수 MB짜리 JSON 응답을 파싱하는 시간이 눈에 띄기 시작하면 다른 파서나 바이너리 포맷을 검토하게 됩니다.

이 글은 포맷 선택(JSON·직접 설계한 바이너리·Protobuf·MessagePack)에서 시작해, 손으로 짜는 직렬화 코드를 매크로·라이브러리·C++26 리플렉션으로 줄여 나가는 방법, 그리고 실제로 파일이 깨지거나 로드가 실패하는 원인을 차례로 다룹니다.


JSON·바이너리·Protobuf·MessagePack 포맷 비교

포맷크기속도사람이 읽기스키마버전 호환주 용도
JSON큼느린 편쉬움없음키 이름 기반으로 유연REST API, 설정, 디버깅
직접 설계한 바이너리작음빠름불가코드가 곧 스키마직접 관리게임 세이브, 내부 프로토콜
Protobuf작음빠름불가.proto필드 번호 기반으로 좋음네트워크, 마이크로서비스
MessagePack중간빠름불가없음JSON과 비슷캐시, 실시간 통신
flowchart TB
  subgraph json[JSON]
    J1[텍스트] --> J2[디버깅 용이]
    J2 --> J3[다른 언어와 호환]
  end
  subgraph bin[바이너리]
    B1[필드 단위] --> B2[최소 크기]
    B2 --> B3[커스텀 규약]
  end
  subgraph pb[Protobuf]
    P1[.proto 스키마] --> P2[자동 코드 생성]
    P2 --> P3[버전 호환]
  end

대체로 사람이 열어 보고 고치는 설정 파일과 외부에 공개하는 REST API에는 JSON을, 여러 언어로 된 내부 서비스 간 통신에는 Protobuf를 씁니다. 게임 세이브처럼 한 프로그램이 쓰고 읽는 파일은 필드 단위로 직접 설계한 바이너리 포맷이 단순하고, Redis 캐시처럼 스키마 없이 JSON과 같은 데이터 모델을 더 작게 담고 싶을 때는 MessagePack이 맞습니다.


nlohmann/json으로 JSON 직렬화

기본 사용

#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
using json = nlohmann::json;

struct User {
    int id;
    std::string name;
    std::string email;
};

// to_json: User → JSON
void to_json(json& j, const User& u) {
    j = json{
        {"id", u.id},
        {"name", u.name},
        {"email", u.email}
    };
}
// from_json: JSON → User
void from_json(const json& j, User& u) {
    j.at("id").get_to(u.id);
    j.at("name").get_to(u.name);
    j.at("email").get_to(u.email);
}

int main() {
    User u{1, "홍길동", "hong@example.com"};
    json j = u;
    std::string str = j.dump(2);  // 들여쓰기 2칸
    std::ofstream file("user.json");
    file << str;
    User loaded = json::parse(str).get<User>();
}

nlohmann/json이 json j = u; 한 줄로 변환을 해내는 것은 ADL(인자 기반 이름 탐색) 덕분입니다. 라이브러리는 User 타입과 같은 네임스페이스에 있는 to_json/from_json을 찾아 호출합니다. 그래서 구조체는 app 네임스페이스에 두고 to_json은 전역에 정의하면 ADL이 찾지 못해, could not find to_json() method in T's namespace 같은 긴 템플릿 에러가 납니다. 처음 이 라이브러리를 쓸 때 가장 흔히 막히는 지점입니다. 또 at()은 키가 없으면 [json.exception.out_of_range.403] key 'email' not found 예외를 던지고, operator[]는 const가 아닌 객체에서 없는 키를 조용히 null로 추가하므로, 읽기에는 at()이나 value()를 쓰는 편이 안전합니다.

출력 예시(user.json):

{
  "id": 1,
  "name": "홍길동",
  "email": "hong@example.com"
}

중첩 구조체와 컨테이너

struct Address {
    std::string city;
    std::string zip;
};
struct Order {
    int orderId;
    std::string productName;
    int quantity;
    Address shippingAddress;
};
void to_json(json& j, const Address& a) {
    j = json{{"city", a.city}, {"zip", a.zip}};
}
void from_json(const json& j, Address& a) {
    j.at("city").get_to(a.city);
    j.at("zip").get_to(a.zip);
}
void to_json(json& j, const Order& o) {
    j = json{
        {"orderId", o.orderId},
        {"productName", o.productName},
        {"quantity", o.quantity},
        {"shippingAddress", o.shippingAddress}  // Address의 to_json이 호출됨
    };
}
void from_json(const json& j, Order& o) {
    j.at("orderId").get_to(o.orderId);
    j.at("productName").get_to(o.productName);
    j.at("quantity").get_to(o.quantity);
    j.at("shippingAddress").get_to(o.shippingAddress);
}

요소 타입에 to_json/from_json이 있으면 std::vector<User>, std::map<std::string, Order> 같은 표준 컨테이너는 라이브러리가 자동으로 처리합니다. std::vector<User>용 함수를 따로 정의할 필요가 없고, 직접 정의하면 라이브러리의 기본 변환과 겹쳐 오버로드 모호성 에러가 날 수 있습니다.

std::vector<User> users = {{1, "A", "a@x"}, {2, "B", "b@x"}};
json arr = users;                              // JSON 배열
auto back = arr.get<std::vector<User>>();      // 역변환

게임 세이브용 바이너리 직렬화

GameSave의 저장 파일 16진수 덤프와 정상·잘린 파일·손상된 길이 로드 결과, 패딩 구조체 크기, byteswap 결과

그림: 실제 실행 결과 — 리틀 엔디안으로 쓴 MAGIC은 파일의 처음 4바이트가 45 56 41 53, 즉 “EVAS”다. 같은 코드로 저장한 48바이트 파일은 정상 로드되고, 30바이트로 잘린 파일과 이름 길이를 0xFFFFFFFF로 바꾼 파일은 로드가 거부된다. 필드 합이 6바이트인 구조체를 통째로 덤프하면 패딩 때문에 12바이트가 된다. (환경: macOS 27.0.1 · Apple clang 21.0.0 · -std=c++23 · little endian)

필드 단위 규약

flowchart LR
  subgraph bad[잘못된 방식]
    B1[구조체 메모리 덤프] --> B2[패딩·엔디안 포함]
    B2 --> B3[다른 플랫폼에서 깨짐]
  end
  subgraph good[올바른 방식]
    G1[버전·매직 넘버] --> G2[필드 단위 직렬화]
    G2 --> G3[길이+데이터 가변 필드]
  end

세이브 파일 구조

#include <cstdint>
#include <fstream>
#include <string>
#include <vector>

// 공통 헬퍼: 문자열은 길이(uint32_t) + 바이트
void writeString(std::ostream& out, const std::string& str) {
    uint32_t len = static_cast<uint32_t>(str.size());
    out.write(reinterpret_cast<const char*>(&len), sizeof(len));
    out.write(str.data(), len);
}
bool readString(std::istream& in, std::string& str, uint32_t maxLen) {
    uint32_t len = 0;
    if (!in.read(reinterpret_cast<char*>(&len), sizeof(len))) return false;
    if (len > maxLen) return false;  // 손상되거나 조작된 길이 거부
    str.resize(len);
    return static_cast<bool>(in.read(str.data(), len));
}

struct GameSave {
    static constexpr uint32_t MAGIC = 0x53415645;  // 리틀 엔디안으로 쓰면 파일에 "EVAS" 순서로 저장됨
    static constexpr uint32_t VERSION = 1;
    static constexpr uint32_t MAX_NAME = 64;
    static constexpr uint32_t MAX_ITEMS = 10'000;

    uint32_t level = 1;
    float health = 100.0f;
    float positionX = 0, positionY = 0;
    std::string playerName;
    std::vector<uint32_t> inventory;

    bool save(const std::string& path) const {
        std::ofstream file(path, std::ios::binary);
        if (!file) return false;
        file.write(reinterpret_cast<const char*>(&MAGIC), sizeof(MAGIC));
        file.write(reinterpret_cast<const char*>(&VERSION), sizeof(VERSION));
        file.write(reinterpret_cast<const char*>(&level), sizeof(level));
        file.write(reinterpret_cast<const char*>(&health), sizeof(health));
        file.write(reinterpret_cast<const char*>(&positionX), sizeof(positionX));
        file.write(reinterpret_cast<const char*>(&positionY), sizeof(positionY));
        writeString(file, playerName);
        uint32_t invCount = static_cast<uint32_t>(inventory.size());
        file.write(reinterpret_cast<const char*>(&invCount), sizeof(invCount));
        file.write(reinterpret_cast<const char*>(inventory.data()),
                   invCount * sizeof(uint32_t));
        return file.good();
    }

    bool load(const std::string& path) {
        std::ifstream file(path, std::ios::binary);
        if (!file) return false;
        uint32_t magic = 0, version = 0;
        file.read(reinterpret_cast<char*>(&magic), sizeof(magic));
        file.read(reinterpret_cast<char*>(&version), sizeof(version));
        if (!file || magic != MAGIC) return false;
        if (version > VERSION) return false;  // 더 새 버전이 쓴 파일
        file.read(reinterpret_cast<char*>(&level), sizeof(level));
        file.read(reinterpret_cast<char*>(&health), sizeof(health));
        file.read(reinterpret_cast<char*>(&positionX), sizeof(positionX));
        file.read(reinterpret_cast<char*>(&positionY), sizeof(positionY));
        if (!file || !readString(file, playerName, MAX_NAME)) return false;
        uint32_t invCount = 0;
        if (!file.read(reinterpret_cast<char*>(&invCount), sizeof(invCount))) return false;
        if (invCount > MAX_ITEMS) return false;
        inventory.resize(invCount);
        file.read(reinterpret_cast<char*>(inventory.data()),
                  invCount * sizeof(uint32_t));
        return static_cast<bool>(file);
    }
};

이 세이브 코드는 필드를 하나씩 쓰기 때문에 구조체 패딩 문제는 피합니다. 길이 필드에 상한을 둔 이유는 파일에서 읽은 길이를 그대로 믿고 resize하면 안 되기 때문입니다. 파일이 손상되어 길이가 0xFFFFFFFF가 되면 4GB 할당을 시도하다 std::bad_alloc으로 죽거나 메모리를 크게 소모합니다. 세이브 파일은 사용자가 얼마든지 편집할 수 있는 입력이므로, 상한을 넘으면 로드를 실패로 처리해야 합니다. 또 읽기 도중 스트림 상태를 확인해야 파일이 중간에 잘린 경우를 쓰레기 값으로 진행하지 않고 걸러낼 수 있습니다. 실패 원인을 로그에 남기려면 bool 대신 실패 지점을 알려 주는 에러 값을 돌려주는 편이 디버깅에 유리합니다.

inventory.data()를 통째로 쓰고 읽는 방식은 요소가 uint32_t처럼 고정 크기·trivially copyable 타입이고 엔디안이 같을 때만 성립합니다. std::string이나 포인터를 가진 타입의 벡터는 요소마다 직렬화해야 합니다.

엔디안 처리

#include <bit>
#include <cstdint>

// C++23: std::byteswap, C++20: std::endian
uint32_t toLittleEndian(uint32_t val) {
    if constexpr (std::endian::native == std::endian::little) {
        return val;
    } else {
        return std::byteswap(val);
    }
}
uint32_t fromLittleEndian(uint32_t val) {
    return toLittleEndian(val);  // 바이트 뒤집기는 자기 자신이 역연산
}

C++20의 std::endian으로 플랫폼의 바이트 순서를 판별하고, C++23의 std::byteswap으로 순서를 뒤집습니다. C++23을 쓸 수 없다면 GCC·Clang의 __builtin_bswap32나 MSVC의 _byteswap_ulong을 씁니다. 파일 포맷의 엔디안은 리틀 엔디안으로 정하는 경우가 많은데, x86과 대부분의 ARM 환경이 리틀 엔디안이라 실제로는 변환 비용이 거의 들지 않기 때문입니다. 네트워크 프로토콜 관례를 따라야 한다면 빅 엔디안(htonl/ntohl)을 씁니다. float는 std::bit_cast<uint32_t>로 비트를 꺼내 같은 방식으로 변환합니다.


Protocol Buffers 통합

.proto 스키마 정의

syntax = "proto3";
message User {
    int32 id = 1;
    string name = 2;
    string email = 3;
}
message Order {
    int32 order_id = 1;
    string product_name = 2;
    int32 quantity = 3;
}

C++ 코드 생성 및 사용

#include "user.pb.h"
#include <fstream>
#include <iostream>

int main() {
    User user;
    user.set_id(1);
    user.set_name("홍길동");
    user.set_email("hong@example.com");

    std::string serialized;
    if (!user.SerializeToString(&serialized)) return 1;

    std::ofstream file("user.pb", std::ios::binary);
    file.write(serialized.data(), serialized.size());

    User loaded;
    if (!loaded.ParseFromString(serialized)) return 1;
    std::cout << "ID: " << loaded.id() << ", Name: " << loaded.name() << "\n";
}

protoc --cpp_out=. user.proto가 user.pb.h/user.pb.cc를 생성합니다. proto3의 string 필드는 UTF-8이어야 하므로, 위 예제의 한글 리터럴도 소스가 UTF-8로 컴파일되어야 합니다(아래 UTF-8 절 참고). 그렇지 않으면 직렬화·파싱 시 UTF-8 검증 오류가 기록되거나 파싱이 실패합니다.

버전 호환 (필드 추가)

// v1
message User {
    int32 id = 1;
    string name = 2;
}
// v2: avatar_url 추가
message User {
    int32 id = 1;
    string name = 2;
    string avatar_url = 3;  // 새 필드
}

필드 번호를 바꾸지 않고 새 필드만 추가하면, v1 파서는 모르는 필드 3을 건너뛰고(unknown field로 보존하고) v2 파서는 v1 데이터에서 avatar_url을 빈 문자열로 읽습니다.

Protobuf의 바이너리 호환성은 필드 이름이 아니라 번호에 묶여 있습니다. 와이어 포맷에는 이름 없이 “번호 + 와이어 타입 + 값”만 들어가므로, 필드 번호를 바꾸거나 삭제한 필드의 번호를 다른 필드에 재사용하면 구버전 데이터가 엉뚱한 필드로 해석됩니다. 필드를 지울 때 reserved 3;처럼 번호를 예약해 두는 이유가 이것입니다. 이름 변경은 바이너리 포맷에서는 안전하지만, JSON 매핑이나 텍스트 포맷은 이름을 키로 쓰므로 그쪽을 함께 쓰면 깨집니다. 또 proto3에서 스칼라 필드는 기본값(0, 빈 문자열)이면 아예 직렬화되지 않아서, “값이 0인지, 보내지 않았는지”를 구분하려면 optional 키워드를 붙여 존재 여부(has_xxx())를 추적해야 합니다. 이 차이를 모르면 “재고 0”과 “재고 정보 없음”을 같은 값으로 처리하게 됩니다.


매크로·cereal로 직렬화 코드 줄이기

nlohmann/json 매크로

직접 매크로를 짜기보다, nlohmann/json이 제공하는 매크로를 쓰는 편이 간단합니다. 멤버 이름을 한 번만 나열하면 to_json/from_json이 모두 생성됩니다.

#include <nlohmann/json.hpp>

struct User {
    int id;
    std::string name;
    std::string email;
};
// 구조체와 같은 네임스페이스에 둘 것 (ADL)
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(User, id, name, email)

// 키가 빠진 JSON도 허용하려면 기본값을 쓰는 변형을 사용 (기본 생성 가능해야 함)
// NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(User, id, name, email)

매크로 방식의 한계는 여전히 멤버 목록을 손으로 적는다는 점입니다. 구조체에 avatarUrl을 추가하고 매크로 인자에 넣는 것을 잊으면 컴파일은 통과하고 그 필드만 조용히 저장되지 않습니다. 이 “두 곳을 동시에 고쳐야 하는” 문제를 없애는 것이 아래 리플렉션 기반 접근입니다.

cereal

#include <cereal/archives/json.hpp>
#include <cereal/types/string.hpp>
#include <cereal/types/vector.hpp>
#include <fstream>

struct User {
    int id;
    std::string name;
    std::string email;
    template <class Archive>
    void serialize(Archive& ar) {
        ar(CEREAL_NVP(id), CEREAL_NVP(name), CEREAL_NVP(email));
    }
};

int main() {
    User u{1, "홍길동", "hong@example.com"};
    {
        std::ofstream file("user.json");
        cereal::JSONOutputArchive ar(file);
        ar(u);
    }  // 아카이브 소멸 시점에 JSON이 마무리되므로 스코프를 닫아야 함
    {
        User loaded;
        std::ifstream file("user.json");
        cereal::JSONInputArchive ar(file);
        ar(loaded);
    }
}

cereal은 하나의 serialize 함수로 JSON·XML·바이너리 아카이브를 모두 지원합니다. CEREAL_NVP를 쓰지 않으면 JSON 키가 value0, value1처럼 자동 생성되므로, 사람이 읽을 JSON이라면 이름을 붙여 주는 편이 낫습니다.

Boost.Hana

#include <boost/hana/define_struct.hpp>
#include <boost/hana/for_each.hpp>
struct User {
    BOOST_HANA_DEFINE_STRUCT(User,
        (int, id),
        (std::string, name),
        (std::string, email)
    );
};
// hana::for_each로 멤버 이름과 값을 순회해 직렬화 함수를 한 번만 작성

멤버 선언 자체를 매크로 안으로 옮기므로 “목록을 두 곳에 적는” 문제는 사라지지만, 구조체 선언 문법이 바뀌고 컴파일 시간이 늘어납니다.

C++26 정적 리플렉션과 Glaze

C++26에는 정적 리플렉션(P2996)과 확장 문(template for, P1306)이 채택되었습니다. ^^T로 타입의 메타 정보를 얻고, 비정적 데이터 멤버를 컴파일 타임에 순회하면서 obj.[:m:]로 멤버에 접근하는 방식입니다. 멤버 목록을 어디에도 따로 적지 않으므로, 구조체에 필드를 추가하면 직렬화 코드가 자동으로 따라옵니다.

#include <meta>
#include <nlohmann/json.hpp>

template <typename T>
nlohmann::json to_json_reflect(const T& obj) {
    nlohmann::json j;
    constexpr auto ctx = std::meta::access_context::unchecked();
    template for (constexpr auto m :
                  std::define_static_array(std::meta::nonstatic_data_members_of(^^T, ctx))) {
        j[std::string(std::meta::identifier_of(m))] = obj.[:m:];
    }
    return j;
}

이 코드는 채택된 제안서 기준의 모양이며, 컴파일러별 구현 상태와 세부 API 이름은 버전에 따라 다를 수 있으니 사용하는 툴체인 문서를 먼저 확인해야 합니다. 당장 C++20으로 비슷한 효과를 원한다면 Glaze나 reflect-cpp가 대안입니다. 이 라이브러리들은 집합체(aggregate) 구조체에 대해 구조적 바인딩과 컴파일러가 만드는 함수 시그니처 문자열을 이용한 기법으로 멤버 이름과 값을 추출하므로, glz::write_json(user)처럼 매크로 없이 바로 직렬화할 수 있습니다. 대신 생성자가 있거나 private 멤버가 있는 클래스처럼 집합체가 아닌 타입에는 별도의 메타 정보를 적어 줘야 하고, 템플릿이 무거워 컴파일 시간이 늘어나는 점은 감안해야 합니다.

cereal 바이너리 아카이브

#include <cereal/archives/binary.hpp>
#include <cereal/types/string.hpp>
#include <cereal/types/vector.hpp>
#include <fstream>

struct GameState {
    int level;
    float health;
    std::string playerName;
    std::vector<int> inventory;
    template <class Archive>
    void serialize(Archive& ar) {
        ar(level, health, playerName, inventory);
    }
};
void saveBinary(const GameState& state, const std::string& path) {
    std::ofstream file(path, std::ios::binary);
    cereal::BinaryOutputArchive ar(file);
    ar(state);
}
void loadBinary(GameState& state, const std::string& path) {
    std::ifstream file(path, std::ios::binary);
    cereal::BinaryInputArchive ar(file);
    ar(state);
}

cereal의 BinaryOutputArchive는 값을 호스트 엔디안 그대로 기록합니다. 같은 플랫폼에서 저장하고 읽는 캐시나 임시 파일에는 충분하지만, 다른 아키텍처 사이에서 파일을 주고받는다면 PortableBinaryOutputArchive를 써야 합니다. 또 ar(level, health, ...)처럼 순서로만 필드를 기록하므로, 멤버 순서를 바꾸거나 중간에 필드를 끼워 넣으면 이전 파일을 읽을 때 값이 밀립니다. 구조가 바뀔 가능성이 있다면 CEREAL_CLASS_VERSION으로 버전을 붙이고 serialize(Archive& ar, std::uint32_t const version) 오버로드에서 버전별로 분기합니다.

MessagePack

#include <msgpack.hpp>
#include <string>
#include <vector>

struct Item {
    int id;
    std::string name;
    MSGPACK_DEFINE(id, name);  // 배열 [id, name]으로 직렬화
};

int main() {
    std::vector<Item> items = {{1, "A"}, {2, "B"}};
    msgpack::sbuffer sbuf;
    msgpack::pack(sbuf, items);
    msgpack::object_handle handle = msgpack::unpack(sbuf.data(), sbuf.size());
    auto loaded = handle.get().as<std::vector<Item>>();
}

MSGPACK_DEFINE은 멤버를 배열로 직렬화하므로 키 이름이 저장되지 않고 순서에 의존합니다. 키 이름을 포함한 맵으로 저장하려면 MSGPACK_DEFINE_MAP을 씁니다. handle.get()이 돌려주는 msgpack::object는 handle이 가진 메모리를 가리키므로, as<>()로 값을 꺼내기 전에 handle이 먼저 사라지면 안 됩니다.


JSON 설정과 바이너리 세이브를 함께 쓰는 예제

사람이 편집하는 설정은 JSON으로, 프로그램만 읽고 쓰는 세이브는 앞에서 만든 바이너리 GameSave로 나누는 구성입니다.

#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
#include <vector>

struct GameConfig {
    std::string title = "My Game";
    int screenWidth = 1280;
    int screenHeight = 720;
    bool fullscreen = false;
    std::vector<std::string> levels;
};
// 키가 빠진 설정 파일은 위 기본값으로 채움
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(GameConfig,
    title, screenWidth, screenHeight, fullscreen, levels)

int main() {
    GameConfig config;
    if (std::ifstream configFile("config.json"); configFile) {
        config = nlohmann::json::parse(configFile).get<GameConfig>();
    }

    GameSave save;  // 앞 절의 바이너리 세이브
    if (!save.load("save.dat")) {
        save.playerName = "Player";  // 새 게임
        save.save("save.dat");
    }
}

설정 파일 예시(config.json):

{
  "title": "My Game",
  "screenWidth": 1920,
  "screenHeight": 1080,
  "fullscreen": true,
  "levels": ["level1", "level2", "level3"]
}

json::parse는 문법 오류가 있으면 parse_error 예외를 던집니다. 사용자가 직접 편집하는 파일이므로, 실제 프로그램에서는 이 예외를 잡아 기본 설정으로 시작하고 어느 줄이 잘못됐는지 알려 주는 편이 좋습니다.


자주 만나는 로드 실패 원인

”key ‘id’ not found” (JSON 파싱)

JSON에 필드가 없거나 키 이름이 다른 경우입니다(대소문자, id와 ID 등).

// at()은 키가 없으면 예외
u.id = j.at("id").get<int>();
// 기본값을 쓰려면 value()
u.id = j.value("id", 0);
// 또는 contains()로 확인
if (j.contains("id")) {
    j.at("id").get_to(u.id);
}

필수 필드는 at()으로 읽어 예외로 빨리 실패하고, 선택 필드만 value()로 기본값을 주는 식으로 구분하는 편이 좋습니다. 모든 필드에 기본값을 주면 키 이름 오타가 조용히 묻힙니다.

바이너리 파일이 다른 플랫폼에서 깨짐

엔디안, 패딩, 타입 크기 차이가 원인입니다. long은 64비트 Windows에서 4바이트, 64비트 Linux에서 8바이트입니다.

// 잘못된 예: 구조체 직접 덤프 (패딩·엔디안·크기가 그대로 들어감)
file.write(reinterpret_cast<const char*>(&data), sizeof(data));
// 올바른 예: 고정 크기 타입으로 필드 단위
uint32_t level = static_cast<uint32_t>(data.level);
file.write(reinterpret_cast<const char*>(&level), sizeof(level));

문자열·벡터를 길이 없이 저장

길이 없이 데이터만 저장하면 읽을 때 얼마나 읽어야 할지 알 수 없습니다. 문자열은 “길이(uint32_t) + 바이트”, 벡터는 “개수 + 요소들” 순서로 쓰고, 읽을 때는 앞의 세이브 예제처럼 길이에 상한을 둡니다. 벡터를 data()째로 쓰는 것은 요소가 trivially copyable일 때만 가능합니다.

버전 업그레이드 후 기존 파일 로드 실패

새 필드를 무조건 읽도록 바꾸면 구버전 파일에는 그 필드가 없어 실패합니다. 버전 번호로 분기합니다.

uint32_t version = readU32(in);
level = readU32(in);
if (version >= 2) {
    readString(in, avatarUrl, 512);  // v2에서 추가된 필드
}

JSON 파싱 성능

nlohmann/json은 편리하지만 DOM 트리를 만들며 값마다 할당하므로, 큰 문서에서는 RapidJSON이나 simdjson이 대안입니다.

// RapidJSON
#include <rapidjson/document.h>
rapidjson::Document doc;
doc.Parse(jsonStr.c_str());
if (doc.HasParseError()) { /* 처리 */ }

// simdjson On-Demand: 입력 뒤에 SIMDJSON_PADDING 바이트의 여유가 필요
#include <simdjson.h>
simdjson::ondemand::parser parser;
simdjson::padded_string padded(jsonStr);  // 여유 공간을 확보한 복사본
simdjson::ondemand::document doc = parser.iterate(padded);

simdjson은 SIMD 명령으로 구조 문자를 한꺼번에 찾아 파싱 속도를 끌어올린 라이브러리입니다. 입력 버퍼 끝에 패딩이 있어야 하므로, std::string을 바로 넘기면 용량이 부족할 때 INSUFFICIENT_PADDING 오류가 납니다. On-Demand API는 문서를 앞에서부터 한 번 훑으며 필요한 값만 꺼내는 방식이라, 같은 필드를 여러 번 읽거나 순서를 거슬러 접근하는 코드에는 맞지 않습니다.

UTF-8 인코딩 깨짐

// dump() 결과(UTF-8 바이트)를 그대로 기록
std::ofstream file("data.json", std::ios::binary);
file << j.dump(2);
// nlohmann/json은 std::string을 UTF-8로 간주하며,
// 잘못된 UTF-8 바이트가 섞이면 dump()가 type_error.316 예외를 던짐

한글이 깨지는 원인은 대부분 스트림이 아니라 문자열이 처음부터 UTF-8이 아닌 경우입니다. MSVC는 BOM이 없는 소스 파일을 시스템 코드 페이지(한국어 Windows에서는 CP949)로 해석하고 실행 문자 집합도 같은 코드 페이지로 잡으므로, "홍길동" 리터럴이 CP949 바이트로 들어가 dump()에서 [json.exception.type_error.316] invalid UTF-8 byte 예외가 납니다. 컴파일 옵션에 /utf-8을 주면 해결됩니다. u8"..." 리터럴은 C++17까지는 const char[]이라 대안이 되지만, C++20부터는 const char8_t[]가 되어 std::string에 바로 넣을 수 없으므로 /utf-8 쪽이 간단합니다. 예전 코드에서 흔히 보이는 file.imbue(std::locale("en_US.UTF-8"))는 char 스트림의 바이트를 변환하지 않아 효과가 없고, 해당 로캘이 설치되지 않은 Windows나 최소 Docker 이미지에서는 std::runtime_error를 던지므로 쓰지 않는 편이 낫습니다.

순환 참조

Parent가 Child를 가지고 Child가 Parent*를 가지면, 포인터를 따라가는 직렬화는 무한히 반복합니다. 포인터 대신 ID를 저장하고, 로드가 끝난 뒤 ID로 조회해 포인터를 복원합니다.

struct Parent {
    int id;
    std::vector<int> childIds;  // Child* 대신 ID
};
struct Child {
    int id;
    int parentId;  // Parent* 대신 ID
};

cereal은 std::shared_ptr를 처음 만날 때만 내용을 기록하고 이후에는 ID로 참조하므로 공유 포인터 그래프는 자동으로 처리하지만, 원시 포인터는 직렬화하지 않습니다.

파일이 비어 있거나 손상됨

저장 중 크래시, 디스크 풀, 권한 오류로 불완전한 파일이 남는 경우입니다. 임시 파일에 쓴 뒤 교체합니다.

#include <filesystem>
#include <fstream>

bool safeSave(const std::filesystem::path& path, const Data& data) {
    auto tempPath = path;
    tempPath += ".tmp";
    {
        std::ofstream file(tempPath, std::ios::binary);
        if (!file || !serialize(file, data) || !file.flush()) {
            std::filesystem::remove(tempPath);
            return false;
        }
    }  // 여기서 파일이 닫힘
    std::error_code ec;
    std::filesystem::rename(tempPath, path, ec);  // 기존 파일을 교체
    return !ec;
}

임시 파일에 쓴 뒤 이름을 바꾸는 이유는, POSIX에서 rename이 같은 파일 시스템 안에서 원자적이라 “예전 파일 아니면 새 파일” 둘 중 하나만 보이기 때문입니다. 저장 도중 크래시가 나도 반쯤 쓰인 세이브 파일이 남지 않습니다. 다만 두 가지 함정이 있습니다. Windows의 C 런타임 std::rename은 대상 파일이 이미 있으면 실패하므로 두 번째 저장부터 항상 실패합니다. std::filesystem::rename을 쓰면 Windows에서도 기존 파일을 교체합니다(내부적으로 MoveFileExW에 MOVEFILE_REPLACE_EXISTING). 또 flush()나 close()는 데이터를 OS에 넘길 뿐 디스크에 내려갔다는 보장이 없어서, 정전까지 대비하려면 rename 전에 fsync(Windows는 FlushFileBuffers)로 내용을 확정해야 합니다.


포맷별 속도와 크기의 경향

포맷속도 경향크기 경향비고
JSON (nlohmann)느린 편큼DOM 트리를 만들고 값마다 동적 할당
JSON (RapidJSON·Glaze)빠름큼할당을 줄인 파서·직렬화기
JSON (simdjson)파싱이 매우 빠름-파싱 중심 라이브러리
직접 설계한 바이너리가장 빠른 편작음호환성 관리는 직접 해야 함
Protobuf빠름작음varint로 작은 정수가 특히 작아짐
MessagePack빠름중간스키마 없이 JSON과 같은 데이터 모델

구체적인 숫자는 일부러 적지 않았습니다. 직렬화 성능은 문자열 비중, 숫자 개수, 중첩 깊이, 메모리 할당기에 따라 몇 배씩 달라지고, 라이브러리 버전이 바뀔 때마다 순위도 바뀝니다. 공개 벤치마크의 수치를 그대로 믿기보다, 실제로 주고받는 페이로드 샘플로 Google Benchmark 같은 도구를 써서 직접 측정해 보고 정하세요. 많은 서비스에서는 직렬화보다 네트워크·디스크 I/O가 병목이므로, 프로파일러로 직렬화가 실제 병목인지 먼저 확인하는 것이 순서입니다.


버전 헤더·다형성·압축

버전 헤더

struct Serializer {
    static constexpr uint32_t MAGIC = 0x53415645;
    static constexpr uint32_t CURRENT_VERSION = 2;

    void save(std::ostream& out, const GameState& state) const {
        writeU32(out, MAGIC);
        writeU32(out, CURRENT_VERSION);
        writeU32(out, state.level);
        writeF32(out, state.health);
        writeString(out, state.avatarUrl);  // v2 필드
    }
    bool load(std::istream& in, GameState& state) const {
        if (readU32(in) != MAGIC) return false;     // 다른 형식의 파일
        uint32_t version = readU32(in);
        if (version > CURRENT_VERSION) return false; // 더 새 버전이 쓴 파일
        state.level = readU32(in);
        state.health = readF32(in);
        if (version >= 2) {
            readString(in, state.avatarUrl, 512);
        } else {
            state.avatarUrl.clear();                 // v1 파일: 기본값
        }
        return static_cast<bool>(in);
    }
};

매직 넘버는 엉뚱한 파일(다른 프로그램의 파일, 빈 파일, 텍스트 파일)을 첫 4바이트에서 바로 걸러내고, 버전 번호는 구버전 파일을 어떤 규칙으로 읽을지 정합니다. writeU32/readU32는 앞의 엔디안 변환을 거쳐 쓰고 읽는 헬퍼라고 가정합니다. 이 헬퍼들을 std::ostream/std::istream 기준으로 만들어 두면 std::ofstream(파일)과 std::ostringstream(메모리 버퍼, 네트워크 전송용 페이로드)에 같은 코드를 쓸 수 있습니다.

다형성 직렬화

struct Base {
    virtual ~Base() = default;
    virtual uint32_t typeId() const = 0;
    virtual void serialize(std::ostream& out) const = 0;
    virtual void deserialize(std::istream& in) = 0;
};
struct Derived : Base {
    int32_t value = 0;
    uint32_t typeId() const override { return 1; }
    void serialize(std::ostream& out) const override {
        out.write(reinterpret_cast<const char*>(&value), sizeof(value));
    }
    void deserialize(std::istream& in) override {
        in.read(reinterpret_cast<char*>(&value), sizeof(value));
    }
};

void serializePolymorphic(std::ostream& out, const Base& obj) {
    uint32_t id = obj.typeId();
    out.write(reinterpret_cast<const char*>(&id), sizeof(id));
    obj.serialize(out);
}
std::unique_ptr<Base> deserializePolymorphic(std::istream& in) {
    uint32_t typeId = 0;
    if (!in.read(reinterpret_cast<char*>(&typeId), sizeof(typeId))) return nullptr;
    auto obj = createFromTypeId(typeId);  // 타입 ID → 생성 함수 매핑
    if (!obj) return nullptr;             // 모르는 타입 ID
    obj->deserialize(in);
    return obj;
}

타입 ID 기록을 바깥 함수로 빼 두면 쓰기와 읽기의 대칭이 분명해집니다. 타입 ID는 파일 포맷의 일부가 되므로, 한 번 배포한 뒤에는 클래스 이름이 바뀌어도 숫자를 바꾸면 안 됩니다.

압축

#include <zlib.h>

std::string compressPayload(const std::string& raw) {
    uLongf destLen = compressBound(raw.size());
    std::string compressed(destLen, '\0');
    int rc = compress2(reinterpret_cast<Bytef*>(compressed.data()), &destLen,
                       reinterpret_cast<const Bytef*>(raw.data()), raw.size(),
                       Z_BEST_SPEED);
    if (rc != Z_OK) throw std::runtime_error("compress2 failed");
    compressed.resize(destLen);
    return compressed;
}

zlib의 uncompress는 출력 버퍼 크기를 미리 알아야 하므로, 압축 전 크기(raw.size())를 헤더에 함께 저장해야 복원할 수 있습니다. 직렬화 결과에 같은 키 이름이나 반복 값이 많은 JSON이라면 압축 효과가 크고, 이미 작게 인코딩된 Protobuf는 상대적으로 효과가 작습니다.


하위 호환 규칙과 버전 마이그레이션

규칙설명
필드 추가새 필드는 항상 끝에 추가하고, 구버전 파일에서는 기본값으로 채움
필드 삭제구버전 파일을 읽을 수 있도록 읽고 버리는 코드는 남김
필드 번호Protobuf에서는 번호 변경·재사용 금지, 삭제한 번호는 reserved
타입 변경int → string 같은 변경은 새 필드로 추가하고 구버전 필드는 유지

저장 구조가 여러 번 바뀐 경우에는 로드 코드에서 버전마다 분기하기보다, 구버전 구조체로 읽은 뒤 단계별 변환 함수로 최신 구조체까지 올리는 방식이 관리하기 쉽습니다.

struct DataV1 {
    int id;
    std::string name;
};
struct DataV2 {
    int id;
    std::string name;
    std::string email;  // v2에서 추가
};
DataV2 migrateFromV1(const DataV1& v1) {
    return {v1.id, v1.name, ""};  // email은 빈 문자열
}

참고 자료



같이 보면 좋은 글