C++ nlohmann/json 실전: 파싱·커스텀 타입 직렬화·에러 처리와 자주 틀리는 동작
들어가며: JSON 파싱이 복잡합니다
문제 시나리오
C++에서 JSON을 다루려다 보면 이런 상황을 자주 마주칩니다.
- REST API 응답을 받았는데,
{"data": [{"id": 1, "name": "Alice"}]}같은 문자열을 어떻게 구조화된 데이터로 바꿀지 막막합니다. 수동으로strstr이나 정규식으로 파싱하면 버그가 나기 쉽고, 중첩 객체·배열·이스케이프 문자 처리가 매우 번거롭습니다. - 설정 파일(
config.json)을 로드해서port,host,timeout같은 값을 읽어야 하는데, C++ 표준에는 JSON 파서가 없습니다. 서드파티 라이브러리를 쓰더라도 빌드 설정이 복잡하거나 API가 직관적이지 않은 경우가 많습니다. - 타입 안전성이 걱정됩니다.
j["age"]가 문자열"30"인데int로 읽으면 어떻게 될까요? 키가 없는데j["optional"]로 접근하면요? 런타임 크래시나 예기치 않은 동작이 발생합니다. - 커스텀 구조체를 JSON으로 직렬화/역직렬화하고 싶은데, 수동으로 필드마다
j["name"] = obj.name을 반복하는 건 번거롭고 실수하기 쉽습니다.
추가 문제 시나리오
외부 API 응답 파싱
REST API에서 {"status": "ok", "data": {"users": [{"id": 1, "email": "[email protected]"}]}} 같은 응답을 받았습니다. data가 null일 수도 있고, users가 빈 배열일 수도 있습니다. 수동 파싱은 중첩 깊이마다 null 체크가 필요해 코드가 지저분해집니다.
설정 파일 버전 호환
config.json에 timeout 필드가 새 버전에서 추가됐는데, 구버전 설정 파일에는 없습니다. j["timeout"]으로 접근하면 null이 삽입되고, 나중에 get<int>() 호출 시 type_error가 발생합니다. 선택적 필드를 안전하게 처리하는 패턴이 필요합니다.
숫자 vs 문자열 혼동
API가 "age": 30(숫자)과 "age": "30"(문자열)을 혼용해서 보냅니다. get<int>()는 문자열에서 실패하고, get<std::string>()은 숫자에서 실패합니다. 타입 검증과 유연한 변환이 필요합니다.
대용량 JSON 메모리
수 MB 크기의 로그 파일을 한 번에 std::string으로 읽어 parse()하면 메모리가 두 배로 사용됩니다. 스트림 파싱으로 메모리 사용을 줄이고 싶습니다.
로그/이벤트 직렬화
분산 시스템에서 이벤트를 JSON 한 줄로 직렬화해 Kafka나 로그 파일에 씁니다. to_json으로 구조체를 자동 변환하고, dump()로 한 줄 출력해야 합니다.
NDJSON 스트리밍
로그 파일이 {"ts":1,"msg":"a"}\n{"ts":2,"msg":"b"}\n 형태의 NDJSON(Newline-Delimited JSON)입니다. 한 줄씩 파싱해 메모리를 절약하고 싶습니다.
타입 유연한 API
외부 API가 "count": 100(숫자) 또는 "count": "100"(문자열)을 상황에 따라 보냅니다. 두 형태 모두 처리하는 유연한 파서가 필요합니다.
nlohmann/json은 이런 문제들을 해결하는 헤더 전용(.cpp 없이 헤더만 include) C++ JSON 라이브러리입니다. STL과 비슷한 인터페이스([], contains, find, value), to_json/from_json으로 커스텀 타입 직렬화, 그리고 풍부한 예외 처리로 실무에서 널리 사용됩니다.
flowchart LR
subgraph input[입력]
I1[문자열]
I2[파일]
I3[스트림]
end
subgraph nlohmann[nlohmann/json]
N1["json parse"]
N2["객체 접근"]
N3["j.dump()"]
N4["타입 변환"]
end
subgraph output[출력]
O1[json 객체]
O2[문자열]
O3[커스텀 타입]
end
input --> N1
N1 --> O1
O1 --> N2
O1 --> N4
N4 --> O3
O1 --> N3
N3 --> O2
이 글을 읽으면:
- nlohmann/json으로 JSON을 파싱·생성·접근할 수 있습니다.
- 커스텀 타입 직렬화(to_json, from_json)를 구현할 수 있습니다.
- JSON 검증과 에러 처리로 안전하게 사용할 수 있습니다.
- 타입 불일치·누락 키 등 자주 나는 에러를 피할 수 있습니다.
- 성능 비교와 프로덕션 패턴을 적용할 수 있습니다.
요구 환경: nlohmann/json은 헤더 전용이라 헤더만 포함하거나 vcpkg(
vcpkg install nlohmann-json), Conan, FetchContent로 추가하면 됩니다. C++11 이상이며, 2026년 기준 최신 릴리스는 3.12.x입니다.
제가 nlohmann/json을 쓰면서 가장 자주 사고가 난 곳은 문법이 아니라 “편한 API가 조용히 하는 일”이었습니다. operator[]가 없는 키를 만들어 넣는다거나, value()가 null 앞에서 예외를 던진다거나, 실수를 정수로 읽으면 소수점이 말없이 잘린다거나, dump()하면 키 순서가 알파벳순으로 바뀐다거나 하는 것들입니다. 이 글은 기본 사용법과 함께 이런 동작을 아래 “자주 나는 에러” 절에 모아 두었습니다.
설치와 기본 사용
헤더만 포함
#include <nlohmann/json.hpp>
using json = nlohmann::json;
단일 헤더(json.hpp)를 프로젝트에 복사하거나, 패키지 매니저로 설치한 뒤 include 경로만 맞추면 됩니다.
vcpkg로 설치
vcpkg install nlohmann-json
CMake에서 find_package(nlohmann_json CONFIG REQUIRED) 후 target_link_libraries(your_target PRIVATE nlohmann_json::nlohmann_json)로 연동합니다.
FetchContent (CMake)
include(FetchContent)
FetchContent_Declare(
json
URL https://github.com/nlohmann/json/releases/download/v3.12.0/json.tar.xz
)
FetchContent_MakeAvailable(json)
target_link_libraries(your_target PRIVATE nlohmann_json::nlohmann_json)
git 저장소 전체를 클론하는 대신 릴리스의 json.tar.xz(CMake 지원이 포함된 경량 아카이브)를 받는 방식이 공식 README에서 권장하는 방법이고, 전체 저장소보다 훨씬 빨리 받아집니다.
첫 예제
json j = {{“name”, “Alice”}, {“age”, 30}}는 중괄호 초기화로 JSON 객체를 만듭니다. j[“name”], j[“age”]로 키에 접근하면 해당 값이 나오고, 문자열은 std::string으로, 숫자는 int 등으로 자동 변환됩니다. 키가 없을 때 j[“key”]는 null을 넣어 버리므로, 존재 여부를 확인하려면 j.contains(“key”) 또는 j.find(“key”) != j.end()를 먼저 쓰는 것이 안전합니다. API 응답 파싱 시 이 패턴으로 필드가 있는지 확인한 뒤 읽으면 런타임 오류를 줄일 수 있습니다.
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o json_basic json_basic.cpp -I<nlohmann/json 경로> && ./json_basic
// (vcpkg/Conan/FetchContent로 nlohmann-json 설치 후 -I 경로만 맞추면 됨)
#include <nlohmann/json.hpp>
#include <iostream>
using json = nlohmann::json;
int main() {
json j = {{"name", "Alice"}, {"age", 30}};
std::cout << j["name"] << "\n"; // "Alice"
std::cout << j["age"] << "\n"; // 30
return 0;
}
실행 결과: "Alice" 와 30 이 각각 한 줄씩 출력됩니다.
parse·at·value·dump: 파싱과 접근, 직렬화
문자열 파싱
R”(…)”는 raw string 리터럴이라 이스케이프 없이 따옴표를 그대로 쓸 수 있습니다. json::parse(str)는 문자열을 파싱해 json 객체로 만들고, 파싱 실패 시 json::parse_error 예외를 던집니다. 예외 없이 처리하려면 json::parse(str, nullptr, false)처럼 세 번째 인자(allow_exceptions)를 false로 주고, 결과가 j.is_discarded()인지 확인합니다(nullptr가 반환되는 것이 아닙니다). 설정 파일처럼 주석이 들어간 JSON은 네 번째 인자 ignore_comments를 true로 주면 //, /* */ 주석을 허용합니다.
json j = json::parse(input, nullptr, /*allow_exceptions=*/false, /*ignore_comments=*/true);
if (j.is_discarded()) {
// 문법 오류
}
#include <nlohmann/json.hpp>
#include <string>
#include <iostream>
using json = nlohmann::json;
int main() {
// 1. 기본 문자열 파싱
std::string str = R"({"key": "value", "num": 42})";
json j = json::parse(str);
// get<T>로 명시적 타입 변환
std::string key = j["key"].get<std::string>();
int num = j["num"].get<int>();
std::cout << key << ", " << num << "\n"; // value, 42
// 2. 중첩 객체 파싱
std::string nested = R"({
"user": {"name": "Alice", "age": 30},
"tags": ["admin", "user"]
})";
json j2 = json::parse(nested);
std::string name = j2["user"]["name"].get<std::string>();
int age = j2["user"]["age"].get<int>();
std::cout << name << ", " << age << "\n"; // Alice, 30
return 0;
}
실행 결과: value, 42와 Alice, 30이 각각 출력됩니다.
파일 파싱
json::parse는 std::istream도 받을 수 있어서, std::ifstream으로 연 파일을 넘기면 파일 내용 전체를 JSON으로 파싱합니다. 문자열로 먼저 읽지 않아 메모리 면에서 효율적입니다.
#include <nlohmann/json.hpp>
#include <fstream>
#include <stdexcept>
#include <string>
using json = nlohmann::json;
json load_config(const std::string& path) {
std::ifstream f(path);
if (!f) {
throw std::runtime_error("Cannot open file: " + path);
}
try {
return json::parse(f);
} catch (const json::parse_error& e) {
throw std::runtime_error(std::string("JSON parse error: ") + e.what());
}
}
// 사용 예: config.json
// {
// "port": 8080,
// "host": "0.0.0.0"
// }
// json j = load_config("config.json");
// int port = j.value("port", 8080);
config.json 예시:
{
"port": 8080,
"host": "0.0.0.0",
"timeout": 30
}
dump: JSON을 문자열로 직렬화
j.dump()는 한 줄로 압축된 JSON 문자열을 반환하고, j.dump(2)는 들여쓰기 2칸으로 보기 좋게 출력합니다. API 요청 본문이나 로그에 쓸 때는 dump()로 직렬화합니다.
#include <nlohmann/json.hpp>
#include <iostream>
using json = nlohmann::json;
int main() {
json j = {{"name", "Bob"}, {"scores", {10, 20, 30}}};
// 한 줄 압축 (API 요청, 로그에 적합)
std::string compact = j.dump();
std::cout << compact << "\n"; // {"name":"Bob","scores":[10,20,30]}
// 들여쓰기 2칸 (디버깅, 설정 파일 저장에 적합)
std::string pretty = j.dump(2);
std::cout << pretty << "\n";
return 0;
}
dump 옵션: dump(indent) — indent=-1이면 압축, 2면 들여쓰기 2칸. dump(indent, indent_char, ensure_ascii)로 한글 등 비ASCII 문자 이스케이프 여부를 제어할 수 있습니다.
안전한 접근 패턴
| 방법 | 키 없을 때 | 키는 있는데 값이 null·다른 타입일 때 |
|---|---|---|
j["key"] (non-const) | null을 삽입한 뒤 반환 | 그대로 반환 |
j["key"] (const json) | 정의되지 않은 동작(디버그 빌드에서는 assert) | 그대로 반환 |
j.at("key") | out_of_range 예외 | 그대로 반환 |
j.contains("key") | false | true |
j.value("key", default) | default 반환 | type_error 예외 (null도 마찬가지) |
j.find("key") | end() | iterator 반환 |
value()를 “없거나 이상하면 기본값”으로 생각하기 쉬운데, 기본값은 키가 없을 때만 쓰입니다. 외부 API가 필드를 빼는 대신 "timeout": null로 보내면 j.value("timeout", 30)은 예외를 던집니다. const 객체에 operator[]로 없는 키를 읽는 것은 더 위험해서 릴리스 빌드에서는 조용히 잘못된 메모리를 읽을 수 있으므로, const 참조로 받은 JSON에서는 at()이나 find()만 쓰는 편이 안전합니다.
// ❌ 위험: 키가 없으면 null이 삽입됨
auto v = j["optional_key"];
// ✅ 안전: contains로 먼저 확인
if (j.contains("optional_key")) {
auto v = j["optional_key"];
}
// ✅ 안전: value로 기본값 지정
auto v = j.value("optional_key", "default");
int age = j.value("age", 0);
배열 순회
j[“items”]가 배열이면 for (auto& item : j[“items”])로 각 요소를 순회할 수 있습니다.
json j = json::parse(R"({"data": [{"id": 1, "name": "A"}, {"id": 2, "name": "B"}]})");
for (auto& item : j["data"]) {
int id = item["id"].get<int>();
std::string name = item["name"].get<std::string>();
std::cout << id << ": " << name << "\n";
}
객체 생성과 수정
json j;
j["name"] = "Bob";
j["scores"] = {10, 20, 30};
j["nested"] = {{"a", 1}, {"b", 2}};
// 배열에 요소 추가
j["tags"].push_back("c++");
j["tags"].push_back("json");
// 중첩 접근
j["nested"]["c"] = 3;
커스텀 타입 직렬화 (to_json, from_json)
기본 패턴
nlohmann::adl_serializer를 사용해 to_json과 from_json을 정의하면, j.get<User>()와 json(obj)로 자동 변환됩니다.
#include <nlohmann/json.hpp>
#include <string>
using json = nlohmann::json;
struct User {
std::string name;
int age;
std::vector<std::string> tags;
};
// JSON → User (역직렬화)
void from_json(const json& j, User& u) {
j.at("name").get_to(u.name);
j.at("age").get_to(u.age);
if (j.contains("tags")) {
j.at("tags").get_to(u.tags);
}
}
// User → JSON (직렬화)
void to_json(json& j, const User& u) {
j = json{
{"name", u.name},
{"age", u.age},
{"tags", u.tags}
};
}
int main() {
std::string str = R"({"name": "Alice", "age": 30, "tags": ["admin", "user"]})";
json j = json::parse(str);
User u = j.get<User>();
std::cout << u.name << ", " << u.age << "\n";
json j2 = u; // to_json 자동 호출
std::cout << j2.dump(2) << "\n";
return 0;
}
at() vs []의 차이
- j.at(“key”): 키가 없으면 out_of_range 예외가 발생합니다. 검증이 필요할 때 사용합니다.
- j[“key”]: 키가 없으면 null을 삽입한 뒤 반환합니다. 주의해서 사용해야 합니다.
선택적 필드 처리
struct Config {
int port = 8080; // 기본값
std::string host = "localhost";
std::optional<int> timeout; // 선택적
};
void from_json(const json& j, Config& c) {
if (j.contains("port")) c.port = j["port"].get<int>();
if (j.contains("host")) c.host = j["host"].get<std::string>();
if (j.contains("timeout")) c.timeout = j["timeout"].get<int>();
}
void to_json(json& j, const Config& c) {
j = {{"port", c.port}, {"host", c.host}};
if (c.timeout) j["timeout"] = *c.timeout;
}
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE
매크로로 반복 코드를 줄일 수 있습니다(C++11부터 사용 가능). 이 매크로가 만드는 from_json은 모든 필드를 at()으로 읽기 때문에 키가 하나라도 없으면 예외가 납니다. 필드가 빠질 수 있다면 3.11부터 있는 NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT를 쓰면, 없는 키는 기본 생성된 객체의 값을 유지합니다.
struct Point {
double x;
double y;
};
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Point, x, y)
// 사용
Point p{1.0, 2.0};
json j = p;
Point p2 = j.get<Point>();
struct Options {
int retries = 3;
std::string mode = "fast";
};
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(Options, retries, mode)
// json::parse(R"({"mode":"safe"})").get<Options>() → retries = 3 유지
enum은 매크로 NLOHMANN_JSON_SERIALIZE_ENUM으로 문자열 매핑을 선언할 수 있습니다. 아래처럼 직접 to_json/from_json을 쓰는 방법과 결과는 같지만, 매핑에 없는 문자열이 오면 첫 번째 항목으로 조용히 변환된다는 점에 주의하세요. 알 수 없는 값을 에러로 처리해야 한다면 직접 작성하는 편이 낫습니다.
enum과 중첩 구조체 직렬화
enum을 문자열로 직렬화하고, 중첩 구조체를 재귀적으로 처리하는 예제입니다.
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
using json = nlohmann::json;
enum class UserRole { Admin, User, Guest };
// enum → 문자열
void to_json(json& j, UserRole r) {
switch (r) {
case UserRole::Admin: j = "admin"; break;
case UserRole::User: j = "user"; break;
case UserRole::Guest: j = "guest"; break;
}
}
void from_json(const json& j, UserRole& r) {
std::string s = j.get<std::string>();
if (s == "admin") r = UserRole::Admin;
else if (s == "user") r = UserRole::User;
else r = UserRole::Guest;
}
struct Address {
std::string city;
std::string zip;
};
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Address, city, zip)
struct UserWithAddress {
std::string name;
UserRole role;
Address address;
};
void to_json(json& j, const UserWithAddress& u) {
j = {{"name", u.name}, {"role", u.role}, {"address", u.address}};
}
void from_json(const json& j, UserWithAddress& u) {
j.at("name").get_to(u.name);
j.at("role").get_to(u.role);
j.at("address").get_to(u.address);
}
// 사용 예
// json j = UserWithAddress{"Alice", UserRole::Admin, {"Seoul", "12345"}};
// std::cout << j.dump(2);
std::optional과 선택적 필드
C++17 std::optional로 선택적 필드를 안전하게 처리합니다.
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
using json = nlohmann::json;
struct Product {
std::string id;
std::string name;
std::optional<double> price; // 없을 수 있음
};
void from_json(const json& j, Product& p) {
j.at("id").get_to(p.id);
j.at("name").get_to(p.name);
if (j.contains("price") && !j["price"].is_null()) {
p.price = j["price"].get<double>();
}
}
void to_json(json& j, const Product& p) {
j = {{"id", p.id}, {"name", p.name}};
if (p.price) j["price"] = *p.price;
}
parse_error·type_error 처리와 사전 검증
파싱 예외
json::parse는 문법 오류 시 json::parse_error를 던집니다. e.what()과 e.byte로 오류 위치를 확인할 수 있습니다.
#include <nlohmann/json.hpp>
#include <iostream>
// 필요한 모듈 import
using json = nlohmann::json;
json safe_parse(const std::string& str) {
try {
return json::parse(str);
} catch (const json::parse_error& e) {
std::cerr << "JSON 파싱 오류: " << e.what() << "\n";
std::cerr << "바이트 위치: " << e.byte << "\n";
return json::object(); // 빈 객체로 폴백
}
}
타입 예외
get<T>에서 타입이 맞지 않으면 json::type_error가 발생합니다.
try {
int x = j["name"].get<int>(); // "name"이 문자열이면 type_error
} catch (const json::type_error& e) {
std::cerr << "타입 오류: " << e.what() << "\n";
}
is_* 메서드로 사전 검증
타입을 먼저 확인한 뒤 get<T>()를 호출하면 type_error를 방지할 수 있습니다.
if (j["age"].is_number_integer()) {
int age = j["age"].get<int>();
} else if (j["age"].is_string()) {
int age = std::stoi(j["age"].get<std::string>());
}
if (j["data"].is_array()) {
for (auto& item : j["data"]) {
if (item.is_object() && item.contains("id")) {
int id = item["id"].get<int>();
}
}
}
// 지원: is_null, is_boolean, is_number, is_number_integer,
// is_number_float, is_string, is_array, is_object
JSON Schema 검증 (선택)
nlohmann/json 자체에는 스키마 검증이 없습니다. nlohmann/json-schema 또는 valijson 같은 별도 라이브러리로 스키마 검증을 할 수 있습니다.
type_error, 누락 키, operator[]가 넣는 null: 자주 나는 에러
에러 1: type_error — 타입 불일치
증상: "type must be number, but is string" 같은 메시지가 나옵니다.
원인: JSON 필드가 문자열인데 get<int>()로 읽거나, 숫자인데 get<std::string>()으로 읽은 경우입니다.
// ❌ 잘못된 예: "age"가 "30" (문자열)인 경우
int age = j["age"].get<int>(); // type_error!
// ✅ 해결 1: is_* 로 검증 후 변환
if (j["age"].is_number_integer()) {
int age = j["age"].get<int>();
} else if (j["age"].is_string()) {
int age = std::stoi(j["age"].get<std::string>());
}
// ✅ 해결 2: value + 기본값
int age = j.value("age", 0);
에러 2: out_of_range — 누락된 키
증상: j.at("required_key") 호출 시 키가 없으면 out_of_range 예외가 발생합니다.
원인: API 응답에 필드가 없거나, 설정 파일에 키가 누락됩니다.
// ❌ at()은 키 없으면 예외
auto name = j.at("name").get<std::string>();
// ✅ contains로 먼저 확인
if (j.contains("name")) {
auto name = j["name"].get<std::string>();
}
// ✅ value로 기본값
auto name = j.value("name", std::string("unknown"));
에러 3: j[“key”]가 null을 삽입합니다
증상: 읽기 전용인데 j["nonexistent"]를 호출하면 객체에 null이 추가됩니다.
원인: operator[]는 키가 없으면 null을 삽입한 뒤 반환합니다.
// ❌ 읽기만 할 때도 객체가 수정됨
if (j["optional"] != nullptr) { ... } // "optional" 키가 생김!
// ✅ contains 또는 find 사용
if (j.contains("optional")) {
auto v = j["optional"];
}
// 또는
auto it = j.find("optional");
if (it != j.end()) {
auto v = *it;
}
에러 4: parse_error — 잘못된 JSON 문법
증상: "parse error at line 1, column 10" 같은 메시지가 나옵니다.
원인: trailing comma, 따옴표 누락, 인코딩 문제 등이 원인입니다.
// ❌ 잘못된 JSON
std::string bad = R"({"name": "Alice",})"; // trailing comma
// ✅ try/catch로 처리
try {
json j = json::parse(bad);
} catch (const json::parse_error& e) {
std::cerr << "파싱 실패: " << e.what() << "\n";
}
에러 5: 순환 참조
증상: to_json에서 무한 재귀 또는 스택 오버플로우가 발생합니다.
원인: 자기 자신을 참조하는 구조체를 직렬화할 때 생깁니다.
struct Node {
std::string value;
Node* parent; // 순환 참조 가능
};
// ✅ parent는 직렬화에서 제외하거나, ID로 대체
void to_json(json& j, const Node& n) {
j = {{"value", n.value}};
// parent 제외
}
에러 6: 숫자 변환이 조용히 값을 바꿉니다
증상: "price": 19.99를 get<int>()로 읽었더니 예외 없이 19가 나오거나, get<float>() 결과가 원래 값과 미세하게 다릅니다.
원인: nlohmann/json은 숫자 타입끼리의 변환을 static_cast처럼 허용합니다. 실수를 정수로 읽으면 소수점이 잘리고, 음수를 unsigned로 읽으면 큰 양수가 됩니다. 예외가 나지 않으므로 테스트에서 드러나지 않다가 실제 데이터에서 값이 틀어집니다. 저장 자체는 double로 정확히 유지되고 dump()도 왕복 가능한 자릿수로 출력하므로, 손실은 대부분 읽는 쪽 타입 선택에서 생깁니다.
// ❌ 소수점이 조용히 잘림
int price = j["price"].get<int>(); // 19.99 → 19
// ✅ 정수가 와야 하는 필드는 타입을 먼저 확인
if (!j["count"].is_number_integer()) throw std::runtime_error("count must be integer");
// ✅ 금액은 double보다 정수(최소 단위, 예: 원·센트)나 문자열 + decimal 라이브러리로 주고받기
std::int64_t price_cents = j["price_cents"].get<std::int64_t>();
큰 정수도 알아 둘 부분입니다. nlohmann/json은 64비트 정수를 정확히 저장하므로 C++ 쪽에서는 문제가 없지만, 같은 JSON을 JavaScript 클라이언트가 읽으면 2^53을 넘는 ID가 반올림됩니다. 트위터 스타일의 64비트 ID를 브라우저와 주고받는다면 서버에서 문자열로 내보내는 것이 안전합니다.
에러 7: UTF-8 BOM 및 인코딩
증상: 파일 파싱 시 "parse error at line 1, column 1"이 나거나 첫 문자가 깨집니다.
원인: nlohmann/json의 parse는 UTF-8 BOM(EF BB BF)을 자동으로 건너뛰므로 UTF-8 BOM 자체는 대개 문제가 되지 않습니다. 문제가 되는 것은 Windows 메모장의 옛 “유니코드” 저장처럼 UTF-16으로 저장된 파일(FF FE로 시작)입니다. nlohmann/json은 UTF-8만 입력으로 받으므로, 이런 파일은 먼저 UTF-8로 변환해야 합니다. 아래 함수는 파서 앞단에서 직접 바이트를 다룰 때(다른 파서로 넘기거나 로그로 남길 때) 쓸 수 있는 BOM 제거 예시입니다.
// ✅ BOM 제거 후 파싱 (구버전 호환)
std::string read_json_file(const std::string& path) {
std::ifstream f(path, std::ios::binary);
std::string content((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
if (content.size() >= 3 &&
static_cast<unsigned char>(content[0]) == 0xEF &&
static_cast<unsigned char>(content[1]) == 0xBB &&
static_cast<unsigned char>(content[2]) == 0xBF) {
content = content.substr(3);
}
return content;
}
에러 8: 빈 문자열/배열 타입 혼동
증상: 빈 배열에 j["data"][0]로 접근했더니 예외 없이 배열에 null 원소가 생깁니다.
원인: non-const operator[]는 배열에서도 범위를 벗어난 인덱스까지 null로 채워 늘립니다. 객체의 없는 키를 삽입하는 것과 같은 동작이라, 읽기만 하려던 코드가 데이터를 바꿉니다. 범위 검사가 필요하면 at(0)(범위 밖이면 out_of_range)을 쓰거나 empty()를 먼저 확인합니다.
// ❌ 빈 배열일 때 item["id"] 접근 시 문제
for (auto& item : j["data"]) {
int id = item["id"].get<int>(); // 빈 배열이면 순회 안 함 (OK)
}
// 하지만 j["data"][0] 직접 접근 시
// auto x = j["data"][0]["id"]; // data가 []면 [ {"id": null} ]로 바뀌어 버림
// ✅ size() 확인 후 접근
if (!j["data"].empty()) {
auto first = j["data"][0];
}
필수·선택 필드 구분, 파싱 래퍼 일원화, ordered_json
필수 필드 vs 선택 필드 구분
필수 필드는 at()으로 검증하고, 선택 필드는 contains() + value()로 처리합니다.
#include <nlohmann/json.hpp>
#include <optional>
using json = nlohmann::json;
struct ApiResponse {
int code; // 필수
std::string message; // 필수
std::optional<json> data; // 선택 (구조가 가변적일 때)
};
void from_json(const json& j, ApiResponse& r) {
r.code = j.at("code").get<int>();
r.message = j.at("message").get<std::string>();
if (j.contains("data") && !j["data"].is_null()) {
r.data = j["data"];
}
}
파싱 래퍼 일원화
프로젝트 전체에서 json::parse를 직접 호출하지 않고, 로깅·폴백을 포함한 래퍼를 사용합니다.
#include <nlohmann/json.hpp>
#include <optional>
#include <functional>
using json = nlohmann::json;
std::optional<json> parse_json_safe(const std::string& input,
std::function<void(const std::string&)> on_error = nullptr) {
try {
return json::parse(input);
} catch (const json::parse_error& e) {
if (on_error) on_error(e.what());
return std::nullopt;
}
}
// 사용
auto j = parse_json_safe(api_response, [](const std::string& msg) {
std::cerr << "JSON 파싱 실패: " << msg << "\n";
});
if (j) { /* 정상 처리 */ }
네임스페이스와 ADL
to_json/from_json은 ADL(Argument-Dependent Lookup)으로 찾습니다. 구조체와 같은 네임스페이스에 두거나, nlohmann 네임스페이스에 특수화합니다.
namespace myapp {
struct User { std::string name; int age; };
void to_json(json& j, const User& u) {
j = {{"name", u.name}, {"age", u.age}};
}
void from_json(const json& j, User& u) {
j.at("name").get_to(u.name);
j.at("age").get_to(u.age);
}
} // namespace myapp
불변성 유지
읽기 전용 접근 시 j["key"] 대신 j.contains()를 먼저 확인해 원본 객체에 null을 삽입하지 않습니다.
dump 옵션 프로젝트별 통일
API 요청은 dump()(압축), 로그/디버깅은 dump(2)로 통일해 가독성과 일관성을 유지합니다.
dump()는 문자열에 잘못된 UTF-8 바이트가 섞여 있으면 기본적으로 type_error(316)를 던집니다. 외부 입력이나 레거시 CP949 문자열이 섞일 수 있는 로그 경로라면 j.dump(-1, ' ', false, json::error_handler_t::replace)처럼 네 번째 인자로 잘못된 바이트를 U+FFFD로 바꾸게 해서, 로그 한 줄 때문에 서비스가 예외로 죽는 일을 막습니다.
키 순서가 중요하면 ordered_json
nlohmann::json의 객체는 std::map에 저장되므로 dump() 결과의 키가 알파벳순으로 정렬됩니다. 설정 파일을 읽어 일부만 고친 뒤 다시 저장하면 사람이 정리해 둔 키 순서가 다 바뀌어 diff가 크게 생기는데, 이럴 때는 입력 순서를 보존하는 nlohmann::ordered_json을 씁니다.
nlohmann::ordered_json cfg = nlohmann::ordered_json::parse(file);
cfg["timeout"] = 60;
out << cfg.dump(2); // 원래 키 순서 유지
ordered_json은 내부적으로 벡터에 순서대로 저장하므로 키 검색이 선형입니다. 키가 수천 개인 객체를 자주 조회하는 용도에는 맞지 않고, 사람이 편집하는 설정 파일이나 출력 순서가 정해진 API 응답에 적합합니다. json과 ordered_json은 서로 다른 타입이라 to_json/from_json도 따로 맞춰야 한다는 점도 기억해 두세요.
nlohmann/json이 느린 이유와 SAX·json_fwd로 비용 줄이기
nlohmann/json vs 다른 라이브러리
| 라이브러리 | 특징 | 쓰기(생성) | 잘 맞는 곳 |
|---|---|---|---|
| nlohmann/json | STL 같은 API, 커스텀 타입 매핑이 가장 쉬움, 헤더 전용 | ✅ | 설정, REST 응답, 도구, 테스트 |
| RapidJSON | 할당자 제어, in-situ 파싱, 헤더 전용이지만 API가 장황함 | ✅ | 게임, 메모리 제약 환경 |
| simdjson | SIMD 파싱, On-Demand API로 필요한 필드만 읽음 | ❌ (파싱 전용) | 대용량 로그·고처리량 수신 |
| yyjson | C 라이브러리, 파싱·생성 모두 매우 빠름 | ✅ | 성능이 중요한데 쓰기도 필요할 때 |
| Glaze | C++20 리플렉션 방식, 구조체와 직접 매핑 | ✅ | 최신 컴파일러에서 속도와 편의 둘 다 |
nlohmann/json이 느린 주된 이유는 모든 값을 동적 할당되는 트리(DOM)로 만들고 객체를 std::map에 넣기 때문입니다. 다른 라이브러리로 바꾸기 전에 먼저 JSON 처리가 실제로 병목인지 프로파일러로 확인하세요. 제가 본 경우 대부분은 파싱이 아니라 네트워크나 DB가 병목이었고, 파싱이 병목이었던 경우에도 필요한 필드 몇 개만 읽으면 되는 상황이라 simdjson의 On-Demand API로 그 경로만 바꾸는 것으로 충분했습니다.
파싱 최적화 팁
// 1. 큰 파일은 스트림으로 파싱 (메모리 절약)
std::ifstream f("large.json");
json j = json::parse(f);
// 2. 반복 파싱 시 문자열 재사용
std::string buffer;
buffer.reserve(4096);
// ... buffer에 데이터 채운 뒤
json j = json::parse(buffer);
// 3. dump 결과 캐싱
std::string cached = j.dump();
// 여러 번 사용할 때 한 번만 dump
컴파일 시간
헤더 전용이라 include하는 순간 컴파일 시간이 늘어납니다. nlohmann/json_fwd.hpp를 사용하면 전방 선언만 하고, 실제 사용하는 .cpp에서만 json.hpp를 include해 컴파일 시간을 줄일 수 있습니다.
// header.h
#include <nlohmann/json_fwd.hpp>
void process(const nlohmann::json& j);
// impl.cpp
#include <nlohmann/json.hpp> // 여기서만 전체 정의
SAX/이벤트 기반 파싱 (대용량)
수십 MB 이상의 JSON에서 특정 키만 추출할 때는 json::sax 파서를 사용해 DOM 전체를 만들지 않고 스트리밍으로 처리할 수 있습니다.
#include <nlohmann/json.hpp>
#include <iostream>
using json = nlohmann::json;
// json_sax<json>의 순수 가상 함수를 모두 구현해야 함
struct KeyExtractor : nlohmann::json_sax<json> {
std::string target_key;
std::string current_key;
std::vector<std::string> values;
explicit KeyExtractor(std::string k) : target_key(std::move(k)) {}
bool key(string_t& val) override { current_key = val; return true; }
bool string(string_t& val) override {
if (current_key == target_key) values.push_back(val);
return true;
}
bool null() override { return true; }
bool boolean(bool) override { return true; }
bool number_integer(number_integer_t) override { return true; }
bool number_unsigned(number_unsigned_t) override { return true; }
bool number_float(number_float_t, const string_t&) override { return true; }
bool binary(binary_t&) override { return true; }
bool start_object(std::size_t) override { return true; }
bool end_object() override { return true; }
bool start_array(std::size_t) override { return true; }
bool end_array() override { return true; }
bool parse_error(std::size_t pos, const std::string&, const nlohmann::detail::exception& ex) override {
std::cerr << "parse error at " << pos << ": " << ex.what() << "\n";
return false; // 파싱 중단
}
};
int main() {
std::ifstream f("users.json");
KeyExtractor h("name");
json::sax_parse(f, &h); // DOM을 만들지 않고 이벤트만 받음
for (auto& v : h.values) std::cout << v << "\n";
}
이 예제는 단순화를 위해 중첩 깊이를 추적하지 않아서, 다른 위치에 있는 같은 이름의 키도 함께 잡힙니다. 실제로 쓸 때는 start_object/end_object에서 경로 스택을 관리하세요. 파일 전체를 필드 몇 개 때문에 읽는 경우라면 SAX 대신 NDJSON으로 형식을 바꾸거나 simdjson On-Demand를 쓰는 편이 코드가 훨씬 짧습니다.
직접 측정하기
인터넷의 벤치마크 수치는 JSON 모양(숫자 위주인지 문자열 위주인지, 중첩 깊이), 컴파일러, CPU에 따라 몇 배씩 달라집니다. 실제 서비스에서 받는 JSON 샘플로 파싱 시간을 재 보는 것이 가장 정확합니다.
#include <chrono>
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < 1000; ++i) {
auto j = json::parse(sample); // 실제 응답 샘플
}
auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
std::cout << "avg " << ms / 1000 << " ms\n";
반드시 최적화 빌드(-O2, Release)로 재세요. 디버그 빌드에서 nlohmann/json은 템플릿 인라이닝이 안 되어 릴리스보다 훨씬 느리게 나오고, 이 숫자를 보고 라이브러리를 바꾸는 결정을 하면 안 됩니다.
API 응답·설정 파일·NDJSON 로그를 다루는 패턴
API 응답 처리 흐름
sequenceDiagram
participant Client as C++ 클라이언트
participant API as REST API
participant JSON as nlohmann/json
Client->>API: HTTP GET /users
API->>Client: {"data":[{"id":1,"name":"Alice"}]}
Client->>JSON: json::parse(response_body)
JSON->>Client: json 객체
Client->>JSON: res["data"].get<vector<User>>()
JSON->>Client: vector<User>
API 응답 처리
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
using json = nlohmann::json;
struct ApiItem {
int id;
std::string name;
};
void from_json(const json& j, ApiItem& item) {
j.at("id").get_to(item.id);
j.at("name").get_to(item.name);
}
std::vector<ApiItem> parse_api_response(const std::string& response_body) {
std::vector<ApiItem> result;
try {
json res = json::parse(response_body);
if (!res.contains("data") || !res["data"].is_array()) {
return result;
}
for (auto& item : res["data"]) {
result.push_back(item.get<ApiItem>());
}
} catch (const json::exception& e) {
// 로깅 후 빈 결과 반환
return result;
}
return result;
}
설정 파일 로드
#include <nlohmann/json.hpp>
#include <fstream>
#include <optional>
using json = nlohmann::json;
struct AppConfig {
int port = 8080;
std::string host = "0.0.0.0";
std::optional<std::string> log_level;
};
void from_json(const json& j, AppConfig& c) {
if (j.contains("port")) c.port = j["port"].get<int>();
if (j.contains("host")) c.host = j["host"].get<std::string>();
if (j.contains("log_level")) c.log_level = j["log_level"].get<std::string>();
}
AppConfig load_config(const std::string& path) {
std::ifstream f(path);
if (!f) {
return AppConfig{}; // 기본 설정 반환
}
try {
return json::parse(f).get<AppConfig>();
} catch (const json::exception& e) {
return AppConfig{};
}
}
요청 본문 생성
json create_request_body(const std::string& action, const std::vector<int>& ids) {
return {
{"action", action},
{"ids", ids},
{"timestamp", std::time(nullptr)}
};
}
// HTTP 클라이언트에 전달
std::string body = create_request_body("delete", {1, 2, 3}).dump();
로그 직렬화
struct LogEntry {
std::string level;
std::string message;
std::time_t timestamp;
};
void to_json(json& j, const LogEntry& e) {
j = {
{"level", e.level},
{"message", e.message},
{"timestamp", e.timestamp}
};
}
// 로그를 JSON 한 줄로 출력
LogEntry entry{"INFO", "User logged in", std::time(nullptr)};
std::cout << json(entry).dump() << "\n";
에러 복구 가능한 파싱 래퍼
외부 API나 사용자 입력은 항상 잘못된 JSON일 수 있으므로, 파싱 실패 시 로깅하고 기본값을 반환하는 래퍼를 두는 것이 좋습니다.
#include <nlohmann/json.hpp>
#include <optional>
#include <iostream>
using json = nlohmann::json;
struct ParseResult {
json data;
bool ok;
std::string error_message;
};
ParseResult safe_parse_with_logging(const std::string& input) {
try {
return {json::parse(input), true, ""};
} catch (const json::parse_error& e) {
std::cerr << "[JSON] 파싱 실패: " << e.what()
<< " (byte " << e.byte << ")\n";
return {json::object(), false, e.what()};
}
}
// 사용
auto result = safe_parse_with_logging(api_response);
if (result.ok) {
// 정상 처리
} else {
// 폴백 또는 재시도
}
설정 파일 환경별 오버라이드
환경 변수로 JSON 설정을 오버라이드하는 패턴입니다.
#include <nlohmann/json.hpp>
#include <cstdlib>
#include <fstream>
#include <string>
using json = nlohmann::json;
json load_config_with_env_override(const std::string& path) {
std::ifstream f(path);
json j = f ? json::parse(f) : json::object();
// 환경 변수로 오버라이드
if (const char* port = std::getenv("APP_PORT")) {
j["port"] = std::stoi(port);
}
if (const char* host = std::getenv("APP_HOST")) {
j["host"] = host;
}
return j;
}
NDJSON(Newline-Delimited JSON) 스트리밍
로그·이벤트 스트림을 한 줄씩 파싱해 메모리 사용을 최소화합니다.
void process_ndjson(const std::string& path, auto on_line) {
std::ifstream f(path);
std::string line;
while (std::getline(f, line)) {
if (line.empty()) continue;
try { on_line(json::parse(line)); }
catch (const json::parse_error&) { /* 스킵 */ }
}
}
숫자/문자열 혼용 필드 처리
API가 "count": 100 또는 "count": "100"을 보낼 때 모두 처리하는 헬퍼입니다.
int get_int_flexible(const json& j, const std::string& key, int d = 0) {
auto it = j.find(key); // const json에서는 operator[] 대신 find/at
if (it == j.end() || it->is_null()) return d;
if (it->is_number_integer()) return it->get<int>();
if (it->is_string()) {
const auto& s = it->get_ref<const std::string&>();
int out = d;
auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), out); // <charconv>, 예외 없음
return (ec == std::errc{} && p == s.data() + s.size()) ? out : d;
}
return d;
}
배포 전에 확인할 것
- 파싱: try/catch로 parse_error 처리
- 접근: contains 또는 value로 누락 키 방어
- 타입: is_* 검증 또는 get<T> 예외 처리
- 커스텀 타입: to_json/from_json로 도메인 객체 매핑
- 대용량: 스트림 파싱, dump 캐싱
- 컴파일 시간: json_fwd.hpp 활용
- 로깅: 파싱 실패 시 에러 메시지와 위치 기록
- 환경 연동: 환경 변수로 설정 오버라이드 지원
자주 묻는 질문 (FAQ)
Q. to_json/from_json을 정의했는데 “no matching function” 컴파일 에러가 납니다.
A. 대부분 ADL 문제입니다. to_json/from_json은 변환할 타입과 같은 네임스페이스에 있어야 nlohmann/json이 찾을 수 있습니다. 구조체는 myapp 네임스페이스에 두고 함수는 전역에 두었거나, 익명 네임스페이스 안에 넣은 경우에 자주 납니다. 서드파티 타입처럼 네임스페이스에 함수를 추가할 수 없다면 nlohmann::adl_serializer<T>를 특수화합니다.
Q. dump()한 결과의 키 순서가 원본과 다릅니다.
A. nlohmann::json의 객체는 기본적으로 std::map으로 저장되어 키가 알파벳순으로 정렬됩니다. 입력 순서를 유지해야 한다면 nlohmann::ordered_json을 쓰세요. 대신 키 검색이 선형이라 키가 아주 많은 객체에서는 느려질 수 있습니다.
nlohmann/json으로 JSON 파싱·생성을 타입 안전하게 할 수 있습니다. to_json/from_json으로 커스텀 타입을 직렬화하고, contains/value로 안전하게 접근하며, 프로덕션 패턴까지 적용해 보세요. 이전 글: C++ 실전 가이드 #27-1: Boost 다음 글: [C++ 실전 가이드 #27-3] 로깅 라이브러리 (spdlog): 빠른 로깅과 다중 싱크