C++ 직렬화 완벽 가이드 | JSON·바이너리·Protobuf·리플렉션 기반 자동화 [#55-5]
이 글의 핵심
C++ 직렬화 포맷 선택, 수동 vs 자동 직렬화, 버전 호환성, 엔디안·패딩·일반적인 에러 해결, 성능 비교, 프로덕션 패턴까지. C++에는 Java나 C#처럼 리플렉션이 표준으로 없어서, 직렬화·역직렬화를 할 때마다 수동으로 코드를 작성해야 합니다. 구조체가 10개, 50개가 되면 tojson, fromjson, serialize,.
들어가며: 구조체가 50개인데 직렬화 코드가 폭발한다
”User, Order, Product… 매번 to_json·from_json을 손으로 짜기엔 한계가 있어요”
C++에는 Java나 C#처럼 리플렉션이 표준으로 없어서, 직렬화·역직렬화를 할 때마다 수동으로 코드를 작성해야 합니다. 구조체가 10개, 50개가 되면 to_json, from_json, serialize, deserialize 함수가 폭발적으로 늘어나 유지보수가 불가능해집니다.
비유하면 “창고에 물건을 넣을 때마다 물건마다 다른 규격으로 포장해야 하는데, C++에는 자동 포장기가 없어서 손으로 하나씩 포장하는” 상황입니다. 멤버를 추가·삭제할 때마다 직렬화 코드도 함께 수정해야 하고, 누락 시 런타임 버그로 이어집니다.
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)을 선택할 수 있습니다.
- 수동·자동 직렬화 구현 방식을 실전 코드로 익힐 수 있습니다.
- 자주 발생하는 에러와 워크어라운드를 알 수 있습니다.
- 성능 비교와 프로덕션 패턴을 활용할 수 있습니다.
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
추가 문제 시나리오
시나리오 1: 게임 세이브 파일이 플랫폼마다 깨진다
게임 진행 상황을 저장했는데, Windows에서 저장한 파일을 macOS에서 열면 데이터가 깨집니다. 엔디안·패딩 차이 때문에 발생합니다. 구조체를 reinterpret_cast로 덤프하면 플랫폼 의존성이 생깁니다.
시나리오 2: 네트워크 프로토콜 버전 업그레이드
서버 v2.0에서 User 클래스에 avatarUrl 필드를 추가했습니다. v1.0 클라이언트가 v2.0 서버로부터 받은 데이터를 파싱할 때 버전 호환성 없이 구현하면 크래시나 데이터 손실이 발생합니다.
시나리오 3: ORM/데이터베이스 매핑
User, Article 같은 엔티티를 DB 테이블과 매핑할 때, 컬럼 이름·타입을 런타임에 알아야 쿼리 생성·결과 바인딩이 가능합니다. 수동 직렬화는 엔티티가 늘어날수록 코드가 폭발합니다.
시나리오 4: 설정 파일 로드
YAML/JSON 설정을 Config 구조체로 로드할 때, 키 이름과 멤버를 매칭하려면 멤버 이름을 런타임에 알아야 합니다. 리플렉션으로 “키 → 멤버” 매핑을 자동화할 수 있습니다.
시나리오 5: 대용량 JSON 파싱 성능
REST API 응답으로 10MB JSON을 받아 파싱할 때, nlohmann/json이 느리면 RapidJSON·simdjson으로 전환하거나, 바이너리 포맷(MessagePack, Protobuf)을 고려해야 합니다.
목차
- 직렬화 포맷 비교
- JSON 직렬화 완전 예제
- 바이너리 직렬화 완전 예제
- Protocol Buffers 통합
- 리플렉션 기반 자동 직렬화
- 완전한 실전 예제: 설정 + 세이브 통합
- 자주 발생하는 에러와 해결법
- 성능 비교
- 프로덕션 패턴
- 버전·호환성
1. 직렬화 포맷 비교
포맷별 특징
| 포맷 | 크기 | 속도 | 가독성 | 스키마 | 호환성 | 용도 |
|---|---|---|---|---|---|---|
| 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
2. 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;
}
출력 예시 (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>());
}
}
3. 바이너리 직렬화 완전 예제
필드 단위 규약
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();
}
};
엔디안 처리 (플랫폼 간 호환)
#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
4. 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 규칙: 필드 번호를 바꾸지 않고, 새 필드만 추가하면 기존 파서가 새 필드를 무시하고 동작합니다.
5. 리플렉션 기반 자동 직렬화
매크로 기반 반복 제거
#define REFLECT_STRUCT(User, ...) \
void to_json(nlohmann::json& j, const User& u) { \
j = nlohmann::json{__VA_ARGS__}; \
} \
void from_json(const nlohmann::json& j, User& u) { \
j.at("id").get_to(u.id); \
/* 각 필드별 매핑 필요 - 매크로 한계 */ \
}
// 한계: 필드별 타입·이름이 다르면 매크로로 완전 자동화 어려움
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로 멤버 순회 → 직렬화 자동화 가능
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보다 3~5배 작고 빠름
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);
}
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;
}
6. 완전한 실전 예제: 설정 + 세이브 통합
시나리오: 게임 앱에서 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"]
}
7. 자주 발생하는 에러와 해결법
문제 1: “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);
문제 2: 바이너리 파일이 다른 플랫폼에서 깨짐
원인: 엔디안·패딩·타입 크기 차이 (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));
문제 3: 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));
문제 4: 버전 업그레이드 후 기존 파일 로드 실패
원인: 새 필드 추가 시 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에서 추가된 필드
}
문제 5: 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);
문제 6: UTF-8 인코딩 깨짐
원인: JSON에 한글 등이 포함될 때, 파일 인코딩·스트림 모드 불일치.
해결법:
// ✅ UTF-8로 저장
std::ofstream file("data.json");
file.imbue(std::locale("en_US.UTF-8")); // 또는 시스템 기본
file << j.dump(2);
// nlohmann/json은 기본적으로 UTF-8 지원
문제 7: 순환 참조 (순환 포인터)
원인: 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로 조회해 포인터 복원
문제 8: 파일이 비어 있거나 손상됨
원인: 저장 중 크래시, 디스크 풀, 권한 오류로 불완전한 파일 생성.
해결법:
// ✅ 임시 파일 + 원자적 교체
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;
}
8. 성능 비교
벤치마크 결과 (개략적)
| 포맷 | 직렬화 10만회 | 역직렬화 10만회 | 크기 (1KB 객체) |
|---|---|---|---|
| JSON (nlohmann) | ~800ms | ~1200ms | ~2.5KB |
| JSON (RapidJSON) | ~200ms | ~350ms | ~2.5KB |
| JSON (simdjson) | - | ~80ms | - |
| 바이너리 | ~50ms | ~60ms | ~1KB |
| Protobuf | ~100ms | ~120ms | ~0.8KB |
| MessagePack | ~150ms | ~180ms | ~1.2KB |
flowchart LR
subgraph speed[속도 비교]
A[JSON nlohmann] -->|1x| B[기준]
C[RapidJSON] -->|4x| B
D[바이너리] -->|16x| B
E[Protobuf] -->|8x| B
end
선택 가이드
- 디버깅·설정: JSON (가독성 우선)
- 대용량·실시간: 바이너리 또는 Protobuf
- REST API: JSON (RapidJSON 권장)
- 게임 세이브: 바이너리 (필드 단위, 버전 관리)
9. 프로덕션 패턴
패턴 1: 직렬화 버전 관리
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;
}
};
패턴 2: 매직 넘버로 포맷 검증
bool validateFile(std::istream& in) {
uint32_t magic;
in.read(reinterpret_cast<char*>(&magic), sizeof(magic));
return magic == 0x53415645; // "SAVE"
}
패턴 3: 스트림 기반 (파일·메모리 공통)
// 메모리: 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();
패턴 4: 에러 처리
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, ""};
}
패턴 5: 타입 안전한 직렬화 레지스트리
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);
}
패턴 6: 다형성 직렬화 (가상 클래스)
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;
}
패턴 7: 압축 직렬화 (대용량)
#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;
}
10. 버전·호환성
하위 호환 규칙
| 규칙 | 설명 |
|---|---|
| 필드 추가 | 새 필드는 항상 끝에, 기본값 처리 |
| 필드 삭제 | 삭제 대신 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·바이너리 |
핵심 원칙:
- 포맷을 요구사항에 맞게 선택
- 버전 관리로 하위 호환 유지
- 가변 필드는 “길이+데이터”
- 플랫폼 간이면 엔디안·고정 크기 타입
자주 묻는 질문 (FAQ)
Q. JSON과 바이너리 중 뭘 써야 하나요?
A. REST API·설정·디버깅에는 JSON, 게임 세이브·내부 프로토콜·대용량에는 바이너리 또는 Protobuf를 권장합니다.
Q. 성능이 중요한데 어떤 라이브러리를 쓰나요?
A. JSON은 RapidJSON 또는 simdjson, 바이너리는 필드 단위 직접 구현, 스키마 기반이면 Protobuf가 적합합니다.
Q. 기존 파일에 필드를 추가하면 어떻게 하나요?
A. 버전 번호를 두고, version >= 2일 때만 새 필드를 읽어서, 구버전 파일은 새 필드를 건너뛰도록 처리합니다.
한 줄 요약: JSON·바이너리·Protobuf 선택부터 버전 관리·에러 처리·성능까지, C++ 직렬화 실전 패턴을 익힐 수 있습니다.
참고 자료
- nlohmann/json — 헤더 전용 JSON 라이브러리
- Protocol Buffers — 스키마 기반 직렬화
- cereal — C++11 직렬화 라이브러리
- RapidJSON — 고성능 JSON 파서
- simdjson — SIMD 기반 초고속 JSON 파서
관련 글
- C++ Protocol Buffers 완벽 가이드 | 직렬화·스키마 진화·성능 최적화·프로덕션 패턴
- C++ HTTP 클라이언트 완벽 가이드 | REST API 호출·연결 풀·타임아웃·프로덕션 패턴