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>>(); // 역변환
게임 세이브용 바이너리 직렬화
그림: 실제 실행 결과 — 리틀 엔디안으로 쓴 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은 빈 문자열
}
참고 자료
- nlohmann/json — 헤더 전용 JSON 라이브러리
- Protocol Buffers — 스키마 기반 직렬화
- cereal — C++11 직렬화 라이브러리
- RapidJSON — JSON 파서·생성기
- simdjson — SIMD 기반 JSON 파서