C++ JSON 파싱: nlohmann/json vs RapidJSON, 커스텀 타입 직렬화, null·타입 에러 처리

들어가며: “API 응답 파싱하다가 크래시가 나요”

필드 접근 한 줄에서 터지는 크래시

// ❌ 문제: HTTP 클라이언트로 API 응답을 받았는데...
// - {"users": [{"id": 1, "name": "Alice"}]} 를 어떻게 구조체로?
// - "age"가 30(숫자)인데 "30"(문자열)으로 오면?
// - 키가 없는데 j["optional"] 접근 → null 삽입 → 나중에 get<int>() 크래시
// - 10MB JSON을 한 번에 메모리에 올리면 OOM

실제 프로덕션에서 겪는 문제들:

  • 타입 불일치: API가 "age": 30(숫자)과 "age": "30"(문자열)을 혼용 → get<int>() 실패
  • null/누락 키 접근: j["optional"]이 없으면 null이 삽입되고, 이후 get<T>()에서 type_error 발생
  • 파싱 실패: 잘못된 JSON(쉼표 누락, 따옴표 미닫힘) → parse_error 예외
  • 메모리 폭발: 대용량 JSON을 std::string으로 읽어 parse()하면 원본+파싱 결과로 메모리 2배
  • 커스텀 타입 변환: User, Config 같은 구조체 ↔ JSON 수동 변환은 번거롭고 실수하기 쉬움 해결책:
  1. 적절한 라이브러리 선택: nlohmann/json(편의성), RapidJSON(성능)
  2. 안전한 접근 패턴: contains, value, at으로 null/누락 방지
  3. 커스텀 직렬화: to_json/from_json으로 타입 안전성 확보
  4. 에러 분류: 파싱 에러 vs 타입 에러 vs 비즈니스 검증 분리
  5. 스트림 파싱: 대용량 시 SAX 스타일로 메모리 절약

nlohmann/json과 RapidJSON으로 REST API 응답과 설정 파일을 파싱하는 예제를 중심으로, 구조체·enum·optional의 커스텀 직렬화, 타입 에러와 null 접근을 피하는 접근 패턴, 두 라이브러리의 성능 차이를 다룹니다.


전형적인 실패 패턴과 안전한 파싱 구조

전형적인 실패 패턴

sequenceDiagram
  participant App as 애플리케이션
  participant HTTP as HTTP 클라이언트
  participant API as 외부 API
  App->>HTTP: GET /api/users
  HTTP->>API: 요청
  API-->>HTTP: {"data": [{"id": 1, "name": "Alice"}]}
  HTTP-->>App: body (문자열)
  Note over App: 파싱 필요
  App->>App: json::parse(body)
  Note over App: j[data][0][age] 접근\nage 키 없음 → null
  App->>App: .get<int>() → type_error!

문제 요약:

  • HTTP 응답 본문은 문자열 → 구조화된 데이터로 변환 필요
  • 키 누락·null·타입 불일치 시 런타임 크래시
  • 수동 파싱(strstr, 정규식)은 이스케이프·중첩 처리 지옥

API 버전마다 다른 응답 구조

외부 API 응답 버전 차이

API v1은 {"user": {"name": "Alice"}}, v2는 {"user": null}을 반환합니다. j["user"]["name"] 접근 시 v2에서 null 참조로 크래시합니다.

설정 파일 마이그레이션

config.json에 timeout 필드가 새 버전에서 추가됐는데, 구버전 설정에는 없습니다. j["timeout"].get<int>() 호출 시 type_error가 발생합니다.

숫자 vs 문자열 혼용

일부 API는 "count": 100, 다른 엔드포인트는 "count": "100"을 보냅니다. 타입 검증 없이 get<int>()만 쓰면 파싱 실패가 발생합니다.

대용량 로그 파일

수 MB 크기의 JSON 로그를 std::string으로 읽어 parse()하면 메모리가 두 배로 사용됩니다. 스트림 파싱 또는 SAX API로 메모리를 절약해야 합니다.

멀티스레드 환경

여러 스레드가 동일 json 객체에 접근하면 data race가 발생할 수 있습니다. 파싱 결과를 스레드별로 복사하거나 불변으로 유지해야 합니다.

이 문제들의 공통점은 JSON은 스키마가 없는 포맷이라는 것입니다. C++ 구조체는 컴파일 시점에 필드와 타입이 정해져 있지만, 네트워크에서 온 JSON은 무엇이든 될 수 있습니다. 그래서 JSON 파싱 코드의 본질은 문법 해석보다 “신뢰할 수 없는 입력을 타입이 정해진 C++ 값으로 옮기는 경계 검사”에 있습니다. 이 경계를 한 곳(from_json 함수나 파싱 계층)에 모아 두면, 나머지 코드는 이미 검증된 구조체만 다루면 되므로 contains 검사가 코드 전체에 흩어지지 않습니다.

해결 아키텍처

