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

들어가며: 구조체가 50개인데 직렬화 코드가 폭발한다

“User, Order, Product… 매번 to_json·from_json을 손으로 짜기엔 한계가 있어요”

C++에는 C++23까지 Java나 C#처럼 리플렉션이 표준으로 없어서, 직렬화·역직렬화를 할 때마다 수동으로 코드를 작성해야 했습니다. 구조체가 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

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


직렬화 코드에서 생기는 다른 문제들

게임 세이브 파일이 플랫폼마다 깨진다

게임 진행 상황을 저장했는데, Windows에서 저장한 파일을 macOS에서 열면 데이터가 깨집니다. 엔디안·패딩 차이 때문에 발생합니다. 구조체를 reinterpret_cast로 덤프하면 플랫폼 의존성이 생깁니다.

네트워크 프로토콜 버전 업그레이드

서버 v2.0에서 User 클래스에 avatarUrl 필드를 추가했습니다. v1.0 클라이언트가 v2.0 서버로부터 받은 데이터를 파싱할 때 버전 호환성 없이 구현하면 크래시나 데이터 손실이 발생합니다.

ORM/데이터베이스 매핑

User, Article 같은 엔티티를 DB 테이블과 매핑할 때, 컬럼 이름·타입을 런타임에 알아야 쿼리 생성·결과 바인딩이 가능합니다. 수동 직렬화는 엔티티가 늘어날수록 코드가 폭발합니다.

설정 파일 로드

YAML/JSON 설정을 Config 구조체로 로드할 때, 키 이름과 멤버를 매칭하려면 멤버 이름을 런타임에 알아야 합니다. 리플렉션으로 “키 → 멤버” 매핑을 자동화할 수 있습니다.

대용량 JSON 파싱 성능

REST API 응답으로 10MB JSON을 받아 파싱할 때, nlohmann/json이 느리면 RapidJSON·simdjson으로 전환하거나, 바이너리 포맷(MessagePack, Protobuf)을 고려해야 합니다.


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

포맷별 특징

포맷크기속도가독성스키마호환성용도
JSON큼느림높음없음높음REST API, 설정, 디버깅
바이너리작음빠름없음수동낮음게임 세이브, 내부 프로토콜
Protobuf작음빠름없음.proto높음네트워크, 마이크로서비스
MessagePack중간빠름없음없음중간캐시, 실시간 통신
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 (nlohmann/json, RapidJSON)
  • 게임 세이브·플랫폼 간: 바이너리 (필드 단위, 엔디안 통일)
  • 마이크로서비스·네트워크: Protobuf
  • 캐시·Redis: MessagePack

nlohmann/json으로 JSON 직렬화

nlohmann/json 기본 사용

#include <nlohmann/json.hpp>
#include <string>
#include <fstream>
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, "홍길동", "[email protected]"};
    json j = u;
    std::string str = j.dump(2);  // 들여쓰기 2칸
    std::ofstream file("user.json");
    file << str;
    User loaded;
    loaded = json::parse(str).get<User>();
    return 0;
}

nlohmann/json이 json j = u; 한 줄로 변환을 해내는 것은 ADL(인자 기반 이름 탐색) 덕분입니다. 라이브러리는 User 타입과 같은 네임스페이스에 있는 to_json/from_json을 찾아 호출합니다. 그래서 구조체는 app 네임스페이스에 두고 to_json은 전역에 정의하면 ADL이 찾지 못해, static assertion failed: ... 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": "[email protected]"
}

중첩 구조체 직렬화

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}
    };
}
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);
}

배열·벡터 직렬화

void to_json(json& j, const std::vector<User>& users) {
    j = json::array();
    for (const auto& u : users) {
        j.push_back(u);
    }
}
void from_json(const json& j, std::vector<User>& users) {
    users.clear();
    for (const auto& item : j) {
        users.push_back(item.get<User>());
    }
}

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

필드 단위 규약

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

완전한 게임 세이브 구조