flowchart TB
  subgraph 입력
    A[HTTP 응답 문자열]
    B[파일 스트림]
    C[소켓 버퍼]
  end
  subgraph 파싱
    D{라이브러리}
    D --> E[nlohmann/json]
    D --> F[RapidJSON]
    E --> G[json 객체]
    F --> G
  end
  subgraph 검증
    G --> H[contains/value 체크]
    H --> I[타입 검증 is_*]
    I --> J[커스텀 타입 변환]
  end
  subgraph 출력
    J --> K[구조체]
    J --> L[비즈니스 로직]
  end
  A --> D
  B --> D
  C --> D

라이브러리 비교: nlohmann vs RapidJSON

항목nlohmann/jsonRapidJSON
헤더 전용✅✅
API 스타일STL 유사, 직관적C 스타일, 명시적
성능보통매우 빠름
메모리상대적으로 많음적음 (in-place 등)
커스텀 직렬화to_json/from_json 간편수동 구현
에러 처리예외 기반반환값 + HasParseError
C++ 표준C++11C++11
추천 용도REST API, 설정, 프로토타입고성능 서버, 대용량, 임베디드

선택 가이드:

  • nlohmann: 개발 속도·가독성 우선, 대부분의 웹 API·설정 파싱
  • RapidJSON: 초당 수만 건 파싱, 메모리 제한 환경, 로그 파이프라인

두 라이브러리의 속도 차이는 설계에서 나옵니다. nlohmann의 json 값은 객체를 std::map(기본), 배열을 std::vector, 문자열을 std::string으로 담으므로 노드마다 힙 할당이 생깁니다. 그 대가로 STL 컨테이너처럼 쓰고, 복사·비교·to_json 확장이 자연스럽습니다. RapidJSON은 Document마다 메모리 풀 할당자를 두고 값을 그 안에 촘촘히 배치하며, 옵션에 따라 입력 버퍼를 제자리에서 수정하는 in-situ 파싱도 지원합니다. 대신 값의 수명이 할당자에 묶이고, 문자열을 추가할 때마다 alloc을 넘겨야 하며, 복사 대신 이동(move) 의미론이 기본이라 실수하기 쉽습니다.

한 가지 더 알아 둘 점은 nlohmann의 기본 객체 타입이 std::map이라 키가 알파벳순으로 정렬된다는 것입니다. dump() 결과의 키 순서가 입력과 다르다며 당황하는 경우가 많은데, 입력 순서를 유지해야 하면 nlohmann::ordered_json을 쓰면 됩니다(조회는 조금 느려집니다).


nlohmann/json으로 안전하게 파싱하기

설치

# vcpkg
vcpkg install nlohmann-json
# 또는 FetchContent (CMake)
# include(FetchContent)
# FetchContent_Declare(json GIT_REPOSITORY https://github.com/nlohmann/json.git GIT_TAG v3.11.2)
# FetchContent_MakeAvailable(json)

기본 파싱: 문자열·파일

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o json_parse json_parse.cpp -I<경로> && ./json_parse
#include <nlohmann/json.hpp>
#include <fstream>
#include <iostream>
#include <string>
using json = nlohmann::json;
int main() {
    // 1. 문자열 파싱
    std::string str = R"({"name": "Alice", "age": 30, "tags": ["admin", "user"]})";
    json j = json::parse(str);
    std::string name = j["name"].get<std::string>();
    int age = j["age"].get<int>();
    std::cout << name << ", " << age << "\n";  // Alice, 30
    // 2. 배열 순회
    for (auto& tag : j["tags"]) {
        std::cout << tag.get<std::string>() << " ";
    }
    std::cout << "\n";  // admin user
    // 3. 파일 파싱 (config.json)
    std::ifstream f("config.json");
    if (f) {
        json config = json::parse(f);
        int port = config.value("port", 8080);  // 기본값 8080
        std::string host = config.value("host", "localhost");
        std::cout << "port=" << port << ", host=" << host << "\n";
    }
    return 0;
}

안전한 접근 패턴

#include <nlohmann/json.hpp>
#include <iostream>
using json = nlohmann::json;
void safe_access_example(const json& j) {
    // ❌ 위험: 키가 없으면 null 삽입
    // auto v = j["optional_key"];
    // ✅ contains로 존재 확인
    if (j.contains("optional_key")) {
        auto v = j["optional_key"];
        std::cout << v << "\n";
    }
    // ✅ value로 기본값 지정 (키가 없을 때만 default 반환,
    //    키가 있는데 null이나 다른 타입이면 type_error 예외)
    int timeout = j.value("timeout", 30);
    std::string env = j.value("env", "development");
    // ✅ at(): 키 없으면 out_of_range 예외 (검증 필요 시)
    try {
        std::string required = j.at("required_field").get<std::string>();
    } catch (const json::out_of_range& e) {
        std::cerr << "필수 필드 누락: " << e.what() << "\n";
    }
    // ✅ find로 이터레이터 사용
    auto it = j.find("maybe_null");
    if (it != j.end() && !it->is_null()) {
        int val = it->get<int>();
    }
}

네 가지 접근 방식은 “키가 없으면 어떻게 할 것인가”에 대한 서로 다른 답입니다. at()은 없는 것을 버그로 보고 예외를 던지므로 필수 필드에 맞고, value()는 없는 것을 정상으로 보고 기본값을 쓰므로 선택 설정에 맞습니다. find()는 존재 확인과 접근을 한 번의 탐색으로 끝내므로, contains() 후 operator[]로 다시 찾는 방식보다 효율적이고 null 검사까지 함께 할 수 있습니다.

가장 흔한 함정은 value()가 null을 처리해 준다고 믿는 것입니다. 실제로는 키가 없을 때만 기본값을 돌려주고, {"timeout": null}처럼 키가 있고 값이 null이면 int로 변환하다가 [json.exception.type_error.302] type must be number, but is null을 던집니다. 선택 필드를 null로 보내는 API가 의외로 많아서, 처음 외부 API를 붙일 때 이 예외로 서비스가 죽는 경우가 잦습니다. 누락과 null을 똑같이 기본값으로 처리하고 싶다면 find() 결과의 is_null()까지 확인하는 작은 도우미 함수를 만들어 두는 편이 안전합니다.

또 하나, 비-const json에서 j["key"]는 읽기처럼 보여도 쓰기 연산입니다. 키가 없으면 null 값을 삽입하므로, 로그를 찍으려고 j["debug"]를 한 번 읽었을 뿐인데 나중에 dump()한 결과에 "debug": null이 생기는 식의 부작용이 생깁니다. 파싱한 JSON을 const json&로 넘기는 습관을 들이면 이런 실수를 컴파일러가 막아 줍니다(const에서 없는 키를 operator[]로 접근하면 정의되지 않은 동작이므로, const 쪽에서는 at()이나 find()를 씁니다).

중첩 객체·배열 파싱

#include <nlohmann/json.hpp>
#include <iostream>
#include <vector>
using json = nlohmann::json;
struct UserSummary {
    int id;
    std::string name;
};
std::vector<UserSummary> parse_users_response(const std::string& body) {
    json j = json::parse(body);
    std::vector<UserSummary> users;
    // {"data": {"users": [{"id": 1, "name": "Alice"}, ...]}}
    if (!j.contains("data") || !j["data"].contains("users")) {
        return users;
    }
    for (auto& item : j["data"]["users"]) {
        if (!item.contains("id") || !item.contains("name")) continue;
        users.push_back({
            item["id"].get<int>(),
            item["name"].get<std::string>()
        });
    }
    return users;
}
int main() {
    std::string api_response = R"({
        "data": {
            "users": [
                {"id": 1, "name": "Alice"},
                {"id": 2, "name": "Bob"}
            ]
        }
    })";
    auto users = parse_users_response(api_response);
    for (const auto& u : users) {
        std::cout << u.id << ": " << u.name << "\n";
    }
    return 0;
}

에러 처리와 예외

#include <nlohmann/json.hpp>
#include <iostream>
#include <optional>
using json = nlohmann::json;
std::optional<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 std::nullopt;
    }
}
int main() {
    // 잘못된 JSON: 쉼표 누락
    std::string bad = R"({"a": 1 "b": 2})";
    auto j = safe_parse(bad);
    if (!j) {
        std::cout << "파싱 실패, 폴백 처리\n";
        return 1;
    }
    // 타입 에러 방지: is_* 검증
    json j2 = json::parse(R"({"age": "thirty"})");
    if (j2["age"].is_number_integer()) {
        int age = j2["age"].get<int>();
    } else {
        std::cout << "age가 숫자가 아님\n";
    }
    return 0;
}

nlohmann은 예외를 세 계열로 나눕니다. parse_error(문법 오류, e.byte로 위치 제공), type_error(타입이 맞지 않는 변환), out_of_range(at()에서 없는 키나 인덱스)입니다. 모두 json::exception을 상속하므로, 요청 하나를 처리하는 경계에서 json::exception을 한 번에 잡고 개별 에러는 로그에 남기는 구조가 일반적입니다. e.what() 메시지에는 [json.exception.type_error.302]처럼 에러 번호가 들어 있어 문서에서 원인을 찾기 쉽습니다.

예외 대신 반환값으로 실패를 처리하고 싶다면 json::parse(str, nullptr, false)처럼 세 번째 인자 allow_exceptions를 false로 주면 됩니다. 이 경우 실패 시 예외 대신 is_discarded()가 true인 값을 돌려주므로, 예외를 끈(-fno-exceptions) 빌드나 잘못된 입력이 흔한 경로에서 유용합니다. 다만 이 옵션은 파싱 단계만 해당하고 이후 get<T>()의 type_error는 여전히 예외입니다.