#include <fstream>
#include <string>
#include <vector>
#include <cstdint>
// 공통 헬퍼: 문자열 길이+바이트
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);
}
std::string readString(std::istream& in) {
    uint32_t len;
    in.read(reinterpret_cast<char*>(&len), sizeof(len));
    std::string str(len, '\0');
    in.read(&str[0], len);
    return str;
}
struct GameSave {
    static constexpr uint32_t MAGIC = 0x53415645;  // "SAVE"
    static constexpr uint32_t VERSION = 1;
    uint32_t level;
    float health;
    float positionX, positionY;
    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, version;
        file.read(reinterpret_cast<char*>(&magic), sizeof(magic));
        file.read(reinterpret_cast<char*>(&version), sizeof(version));
        if (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));
        playerName = readString(file);
        uint32_t invCount;
        file.read(reinterpret_cast<char*>(&invCount), sizeof(invCount));
        inventory.resize(invCount);
        file.read(reinterpret_cast<char*>(inventory.data()),
                  invCount * sizeof(uint32_t));
        return file.good();
    }
};

이 세이브 코드는 필드를 하나씩 쓰기 때문에 구조체 패딩 문제는 피하지만, 두 가지를 더 챙겨야 실제 게임에 쓸 수 있습니다. 첫째, readString과 인벤토리 로드는 파일에서 읽은 길이를 그대로 믿고 resize합니다. 파일이 손상되어 길이 필드가 0xFFFFFFFF가 되면 4GB 할당을 시도하다 std::bad_alloc으로 죽거나 메모리를 크게 소모합니다. 세이브 파일은 사용자가 얼마든지 편집할 수 있는 입력이므로, “플레이어 이름은 최대 64바이트”처럼 상한을 두고 넘으면 로드를 실패로 처리해야 합니다. 둘째, 각 read 뒤에 스트림 상태를 확인하지 않으면 파일이 중간에 잘린 경우에도 쓰레기 값이 담긴 채 진행됩니다. 마지막의 file.good()만으로는 어느 필드에서 실패했는지 알 수 없으므로, 로드 실패 원인을 로그에 남기려면 필드마다 확인하는 편이 디버깅에 유리합니다.

엔디안 처리 (플랫폼 간 호환)

#include <cstdint>
uint32_t toLittleEndian(uint32_t val) {
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
    return val;
#else
    return __builtin_bswap32(val);
#endif
}
uint32_t fromLittleEndian(uint32_t val) {
    return toLittleEndian(val);  // 대칭
}
// 사용: 저장 시 toLittleEndian, 로드 시 fromLittleEndian

위 코드의 __BYTE_ORDER__와 __builtin_bswap32는 GCC·Clang 확장이라 MSVC에서는 컴파일되지 않습니다. C++20부터는 std::endian::native == std::endian::little로 표준 방식의 판별이 가능하고, C++23의 std::byteswap으로 바이트 순서를 뒤집을 수 있습니다. 파일 포맷의 엔디안은 리틀 엔디안으로 정하는 경우가 많은데, 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("[email protected]");
    // 바이너리 직렬화
    std::string serialized;
    user.SerializeToString(&serialized);
    // 파일 저장
    std::ofstream file("user.pb", std::ios::binary);
    file.write(serialized.data(), serialized.size());
    // 역직렬화
    User loaded;
    loaded.ParseFromString(serialized);
    std::cout << "ID: " << loaded.id() << ", Name: " << loaded.name() << "\n";
    return 0;
}

버전 호환 (필드 추가)

// v1
message User {
    int32 id = 1;
    string name = 2;
}
// v2: avatar_url 추가 (기존 v1 클라이언트는 무시)
message User {
    int32 id = 1;
    string name = 2;
    string avatar_url = 3;  // 새 필드
}

Protobuf 규칙: 필드 번호를 바꾸지 않으며, 새 필드만 추가하면 기존 파서가 새 필드를 무시하고 동작합니다.

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


cereal·매크로로 직렬화 코드 자동화

매크로 기반 반복 제거