숫자 변환은 조용히 값을 바꿀 수 있다는 점도 기억해야 합니다. {"age": 30.7}에 get<int>()를 호출하면 예외 없이 30이 되고, int 범위를 넘는 정수도 경고 없이 잘립니다. 금액·ID처럼 정확성이 중요한 필드는 is_number_integer()와 범위를 직접 확인하거나 std::int64_t로 받는 편이 안전합니다.


RapidJSON으로 파싱하고 생성하기

설치

# vcpkg
vcpkg install rapidjson
# 또는 헤더만 복사: include/rapidjson 폴더를 프로젝트에

기본 파싱

// g++ -std=c++17 -o rapidjson_parse rapidjson_parse.cpp -I<경로> && ./rapidjson_parse
#include <rapidjson/document.h>
#include <rapidjson/error/en.h>
#include <iostream>
#include <string>
using namespace rapidjson;
int main() {
    const char* json = R"({"name": "Alice", "age": 30, "active": true})";
    Document doc;
    doc.Parse(json);
    if (doc.HasParseError()) {
        std::cerr << "파싱 오류: " << GetParseError_En(doc.GetParseError())
                  << " (offset: " << doc.GetErrorOffset() << ")\n";
        return 1;
    }
    // 객체인지 확인
    if (!doc.IsObject()) {
        std::cerr << "JSON이 객체가 아님\n";
        return 1;
    }
    // HasMember로 키 존재 확인
    if (doc.HasMember("name") && doc["name"].IsString()) {
        std::cout << "name: " << doc["name"].GetString() << "\n";
    }
    if (doc.HasMember("age") && doc["age"].IsInt()) {
        std::cout << "age: " << doc["age"].GetInt() << "\n";
    }
    if (doc.HasMember("active") && doc["active"].IsBool()) {
        std::cout << "active: " << (doc["active"].GetBool() ? "true" : "false") << "\n";
    }
    return 0;
}

RapidJSON 코드가 HasMember와 IsXxx를 매번 짝지어 쓰는 데에는 이유가 있습니다. RapidJSON은 성능을 위해 대부분의 잘못된 접근을 예외가 아니라 RAPIDJSON_ASSERT로 처리합니다. 없는 멤버를 doc["name"]으로 접근하거나 문자열 값에 GetInt()를 호출하면, 디버그 빌드에서는 assert로 프로그램이 즉시 종료되고 릴리스 빌드(NDEBUG)에서는 assert가 사라져 정의되지 않은 동작이 됩니다. 테스트에서는 잘 죽던 코드가 운영에서는 쓰레기 값을 내는 식이라 추적이 어렵습니다. HasMember 후 doc["name"]은 멤버를 두 번 찾으므로, 많이 반복되는 경로라면 FindMember로 한 번에 찾고 반환된 이터레이터를 쓰는 편이 낫습니다.

숫자 타입 검사도 nlohmann보다 엄격합니다. IsInt()는 값이 32비트 부호 있는 정수 범위일 때만 true이므로, 3000000000 같은 큰 ID는 IsInt()가 false이고 IsInt64()나 IsUint()로 확인해야 합니다. 30.0은 IsDouble()이 true이고 IsInt()는 false입니다.

파일 파싱 (스트림)

#include <rapidjson/document.h>
#include <rapidjson/filereadstream.h>
#include <rapidjson/error/en.h>
#include <cstdio>
#include <iostream>
int main() {
    FILE* fp = fopen("config.json", "rb");
    if (!fp) {
        std::cerr << "파일 열기 실패\n";
        return 1;
    }
    char readBuffer[65536];
    rapidjson::FileReadStream is(fp, readBuffer, sizeof(readBuffer));
    rapidjson::Document doc;
    doc.ParseStream(is);
    fclose(fp);
    if (doc.HasParseError()) {
        std::cerr << "파싱 오류: " << rapidjson::GetParseError_En(doc.GetParseError()) << "\n";
        return 1;
    }
    if (doc.HasMember("port") && doc["port"].IsInt()) {
        std::cout << "port: " << doc["port"].GetInt() << "\n";
    }
    return 0;
}

배열·중첩 객체 파싱

#include <rapidjson/document.h>
#include <rapidjson/error/en.h>
#include <iostream>
#include <vector>
int main() {
    const char* json = R"({
        "users": [
            {"id": 1, "name": "Alice"},
            {"id": 2, "name": "Bob"}
        ]
    })";
    rapidjson::Document doc;
    doc.Parse(json);
    if (doc.HasParseError() || !doc.HasMember("users") || !doc["users"].IsArray()) {
        std::cerr << "파싱 실패 또는 users 배열 없음\n";
        return 1;
    }
    const auto& users = doc["users"].GetArray();
    for (rapidjson::SizeType i = 0; i < users.Size(); ++i) {
        const auto& u = users[i];
        if (!u.IsObject() || !u.HasMember("id") || !u.HasMember("name")) continue;
        int id = u["id"].GetInt();
        const char* name = u["name"].GetString();
        std::cout << id << ": " << name << "\n";
    }
    return 0;
}