직접 매크로를 짜기보다, 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(id, name, email);  // 한 줄로 직렬화 정의
    }
};
int main() {
    User u{1, "홍길동", "[email protected]"};
    {
        std::ofstream file("user.json");
        cereal::JSONOutputArchive ar(file);
        ar(u);
    }
    {
        User loaded;
        std::ifstream file("user.json");
        cereal::JSONInputArchive ar(file);
        ar(loaded);
    }
    return 0;
}

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)
    );
};
// 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);
    }
};
// 텍스트 JSON 아카이브보다 대체로 작고 빠름 (단, 이식성 없음: 아래 설명 참고)
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 예제 (Redis·캐시용)

#include <msgpack.hpp>
#include <vector>
#include <string>
struct Item {
    int id;
    std::string name;
    MSGPACK_DEFINE(id, name);  // 매크로로 직렬화 정의
};
int main() {
    std::vector<Item> items = {{1, "A"}, {2, "B"}};
    msgpack::sbuffer sbuf;
    msgpack::pack(sbuf, items);
    auto handle = msgpack::unpack(sbuf.data(), sbuf.size());
    auto obj = handle.get();
    std::vector<Item> loaded = obj.as<std::vector<Item>>();
    return 0;
}

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

시나리오: 게임 앱에서 JSON 설정 로드 + 바이너리 세이브

#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
#include <vector>
#include <cstdint>
// 1. 설정: JSON (편집 가능, 디버깅 용이)
struct GameConfig {
    std::string title;
    int screenWidth;
    int screenHeight;
    bool fullscreen;
    std::vector<std::string> levels;
};
void to_json(nlohmann::json& j, const GameConfig& c) {
    j = {{"title", c.title}, {"screenWidth", c.screenWidth},
         {"screenHeight", c.screenHeight}, {"fullscreen", c.fullscreen},
         {"levels", c.levels}};
}
void from_json(const nlohmann::json& j, GameConfig& c) {
    j.at("title").get_to(c.title);
    j.at("screenWidth").get_to(c.screenWidth);
    j.at("screenHeight").get_to(c.screenHeight);
    j.at("fullscreen").get_to(c.fullscreen);
    j.at("levels").get_to(c.levels);
}
// 2. 세이브: 바이너리 (빠름, 작음)
struct GameSave {
    static constexpr uint32_t MAGIC = 0x53415645;
    static constexpr uint32_t VERSION = 1;
    uint32_t level;
    float health;
    std::string playerName;
    std::vector<uint32_t> inventory;
    bool save(const std::string& path) const {
        std::ofstream f(path, std::ios::binary);
        if (!f) return false;
        f.write(reinterpret_cast<const char*>(&MAGIC), sizeof(MAGIC));
        f.write(reinterpret_cast<const char*>(&VERSION), sizeof(VERSION));
        f.write(reinterpret_cast<const char*>(&level), sizeof(level));
        f.write(reinterpret_cast<const char*>(&health), sizeof(health));
        uint32_t len = static_cast<uint32_t>(playerName.size());
        f.write(reinterpret_cast<const char*>(&len), sizeof(len));
        f.write(playerName.data(), len);
        uint32_t cnt = static_cast<uint32_t>(inventory.size());
        f.write(reinterpret_cast<const char*>(&cnt), sizeof(cnt));
        f.write(reinterpret_cast<const char*>(inventory.data()), cnt * sizeof(uint32_t));
        return f.good();
    }
    bool load(const std::string& path) {
        std::ifstream f(path, std::ios::binary);
        if (!f) return false;
        uint32_t magic, version;
        f.read(reinterpret_cast<char*>(&magic), sizeof(magic));
        f.read(reinterpret_cast<char*>(&version), sizeof(version));
        if (magic != MAGIC || version > VERSION) return false;
        f.read(reinterpret_cast<char*>(&level), sizeof(level));
        f.read(reinterpret_cast<char*>(&health), sizeof(health));
        uint32_t len;
        f.read(reinterpret_cast<char*>(&len), sizeof(len));
        playerName.resize(len);
        f.read(&playerName[0], len);
        uint32_t cnt;
        f.read(reinterpret_cast<char*>(&cnt), sizeof(cnt));
        inventory.resize(cnt);
        f.read(reinterpret_cast<char*>(inventory.data()), cnt * sizeof(uint32_t));
        return f.good();
    }
};
// 3. 앱 진입점
int main() {
    GameConfig config;
    std::ifstream configFile("config.json");
    if (configFile) {
        config = nlohmann::json::parse(configFile).get<GameConfig>();
    }
    GameSave save;
    if (save.load("save.dat")) {
        // 이어하기
    } else {
        save.level = 1;
        save.health = 100.0f;
        save.playerName = "Player";
        save.save("save.dat");
    }
    return 0;
}

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

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

누락 키·엔디안·버전 업그레이드 후 로드 실패

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

원인: JSON에 필드가 없거나, 키 이름이 다름 (대소문자, id vs ID). 해결법:

// ❌ 잘못된 예: at()은 키 없으면 예외
u.id = j.at("id").get<int>();
// ✅ 올바른 예: contains()로 확인 후 기본값
if (j.contains("id")) {
    u.id = j["id"].get<int>();
} else {
    u.id = 0;  // 기본값
}
// 또는 value() 사용 (기본값 지정)
u.id = j.value("id", 0);

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

원인: 엔디안·패딩·타입 크기 차이 (int 4바이트 vs 8바이트 등). 해결법:

// ❌ 잘못된 예: 구조체 직접 덤프
file.write(reinterpret_cast<const char*>(&data), sizeof(data));
// ✅ 올바른 예: 고정 크기 타입 + 필드 단위
uint32_t level = data.level;  // int → uint32_t 고정
file.write(reinterpret_cast<const char*>(&level), sizeof(level));

std::string·vector 직렬화 시 크래시

원인: “길이” 없이 데이터만 저장하면, 읽을 때 resize 크기를 알 수 없음. 해결법:

// ✅ 문자열: "길이(uint32_t) + 바이트"
uint32_t len = str.size();
out.write(reinterpret_cast<const char*>(&len), sizeof(len));
out.write(str.data(), len);
// ✅ 벡터: "개수(uint32_t) + 요소들"
uint32_t count = vec.size();
out.write(reinterpret_cast<const char*>(&count), sizeof(count));
out.write(reinterpret_cast<const char*>(vec.data()), count * sizeof(T));

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

원인: 새 필드 추가 시 at()으로 필수 키를 읽으면, 구버전 파일에 없어 예외 발생. 해결법:

// ✅ 버전별 분기
uint32_t version;
in.read(reinterpret_cast<char*>(&version), sizeof(version));
if (version >= 1) {
    in.read(reinterpret_cast<char*>(&level), sizeof(level));
}
if (version >= 2) {
    avatarUrl = readString(in);  // v2에서 추가된 필드
}

JSON 파싱 성능 부족

원인: nlohmann/json은 편리하지만 대용량에서 느림. 해결법:

// RapidJSON (더 빠름)
#include <rapidjson/document.h>
#include <rapidjson/stringbuffer.h>
#include <rapidjson/writer.h>
rapidjson::Document doc;
doc.Parse(jsonStr.c_str());
// simdjson (SIMD 최적화, 가장 빠름)
#include <simdjson.h>
simdjson::ondemand::parser parser;
simdjson::ondemand::document doc = parser.iterate(jsonStr);

UTF-8 인코딩 깨짐

원인: JSON에 한글 등이 포함될 때, 파일 인코딩·스트림 모드 불일치. 해결법:

// ✅ 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는 소스 파일을 시스템 코드 페이지(한국어 Windows에서는 CP949)로 해석하므로, "홍길동" 리터럴이 CP949 바이트로 들어가 dump()에서 [json.exception.type_error.316] invalid UTF-8 byte 예외가 납니다. 컴파일 옵션에 /utf-8을 주거나 u8"..." 리터럴을 쓰면 해결됩니다. 예전 코드에서 흔히 보이는 file.imbue(std::locale("en_US.UTF-8"))는 char 스트림의 바이트를 변환하지 않아 효과가 없고, 해당 로캘이 설치되지 않은 Windows나 최소 Docker 이미지에서는 std::runtime_error를 던지므로 쓰지 않는 편이 낫습니다.