RapidJSON으로 JSON 생성

#include <rapidjson/document.h>
#include <rapidjson/stringbuffer.h>
#include <rapidjson/writer.h>
#include <iostream>
int main() {
    rapidjson::Document doc(rapidjson::kObjectType);
    rapidjson::Document::AllocatorType& alloc = doc.GetAllocator();
    doc.AddMember("name", "Bob", alloc);
    doc.AddMember("age", 25, alloc);
    rapidjson::Value tags(rapidjson::kArrayType);
    tags.PushBack("admin", alloc);
    tags.PushBack("user", alloc);
    doc.AddMember("tags", tags, alloc);
    rapidjson::StringBuffer buffer;
    rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
    doc.Accept(writer);
    std::cout << buffer.GetString() << "\n";
    // {"name":"Bob","age":25,"tags":["admin","user"]}
    return 0;
}

doc.AddMember("tags", tags, alloc) 이후 tags는 비어 있는 null 값이 됩니다. RapidJSON의 Value는 대입과 AddMember에서 복사 대신 소유권을 옮기기 때문입니다. 같은 배열을 두 곳에 넣으려고 tags를 재사용하면 두 번째에는 null이 들어가므로, 복사가 필요하면 Value(tags, alloc)처럼 할당자를 넘기는 복사 생성자를 명시적으로 써야 합니다. 문자열 리터럴 "Bob"은 상수 문자열로 참조만 저장하지만, std::string처럼 수명이 짧은 문자열은 아래 커스텀 타입 예제처럼 Value(s.c_str(), alloc)로 복사해 넣어야 합니다. 이를 빠뜨리면 원본 문자열이 사라진 뒤 dangling 포인터를 직렬화하게 됩니다.


to_json/from_json으로 구조체와 enum 직렬화

nlohmann: to_json / from_json

#include <nlohmann/json.hpp>
#include <string>
#include <vector>
#include <optional>
using json = nlohmann::json;
struct User {
    std::string name;
    int age;
    std::vector<std::string> tags;
    std::optional<std::string> email;  // 선택적
};
// 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);
    }
    if (j.contains("email") && !j["email"].is_null()) {
        u.email = j["email"].get<std::string>();
    }
}
// User → JSON (직렬화)
void to_json(json& j, const User& u) {
    j = json{
        {"name", u.name},
        {"age", u.age},
        {"tags", u.tags}
    };
    if (u.email) {
        j["email"] = *u.email;
    }
}
int main() {
    std::string str = R"({"name": "Alice", "age": 30, "tags": ["admin"], "email": "[email protected]"})";
    json j = json::parse(str);
    User u = j.get<User>();
    std::cout << u.name << ", " << u.age << "\n";
    if (u.email) std::cout << "email: " << *u.email << "\n";
    json j2 = u;  // to_json 자동 호출
    std::cout << j2.dump(2) << "\n";
    return 0;
}

to_json/from_json은 User와 같은 네임스페이스에 정의해야 합니다. nlohmann은 인자 의존 탐색(ADL)으로 이 함수들을 찾기 때문에, 구조체는 myapp 네임스페이스에 있고 함수는 전역에 두면 j.get<User>()에서 수십 줄짜리 템플릿 에러(대개 no matching function for call to 'from_json')가 납니다. 외부 라이브러리 타입처럼 네임스페이스에 함수를 추가할 수 없는 경우에는 nlohmann::adl_serializer를 특수화합니다.

from_json에서 at()과 contains()를 섞어 쓴 것은 의도된 설계입니다. name과 age는 없으면 User를 만들 수 없으므로 at()으로 예외를 내고, tags와 email은 없어도 되는 필드라 존재할 때만 읽습니다. 이렇게 필수/선택의 구분이 from_json 한 곳에 드러나 있으면 API 스펙이 바뀌었을 때 고칠 곳도 한 곳입니다.

NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE (간단한 구조체)

#include <nlohmann/json.hpp>
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>();

이 매크로는 모든 필드를 필수로 취급합니다. 입력에 y가 없으면 out_of_range 예외가 나므로, 선택 필드가 있는 구조체에는 3.11부터 제공되는 NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT를 쓰면 누락된 필드를 기본 생성된 객체의 값으로 채웁니다. 매크로는 필드를 나열하는 것 외에 검증 로직을 넣을 수 없으므로, 범위 검사나 null 처리 같은 규칙이 필요한 타입은 직접 from_json을 쓰는 편이 낫습니다.

enum 직렬화

#include <nlohmann/json.hpp>
#include <string>
using json = nlohmann::json;
enum class Status { Pending, Active, Done };
void to_json(json& j, Status s) {
    switch (s) {
        case Status::Pending: j = "pending"; break;
        case Status::Active:  j = "active";  break;
        case Status::Done:    j = "done";    break;
    }
}
void from_json(const json& j, Status& s) {
    std::string v = j.get<std::string>();
    if (v == "pending") s = Status::Pending;
    else if (v == "active") s = Status::Active;
    else if (v == "done") s = Status::Done;
    else throw std::invalid_argument("unknown status: " + v);
}