순환 참조 (순환 포인터)

원인: Parent가 Child를 가지고, Child가 Parent*를 가지는 경우, JSON 직렬화 시 무한 루프. 해결법:

// ✅ ID 참조로 변환
struct Parent {
    int id;
    std::vector<int> childIds;  // Child* 대신 ID만 저장
};
struct Child {
    int id;
    int parentId;  // Parent* 대신 ID만 저장
};
// 로드 시 ID로 조회해 포인터 복원

파일이 비어 있거나 손상됨

원인: 저장 중 크래시, 디스크 풀, 권한 오류로 불완전한 파일 생성. 해결법:

// ✅ 임시 파일 + 원자적 교체
bool safeSave(const std::string& path, const Data& data) {
    std::string tempPath = path + ".tmp";
    std::ofstream file(tempPath, std::ios::binary);
    if (!file || !serialize(file, data)) {
        std::remove(tempPath.c_str());
        return false;
    }
    file.close();
    return std::rename(tempPath.c_str(), path.c_str()) == 0;
}

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


포맷별 속도와 크기 비교

상대적인 경향

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

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

선택 가이드

  • 디버깅·설정: JSON (가독성 우선)
  • 대용량·실시간: 바이너리 또는 Protobuf
  • REST API: JSON (RapidJSON 권장)
  • 게임 세이브: 바이너리 (필드 단위, 버전 관리)

버전 헤더·매직 넘버·다형성 직렬화

직렬화 버전 관리

struct Serializer {
    static constexpr uint32_t CURRENT_VERSION = 2;
    void save(std::ostream& out, const GameState& state) const {
        writeU32(out, CURRENT_VERSION);
        writeU32(out, state.level);
        writeF32(out, state.health);
        if (CURRENT_VERSION >= 2) {
            writeString(out, state.avatarUrl);
        }
    }
    bool load(std::istream& in, GameState& state) const {
        uint32_t version = readU32(in);
        if (version > CURRENT_VERSION) return false;
        state.level = readU32(in);
        state.health = readF32(in);
        if (version >= 2) {
            state.avatarUrl = readString(in);
        }
        return true;
    }
};

매직 넘버로 포맷 검증

bool validateFile(std::istream& in) {
    uint32_t magic;
    in.read(reinterpret_cast<char*>(&magic), sizeof(magic));
    return magic == 0x53415645;  // "SAVE"
}

스트림 기반 (파일·메모리 공통)

// 메모리: std::stringstream
// 파일: std::ifstream / std::ofstream
// 네트워크: boost::asio::streambuf
void saveToStream(std::ostream& out, const Data& data) {
    data.serialize(out);
}
// 파일
std::ofstream file("data.bin", std::ios::binary);
saveToStream(file, data);
// 메모리
std::ostringstream oss;
saveToStream(oss, data);
std::string payload = oss.str();

에러 처리

struct LoadResult {
    bool success;
    std::string errorMessage;
};
LoadResult load(const std::string& path) {
    std::ifstream file(path, std::ios::binary);
    if (!file) {
        return {false, "파일을 열 수 없습니다: " + path};
    }
    uint32_t magic;
    file.read(reinterpret_cast<char*>(&magic), sizeof(magic));
    if (magic != EXPECTED_MAGIC) {
        return {false, "잘못된 파일 형식입니다"};
    }
    // ...
    return {true, ""};
}

타입 안전한 직렬화 레지스트리

template <typename T>
struct SerializerRegistry {
    static std::string serialize(const T& obj);
    static T deserialize(const std::string& data);
};
// 사용
template <typename T>
std::string serialize(const T& obj) {
    return SerializerRegistry<T>::serialize(obj);
}

다형성 직렬화 (가상 클래스)