enum은 숫자로 직렬화하는 것이 가장 쉽지만(아무것도 하지 않으면 nlohmann이 정수로 저장합니다), enum 값 순서를 바꾸거나 중간에 항목을 추가하면 이미 저장된 데이터의 의미가 바뀝니다. 그래서 외부와 주고받는 enum은 위처럼 문자열로 매핑하는 편이 안전합니다. 알 수 없는 문자열을 만났을 때 조용히 한 값으로 떨어뜨리면(원래 코드는 모두 Done으로 처리했습니다) 서버가 새 상태 값을 추가한 순간 클라이언트가 엉뚱한 상태로 동작하므로, 명시적으로 실패시키거나 Unknown 항목을 따로 두는 것이 좋습니다. nlohmann의 NLOHMANN_JSON_SERIALIZE_ENUM 매크로로도 같은 매핑을 짧게 쓸 수 있는데, 이 매크로는 모르는 값을 목록의 첫 항목으로 매핑하므로 첫 항목을 Unknown으로 두는 관례가 있습니다.

RapidJSON 커스텀 타입 (수동)

#include <rapidjson/document.h>
#include <rapidjson/stringbuffer.h>
#include <rapidjson/writer.h>
#include <string>
#include <vector>
struct User {
    std::string name;
    int age;
    std::vector<std::string> tags;
};
User parse_user(const rapidjson::Value& v) {
    User u;
    if (v.HasMember("name") && v["name"].IsString())
        u.name = v["name"].GetString();
    if (v.HasMember("age") && v["age"].IsInt())
        u.age = v["age"].GetInt();
    if (v.HasMember("tags") && v["tags"].IsArray()) {
        for (auto& t : v["tags"].GetArray()) {
            if (t.IsString()) u.tags.push_back(t.GetString());
        }
    }
    return u;
}
void user_to_json(const User& u, rapidjson::Value& v, rapidjson::Document::AllocatorType& alloc) {
    v.SetObject();
    v.AddMember("name", rapidjson::Value(u.name.c_str(), alloc), alloc);
    v.AddMember("age", u.age, alloc);
    rapidjson::Value tags(rapidjson::kArrayType);
    for (const auto& t : u.tags) {
        tags.PushBack(rapidjson::Value(t.c_str(), alloc), alloc);
    }
    v.AddMember("tags", tags, alloc);
}

type_error, parse_error, null 접근 같은 파싱 에러

type_error: 타입 불일치

원인: get<int>() 호출 시 실제 값이 문자열·null·다른 타입 해결:

// ❌ 위험
int age = j["age"].get<int>();  // "age"가 "30"(문자열)이면 type_error
// ✅ 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>());
} else {
    // 기본값 또는 에러 처리
}
// ✅ value로 기본값 (nlohmann)
int age = j.value("age", 0);

out_of_range / null 접근

원인: j[key]로 존재하지 않는 키 접근 시 null 삽입, 이후 get<T>()에서 실패 해결:

// ❌ 위험
auto v = j["optional_key"];
int x = v.get<int>();  // optional_key 없으면 null → type_error
// ✅ contains + value
if (j.contains("optional_key") && !j["optional_key"].is_null()) {
    int x = j["optional_key"].get<int>();
}
int x = j.value("optional_key", 0);  // 없으면 0

parse_error: 잘못된 JSON 문법

원인: 쉼표 누락, 따옴표 미닫힘, 제어 문자 등 해결:

// ✅ try-catch로 파싱 실패 처리
try {
    json j = json::parse(response_body);
} catch (const json::parse_error& e) {
    std::cerr << "파싱 오류: " << e.what() << " at byte " << e.byte << "\n";
    // 로깅, 폴백, 재시도 등
}

메모리 과다 사용 (대용량 JSON)

원인: 전체 문자열을 메모리에 로드 후 parse → 2배 메모리 해결:

// ✅ 파일은 스트림으로 파싱
std::ifstream f("large.json");
json j = json::parse(f);  // 문자열로 먼저 읽지 않음
// ✅ RapidJSON SAX: 이벤트 기반, DOM 없이 스트리밍
// 필요한 필드만 추출하면 메모리 절약

인코딩 문제 (UTF-8)

원인: JSON은 UTF-8이 기본. Windows 등에서 CP949 등 다른 인코딩으로 저장된 파일 파싱 시 깨짐 해결:

// ✅ 입력을 UTF-8로 보장
// - 파일 저장 시 UTF-8
// - HTTP 응답 Content-Type: application/json; charset=utf-8
// - nlohmann dump()는 기본값(ensure_ascii=false)으로 한글을 그대로 출력,
//   dump(-1, ' ', true)로 주면 \uXXXX 이스케이프로 출력

인코딩 문제는 파싱보다 직렬화할 때 더 자주 터집니다. Windows에서 CP949로 된 파일 경로나 사용자 입력을 std::string에 담아 json에 넣고 dump()하면, 유효하지 않은 UTF-8 바이트 때문에 [json.exception.type_error.316] invalid UTF-8 byte at index ... 예외가 납니다. 로그를 JSON으로 남기는 코드에서 특정 사용자 이름이 들어올 때만 서버가 죽는 문제가 대개 이것입니다. 입력 경계에서 UTF-8로 변환하는 것이 정석이고, 로그처럼 손실이 괜찮은 경우에는 dump(-1, ' ', false, json::error_handler_t::replace)로 잘못된 바이트를 대체 문자로 바꿀 수 있습니다.

Data Race (멀티스레드)

원인: 여러 스레드가 동일 json 객체에 동시 쓰기/읽기

표준 컨테이너와 마찬가지로 json 객체를 여러 스레드가 읽기만 하는 것은 안전합니다. 문제는 읽기처럼 보이는 쓰기입니다. 비-const json에 operator[]로 없는 키를 조회하면 삽입이 일어나므로, “모든 스레드가 설정을 읽기만 한다”고 생각한 코드가 실제로는 동시에 맵을 수정하고 있을 수 있습니다. 공유 설정은 const json으로 만들어 두고 at()/find()/value()만 쓰면 이 문제를 구조적으로 막을 수 있습니다.

해결:

// ✅ 파싱 후 스레드별로 복사
json j_global = json::parse(body);
std::thread t([j_global]() {  // 복사로 전달
    process(j_global);
});
// ✅ 또는 파싱을 스레드 내부에서
std::thread t([body]() {
    json j = json::parse(body);  // 스레드 로컬
    process(j);
});

필수·선택 필드 구분과 API 응답 래퍼

필수 필드 vs 선택 필드 구분

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"];
    }
}

API 응답 래퍼

template<typename T>
struct ApiResult {
    bool ok;
    T data;
    std::string error_msg;
};
template<typename T>
ApiResult<T> parse_api_response(const std::string& body) {
    ApiResult<T> result{false, {}, ""};
    try {
        json j = json::parse(body);
        if (j.contains("error")) {
            result.error_msg = j.value("error", "unknown");
            return result;
        }
        result.data = j.get<T>();
        result.ok = true;
    } catch (const json::exception& e) {
        result.error_msg = e.what();
    }
    return result;
}

설정 파일 로드 패턴

struct Config {
    int port = 8080;
    std::string host = "0.0.0.0";
    int timeout = 30;
};
Config load_config(const std::string& path) {
    std::ifstream f(path);
    if (!f) throw std::runtime_error("Cannot open: " + path);
    json j = json::parse(f);
    Config c;
    c.port = j.value("port", 8080);
    c.host = j.value("host", "0.0.0.0");
    c.timeout = j.value("timeout", 30);
    return c;
}

로깅용 한 줄 JSON

// 이벤트·로그 직렬화: 한 줄로 출력 (Kafka, 로그 파일)
json event = {{"ts", timestamp}, {"level", "info"}, {"msg", message}};
std::cout << event.dump() << "\n";  // 압축 형식

nlohmann과 RapidJSON의 성능 비교

무엇이 속도를 가르는가

방식파싱 속도메모리이유
nlohmann/json상대적으로 느림가장 많음노드마다 STL 컨테이너·힙 할당
RapidJSON (DOM)빠름적음풀 할당자에 값을 촘촘히 배치
RapidJSON (SAX)가장 빠름입력 크기와 거의 무관트리를 만들지 않고 이벤트만 전달

공개 벤치마크(예: nativejson-benchmark)에서도 RapidJSON이 nlohmann보다 여러 배 빠른 결과가 흔하지만, 정확한 배수는 JSON 모양(깊은 중첩, 긴 문자열, 숫자 비중), 컴파일러, 할당자에 따라 크게 달라집니다. 더 중요한 것은 파싱이 실제 병목인지입니다. HTTP 호출 하나에 수십 ms가 걸리는 서비스에서 수 KB 응답의 파싱은 전체 시간의 극히 일부이므로, 라이브러리를 바꾸기 전에 프로파일러로 파싱 비중부터 확인하세요. 제 경험상 “JSON이 느리다”는 문제의 상당수는 라이브러리가 아니라 같은 문서를 여러 번 파싱하거나, json 값을 불필요하게 복사하는 코드에서 나옵니다.

선택 가이드

flowchart TD
    A[JSON 파싱 필요] --> B{성능·메모리 제약?}
    B -->|아니오| C[nlohmann/json]
    B -->|예| D{대용량 스트리밍?}
    D -->|예| E[RapidJSON SAX]
    D -->|아니오| F[RapidJSON DOM]
    C --> G[개발 편의성 우선]
    F --> H[고성능, 저메모리]
    E --> I[최소 메모리]