struct Base {
    virtual ~Base() = default;
    virtual void serialize(std::ostream& out) const = 0;
    virtual void deserialize(std::istream& in) = 0;
};
struct Derived : Base {
    int value;
    void serialize(std::ostream& out) const override {
        uint32_t typeId = 1;  // Derived 식별자
        out.write(reinterpret_cast<const char*>(&typeId), sizeof(typeId));
        out.write(reinterpret_cast<const char*>(&value), sizeof(value));
    }
    void deserialize(std::istream& in) override {
        in.read(reinterpret_cast<char*>(&value), sizeof(value));
    }
};
// 팩토리로 타입 ID → 생성자 매핑
std::unique_ptr<Base> deserializePolymorphic(std::istream& in) {
    uint32_t typeId;
    in.read(reinterpret_cast<char*>(&typeId), sizeof(typeId));
    auto obj = createFromTypeId(typeId);
    obj->deserialize(in);
    return obj;
}

압축 직렬화 (대용량)

#include <zlib.h>
#include <sstream>
std::string compressAndSerialize(const Data& data) {
    std::ostringstream oss;
    serialize(oss, data);
    std::string raw = oss.str();
    std::string compressed;
    compressed.resize(compressBound(raw.size()));
    uLongf destLen = compressed.size();
    compress2(reinterpret_cast<Bytef*>(compressed.data()), &destLen,
              reinterpret_cast<const Bytef*>(raw.data()), raw.size(),
              Z_BEST_SPEED);
    compressed.resize(destLen);
    return compressed;
}

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

하위 호환 규칙

규칙설명
필드 추가새 필드는 항상 끝에, 기본값 처리
필드 삭제삭제 대신 deprecated 표시, 읽기만 하고 무시
필드 번호Protobuf: 번호 변경 금지
타입 변경int→string 등: 새 필드로 추가, 구버전 필드 유지

버전 마이그레이션

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

직렬화 설계 점검 목록

  • 직렬화 포맷 선택 (JSON vs 바이너리 vs Protobuf)
  • 버전 번호·매직 넘버 포함
  • 가변 길이 필드: 길이+데이터 순서
  • 엔디안 통일 (플랫폼 간 필요 시)
  • 버전별 분기 (필드 추가 시)
  • 에러 처리 (파일 열기, 포맷 검증)
  • UTF-8 인코딩 (한글 등)

포맷 선택 요약

항목설명
포맷JSON(가독성), 바이너리(속도), Protobuf(호환)
버전매직 넘버, 버전 번호, 하위 호환
에러contains/value, 길이+데이터, 엔디안
성능대용량 시 RapidJSON·simdjson·바이너리

핵심 원칙:

  1. 포맷을 요구사항에 맞게 선택
  2. 버전 관리로 하위 호환 유지
  3. 가변 필드는 “길이+데이터”
  4. 플랫폼 간이면 엔디안·고정 크기 타입

자주 묻는 질문 (FAQ)

Q. nlohmann/json에서 to_json을 정의했는데 “could not find to_json()” 에러가 납니다.

A. to_json/from_json이 구조체와 같은 네임스페이스에 있어야 ADL로 찾을 수 있습니다. 구조체가 app 네임스페이스에 있다면 함수도 app 안에 정의하거나, 멤버 함수가 필요하면 NLOHMANN_DEFINE_TYPE_INTRUSIVE를 클래스 안에 둡니다.

Q. 두 번째 저장부터 세이브가 갱신되지 않습니다(Windows).

A. 임시 파일을 std::rename으로 교체하는 코드라면, Windows에서는 대상 파일이 이미 있을 때 rename이 실패합니다. std::filesystem::rename을 쓰거나 MoveFileExW에 MOVEFILE_REPLACE_EXISTING을 줘야 합니다.

Q. 기존 파일에 필드를 추가하면 어떻게 하나요?

A. 버전 번호를 두고, version >= 2일 때만 새 필드를 읽어서 구버전 파일은 새 필드를 기본값으로 채웁니다. Protobuf라면 새 번호로 필드를 추가하기만 하면 됩니다.


참고 자료


같이 보면 좋은 글