HTTP 응답 파싱, 재시도, 스키마 검증

HTTP 응답 + JSON 파싱 통합

#include <nlohmann/json.hpp>
#include <string>
using json = nlohmann::json;
// HTTP 클라이언트로 body 수신 후 (예: #21-1 참고)
bool fetch_and_parse(const std::string& url, json& out) {
    std::string body;
    if (!http_get(url, body)) return false;
    try {
        out = json::parse(body);
        return true;
    } catch (const json::parse_error& e) {
        // 로깅: e.what(), e.byte
        return false;
    }
}

REST API 사용자 목록 조회 통합 예제

// HTTP GET + JSON 파싱 + 커스텀 타입 변환 통합
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
#include <optional>
using json = nlohmann::json;
struct User {
    int id;
    std::string name;
    std::optional<std::string> email;
};
std::optional<std::vector<User>> fetch_users(const std::string& host,
                                              const std::string& path) {
    std::string body;
    if (!http_get(host, path, body)) return std::nullopt;
    try {
        json j = json::parse(body);
        if (!j.contains("data") || !j["data"].is_array()) return std::nullopt;
        std::vector<User> users;
        for (auto& item : j["data"]) {
            if (!item.contains("id") || !item.contains("name")) continue;
            User u;
            u.id = item["id"].get<int>();
            u.name = item["name"].get<std::string>();
            if (item.contains("email") && !item["email"].is_null())
                u.email = item["email"].get<std::string>();
            users.push_back(std::move(u));
        }
        return users;
    } catch (const json::exception&) {
        return std::nullopt;
    }
}

재시도 + 파싱

json fetch_with_retry(const std::string& url, int max_retries = 3) {
    for (int i = 0; i < max_retries; ++i) {
        std::string body;
        if (!http_get(url, body)) {
            std::this_thread::sleep_for(std::chrono::seconds(1 << i));
            continue;
        }
        try {
            return json::parse(body);
        } catch (const json::parse_error&) {
            // 파싱 실패는 재시도해도 소용없음
            throw;
        }
    }
    throw std::runtime_error("HTTP fetch failed after retries");
}

재시도 코드에서 파싱 실패를 다시 던지는 이유는 실패의 종류가 다르기 때문입니다. 네트워크 오류나 5xx는 잠시 뒤 성공할 수 있지만, 서버가 보낸 JSON이 문법적으로 틀렸다면 같은 요청을 몇 번 반복해도 같은 결과가 나옵니다. 다만 예외가 하나 있습니다. 프록시나 로드밸런서가 장애 중에 JSON 대신 HTML 에러 페이지(<html>...)를 돌려주면 parse_error.101 ... unexpected '<'가 나는데, 이 경우는 사실 일시적인 서버 장애입니다. 파싱 전에 HTTP 상태 코드와 Content-Type을 먼저 확인하면 이 둘을 구분할 수 있고, 로그에 본문 앞부분을 조금 남겨 두면 원인을 훨씬 빨리 찾을 수 있습니다.

스키마 검증 (선택)

nlohmann/json 자체에는 스키마 검증이 없습니다. valijson, nlohmann/json-schema 등 별도 라이브러리로 스키마 검증을 추가할 수 있습니다.

JSON 파싱 운영 체크리스트

  • 파싱 예외 처리: parse_error, type_error try-catch
  • 안전한 접근: contains, value, at 사용
  • 타입 검증: is_* 또는 스키마 검증
  • 선택 필드: std::optional, value(key, default)
  • 대용량: 스트림 파싱 또는 SAX 고려
  • 멀티스레드: 파싱 결과 복사 또는 스레드 로컬
  • 로깅: 파싱 실패 시 요청/응답 샘플 로깅 (개인정보 제외)

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. dump() 결과의 키 순서가 입력과 달라요.

A. nlohmann의 기본 json은 객체를 std::map에 저장해 키를 정렬합니다. 입력 순서를 유지하려면 nlohmann::ordered_json을 쓰세요. JSON 명세상 객체의 키 순서에는 의미가 없으므로, 순서에 의존하는 비교(서명 검증 등)가 필요하다면 정렬된 형태로 정규화하는 편이 오히려 안정적입니다.

Q. JSON 파싱이 느려요.

A. 먼저 프로파일러로 파싱이 실제 병목인지 확인하세요. 같은 문서를 반복 파싱하거나 json 값을 값으로 넘겨 복사하는 코드가 원인인 경우가 많습니다. 파싱 자체가 병목이면 RapidJSON DOM, 필요한 필드만 뽑는다면 SAX로 옮기는 순서로 검토합니다. nlohmann/json과 RapidJSON으로 JSON을 안전하게 파싱하며, contains·value·커스텀 직렬화로 프로덕션 수준의 에러 처리를 적용할 수 있습니다. 다음 글: C++ Concepts 기초: 타입 제약과 requires 이전 글: C++ HTTP 클라이언트 직접 만들기

참고 자료