C++ 리플렉션 구현 | 타입 정보·메타데이터·자동 직렬화

User, Order, Product처럼 구조체가 늘어날 때마다 to_json과 from_json을 손으로 작성하다 보면, 멤버를 추가하거나 이름을 바꿀 때 직렬화 코드를 함께 고치는 것을 잊기 쉽습니다. Java나 C#이라면 실행 중에 타입의 필드 목록을 조회하는 리플렉션으로 이 반복을 없앨 수 있지만, C++에는 런타임 리플렉션이 표준으로 없습니다. C++26에서 정적(컴파일 타임) 리플렉션이 채택되었지만, 컴파일러 지원은 이제 막 시작되었습니다.

그래서 직렬화, ORM 매핑, 게임 에디터의 프로퍼티 패널, Lua·JavaScript 스크립트 바인딩, 설정 파일 로딩처럼 “타입 구조를 모르는 코드가 멤버를 순회”해야 하는 곳에서는 지금도 대부분 멤버 정보를 직접 등록하는 레지스트리, 매크로, 코드 생성, RTTR 같은 라이브러리를 씁니다. 이 글은 문자열 이름으로 타입과 멤버를 조회하는 최소한의 런타임 레지스트리를 직접 만들어 보고, 매크로와 RTTR로 반복을 줄이는 방법, 자주 빠지는 함정, C++26 리플렉션과의 관계를 다룹니다.

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[타입 등록 1회] --> S2[멤버 메타데이터]
    S2 --> S3[자동 직렬화]
    S3 --> S4[공통 코드로 처리]
  end

리플렉션이란?

리플렉션(reflection)은 프로그램이 자신의 타입, 멤버, 메서드 정보를 조회하고 순회하는 기능입니다. Java의 Class, C#의 Type, Python의 getattr이 대표적인 런타임 리플렉션입니다. C++에서 런타임에 얻을 수 있는 표준 정보는 typeid가 돌려주는 std::type_info 정도이고, 그 이름 문자열마저 구현마다 형식이 다릅니다. 멤버 목록과 이름, 타입을 실행 중에 조회하는 표준 방법은 없으므로 메타데이터를 직접 구축해야 합니다.

flowchart TB
  subgraph cpp[런타임 레지스트리]
    A1[구조체 정의] --> A2[수동 등록 또는 매크로]
    A2 --> A3[타입 레지스트리]
    A3 --> A4[런타임 조회]
  end
  subgraph future[C++26 정적 리플렉션]
    B1[구조체 정의] --> B2[std::meta::nonstatic_data_members_of]
    B2 --> B3[컴파일 타임 순회]
    B3 --> B4[코드 생성]
  end
구분런타임 리플렉션컴파일 타임 리플렉션
시점실행 중컴파일 시
얻는 정보타입 이름, 멤버 목록, 값 접근타입, 멤버, 이름 등
현재 C++에서의 구현수동 등록, RTTR 등템플릿, 매크로, C++26 std::meta
주된 용도이름으로 타입을 찾는 에디터·스크립트·플러그인직렬화 코드 생성, 검증

수동 등록 기반 타입 레지스트리

레지스트리 설계

가장 기본적인 방식은 타입마다 프로퍼티 이름, 타입 이름, 값을 읽고 쓰는 함수를 등록하고, 문자열 이름으로 조회하는 레지스트리를 두는 것입니다.

// reflection_core.h — 최소한의 리플렉션 코어
#pragma once
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>

// getter: obj의 멤버 값을 out이 가리키는 "이미 생성된" 객체에 대입
// setter: value가 가리키는 값을 obj의 멤버에 대입
using Getter = std::function<void(const void* obj, void* out)>;
using Setter = std::function<void(void* obj, const void* value)>;

struct PropertyInfo {
    std::string name;
    std::string typeName;
    Getter getter;
    Setter setter;
};

struct TypeInfo {
    std::string name;
    std::vector<PropertyInfo> properties;
    std::function<void*()> defaultConstructor;  // 인스턴스 생성 (호출자가 소유)

    const PropertyInfo* findProperty(const std::string& prop) const {
        for (const auto& p : properties) {
            if (p.name == prop) return &p;
        }
        return nullptr;
    }
};

class TypeRegistry {
public:
    static TypeRegistry& instance() {
        static TypeRegistry reg;  // 첫 호출 시 생성: 정적 초기화 순서 문제 회피
        return reg;
    }
    void registerType(const std::string& name, TypeInfo info) {
        types_[name] = std::move(info);
    }
    const TypeInfo* getType(const std::string& name) const {
        auto it = types_.find(name);
        return it != types_.end() ? &it->second : nullptr;
    }
private:
    std::unordered_map<std::string, TypeInfo> types_;
};

void*로 값을 주고받는 이 설계는 단순하지만 타입 안전성이 없습니다. getter와 setter의 계약은 “호출자가 해당 타입의 객체를 미리 만들어 두고 그 주소를 넘긴다”는 것이고, 등록된 typeName과 실제로 넘기는 객체의 타입이 다르면 미정의 동작입니다. 실무 라이브러리가 std::any나 자체 variant 타입으로 값을 감싸는 이유가 이것입니다.

User 구조체 등록

// user.h
#pragma once
#include <string>

struct User {
    int id;
    std::string name;
    std::string email;
};
// user_reflection.cpp — 등록은 하나의 .cpp에서
#include "user.h"
#include "reflection_core.h"

void registerUserType() {
    TypeInfo info;
    info.name = "User";
    info.properties.push_back({
        "id", "int",
        [](const void* obj, void* out) {
            *static_cast<int*>(out) = static_cast<const User*>(obj)->id;
        },
        [](void* obj, const void* value) {
            static_cast<User*>(obj)->id = *static_cast<const int*>(value);
        }
    });
    info.properties.push_back({
        "name", "std::string",
        [](const void* obj, void* out) {
            *static_cast<std::string*>(out) = static_cast<const User*>(obj)->name;
        },
        [](void* obj, const void* value) {
            static_cast<User*>(obj)->name = *static_cast<const std::string*>(value);
        }
    });
    info.properties.push_back({
        "email", "std::string",
        [](const void* obj, void* out) {
            *static_cast<std::string*>(out) = static_cast<const User*>(obj)->email;
        },
        [](void* obj, const void* value) {
            static_cast<User*>(obj)->email = *static_cast<const std::string*>(value);
        }
    });
    info.defaultConstructor = []() -> void* { return new User(); };
    TypeRegistry::instance().registerType("User", std::move(info));
}

getter는 호출자가 이미 생성해 둔 객체(std::string val;)에 값을 대입합니다. int든 std::string이든 대입 연산자를 쓰면 되므로 타입별로 다른 처리가 필요 없습니다. 반대로 이미 살아 있는 std::string 위에 placement new로 새 객체를 만들면, 기존 객체의 소멸자가 호출되지 않은 채 덮어쓰이므로 피해야 합니다.

리플렉션 기반 JSON 직렬화

// json_serializer.h — 리플렉션 기반 직렬화
#pragma once
#include "reflection_core.h"
#include <nlohmann/json.hpp>
#include <string>

inline nlohmann::json toJsonReflection(const void* obj, const TypeInfo& type) {
    nlohmann::json j = nlohmann::json::object();
    for (const auto& p : type.properties) {
        if (p.typeName == "int") {
            int val{};
            p.getter(obj, &val);
            j[p.name] = val;
        } else if (p.typeName == "bool") {
            bool val{};
            p.getter(obj, &val);
            j[p.name] = val;
        } else if (p.typeName == "std::string") {
            std::string val;
            p.getter(obj, &val);
            j[p.name] = val;  // 따옴표·제어 문자 이스케이프는 라이브러리가 처리
        }
    }
    return j;
}

inline void fromJsonReflection(void* obj, const TypeInfo& type, const nlohmann::json& j) {
    for (const auto& p : type.properties) {
        if (!j.contains(p.name)) continue;
        if (p.typeName == "int") {
            int val = j[p.name].get<int>();
            p.setter(obj, &val);
        } else if (p.typeName == "bool") {
            bool val = j[p.name].get<bool>();
            p.setter(obj, &val);
        } else if (p.typeName == "std::string") {
            std::string val = j[p.name].get<std::string>();
            p.setter(obj, &val);
        }
    }
}

헤더에 정의한 함수에는 inline을 붙여야 합니다. 그렇지 않으면 이 헤더를 두 개 이상의 .cpp에서 include했을 때 같은 함수가 여러 번 정의되어 링크 오류(ODR 위반)가 납니다. 문자열을 "\"" + val + "\""처럼 직접 이어 붙이면 값에 따옴표나 줄바꿈이 들어 있을 때 깨진 JSON이 나오므로, 출력은 JSON 라이브러리에 맡기는 편이 안전합니다.

설정 로더와 필수 필드 검증

설정 로더

// config.h
#pragma once
#include <string>
#include <vector>

struct GameConfig {
    std::string title;
    int screenWidth = 1280;
    int screenHeight = 720;
    bool fullscreen = false;
    std::vector<std::string> levels;  // 아래 레지스트리에는 아직 등록하지 않음
};
// config_reflection.cpp
#include "config.h"
#include "reflection_core.h"

void registerGameConfigType() {
    TypeInfo info;
    info.name = "GameConfig";
    auto add = [&info](const char* name, const char* typeName, auto get, auto set) {
        info.properties.push_back({
            name, typeName,
            [get](const void* o, void* out) { get(static_cast<const GameConfig*>(o), out); },
            [set](void* o, const void* v) { set(static_cast<GameConfig*>(o), v); }
        });
    };
    add("title", "std::string",
        [](const GameConfig* c, void* out) { *static_cast<std::string*>(out) = c->title; },
        [](GameConfig* c, const void* v) { c->title = *static_cast<const std::string*>(v); });
    add("screenWidth", "int",
        [](const GameConfig* c, void* out) { *static_cast<int*>(out) = c->screenWidth; },
        [](GameConfig* c, const void* v) { c->screenWidth = *static_cast<const int*>(v); });
    add("screenHeight", "int",
        [](const GameConfig* c, void* out) { *static_cast<int*>(out) = c->screenHeight; },
        [](GameConfig* c, const void* v) { c->screenHeight = *static_cast<const int*>(v); });
    add("fullscreen", "bool",
        [](const GameConfig* c, void* out) { *static_cast<bool*>(out) = c->fullscreen; },
        [](GameConfig* c, const void* v) { c->fullscreen = *static_cast<const bool*>(v); });
    info.defaultConstructor = []() -> void* { return new GameConfig(); };
    TypeRegistry::instance().registerType("GameConfig", std::move(info));
}

levels처럼 컨테이너 타입의 멤버는 위 직렬화 함수가 처리하는 타입 목록(int, bool, std::string)에 없으므로, 등록하더라도 조용히 건너뛰게 됩니다. 지원하지 않는 typeName을 만나면 예외를 던지거나 로그를 남기도록 해 두면 이런 누락을 일찍 발견할 수 있습니다.

필수 필드 검증

// reflection_validate.h
#pragma once
#include "reflection_core.h"
#include <string>
#include <vector>

struct ValidationError {
    std::string propertyName;
    std::string message;
};

inline std::vector<ValidationError> validateRequired(const void* obj, const TypeInfo& type,
                                                     const std::vector<std::string>& required) {
    std::vector<ValidationError> errors;
    for (const auto& name : required) {
        const auto* prop = type.findProperty(name);
        if (!prop) {
            errors.push_back({name, "property not found"});
            continue;
        }
        if (prop->typeName == "std::string") {
            std::string val;
            prop->getter(obj, &val);
            if (val.empty()) {
                errors.push_back({name, "required field is empty"});
            }
        }
    }
    return errors;
}

main에서 등록과 사용

// main.cpp
#include "user.h"
#include "reflection_core.h"
#include "json_serializer.h"
#include "reflection_validate.h"
#include <iostream>

void registerUserType();
void registerGameConfigType();

void registerAllTypes() {
    registerUserType();
    registerGameConfigType();
}

int main() {
    registerAllTypes();

    User user{1, "Alice", "alice@example.com"};
    const TypeInfo* type = TypeRegistry::instance().getType("User");
    if (!type) return 1;

    std::cout << toJsonReflection(&user, *type).dump() << "\n";
    // {"email":"alice@example.com","id":1,"name":"Alice"}  (nlohmann::json은 기본적으로 키를 정렬)

    for (const auto& e : validateRequired(&user, *type, {"name", "email"})) {
        std::cerr << e.propertyName << ": " << e.message << "\n";
    }
}

매크로로 등록 코드 줄이기

프로퍼티마다 getter·setter 람다를 쓰는 부분은 거의 같으므로 매크로로 줄일 수 있습니다. 이때 프로퍼티의 타입 이름은 매크로 인자로 문자열화할 수 없으므로(멤버의 타입은 코드에 적혀 있지 않음), 타입으로부터 이름을 돌려주는 헬퍼를 함께 둡니다.

// reflection_macro.h
#pragma once
#include "reflection_core.h"
#include <string>
#include <type_traits>

template <typename T>
const char* reflectTypeName() {
    if constexpr (std::is_same_v<T, int>) return "int";
    else if constexpr (std::is_same_v<T, bool>) return "bool";
    else if constexpr (std::is_same_v<T, std::string>) return "std::string";
    else return "unknown";
}

#define REFLECT_PROPERTY(info, Type, Member)                                       \
    (info).properties.push_back({                                                  \
        #Member,                                                                   \
        reflectTypeName<decltype(Type::Member)>(),                                 \
        [](const void* o, void* out) {                                             \
            *static_cast<decltype(Type::Member)*>(out) =                           \
                static_cast<const Type*>(o)->Member;                               \
        },                                                                         \
        [](void* o, const void* v) {                                               \
            static_cast<Type*>(o)->Member =                                        \
                *static_cast<const decltype(Type::Member)*>(v);                    \
        }                                                                          \
    })
// user_reflection.cpp
#include "user.h"
#include "reflection_macro.h"

void registerUserType() {
    TypeInfo info;
    info.name = "User";
    REFLECT_PROPERTY(info, User, id);
    REFLECT_PROPERTY(info, User, name);
    REFLECT_PROPERTY(info, User, email);
    info.defaultConstructor = []() -> void* { return new User(); };
    TypeRegistry::instance().registerType("User", std::move(info));
}

decltype(Type::Member)는 멤버의 선언된 타입을 돌려주므로, 대입 연산자만 있으면 int와 std::string을 같은 코드로 처리할 수 있습니다. 매크로의 단점은 오류 메시지가 매크로 전개 결과를 가리켜 읽기 어렵다는 것과, 여전히 멤버마다 한 줄씩 적어야 한다는 것입니다. 참고로 __reflect_User처럼 밑줄 두 개로 시작하는 이름은 구현(컴파일러·표준 라이브러리)용으로 예약되어 있으므로 매크로가 만드는 식별자에도 쓰지 않아야 합니다.

RTTR 라이브러리

RTTR(Run Time Type Reflection)은 C++11 기반 오픈소스 런타임 리플렉션 라이브러리로, 외부 코드 생성기 없이 .cpp 파일에서 등록만 하면 됩니다.

// user_rttr.cpp
#include "user.h"
#include <rttr/registration>

RTTR_REGISTRATION {
    rttr::registration::class_<User>("User")
        .constructor<>()
        .property("id", &User::id)
        .property("name", &User::name)
        .property("email", &User::email);
}
// main_rttr.cpp
#include "user.h"
#include <rttr/type>
#include <iostream>

int main() {
    User user{1, "Alice", "alice@example.com"};
    rttr::type type = rttr::type::get<User>();
    for (auto& prop : type.get_properties()) {
        rttr::variant value = prop.get_value(user);
        std::cout << prop.get_name() << " = " << value.to_string() << "\n";
    }
    type.get_property("name").set_value(user, std::string("Bob"));
    std::cout << "name after set: " << user.name << "\n";
}

RTTR은 프로퍼티뿐 아니라 메서드, 생성자, enum을 등록할 수 있고, 값은 rttr::variant로 타입을 지운 채 주고받으며, 공유 라이브러리(플러그인)를 로드해 그 안의 타입을 조회하는 기능도 있습니다. 다만 마지막 정식 릴리스(0.9.6)가 2018년이라 활발히 유지보수되고 있지 않으므로, 새 프로젝트에 도입할 때는 최신 컴파일러에서의 빌드 상태와 이슈 트래커를 먼저 확인하는 것이 좋습니다.

자주 빠지는 함정

멤버를 추가했는데 직렬화에 반영되지 않음

struct User {
    int id;
    std::string name;
    std::string email;
    int age;  // 새로 추가했지만 등록 코드는 그대로
};
// age가 JSON에 나오지 않음

수동 등록 방식에서는 레지스트리에 적힌 프로퍼티만 존재합니다. 구조체와 등록 코드를 같은 변경에서 고치도록 리뷰 규칙을 두거나, 직렬화 결과의 키 목록을 기대값과 비교하는 단위 테스트로 막습니다. 구조체가 집합체라면 집합체 초기화 트릭으로 멤버 개수를 컴파일 타임에 세어 static_assert로 등록 수와 비교하는 기법도 있지만(Boost.PFR이 이 방식으로 동작합니다), 이름까지 검증하지는 못합니다.

getter에서 비트리비얼 타입을 바이트로 복사

// 잘못된 예: std::string을 memcpy로 복사
p.getter = [](const void* obj, void* out) {
    std::memcpy(out, &static_cast<const User*>(obj)->name, sizeof(std::string));
};

std::string은 내부에 힙 버퍼 포인터를 가질 수 있어, 바이트를 복사하면 두 객체가 같은 버퍼를 가리키게 되고 둘 다 소멸할 때 이중 해제가 납니다. 앞의 예제처럼 이미 생성된 객체에 대입 연산자로 복사해야 합니다.

이름이 아니라 순서로 매칭

// 잘못된 예: 인덱스로 매칭 (등록 순서와 입력 순서가 같다고 가정)
for (size_t i = 0; i < keys.size(); ++i) {
    type.properties[i].setter(obj, &values[i]);
}

// 올바른 예: 이름으로 매칭
if (const auto* prop = type.findProperty(key)) {
    prop->setter(obj, &value);
}

JSON 객체의 키 순서는 의미가 없고, 라이브러리에 따라 정렬되기도 합니다. 등록 순서에 기대면 멤버 순서를 바꾸는 것만으로 값이 엉뚱한 필드에 들어갑니다.

등록이 여러 번 실행됨

// 잘못된 예: 헤더에 둔 자동 등록
// user_reflection.h
inline void registerUserType() { /* ... */ }
static const bool userRegistered = (registerUserType(), true);
// 이 헤더를 include한 .cpp마다 별도의 userRegistered가 생겨 등록이 반복 실행됨

static 변수는 번역 단위마다 따로 생기므로, 헤더에 두면 include한 횟수만큼 등록이 실행됩니다. 이 예제의 registerType은 같은 이름을 덮어쓰므로 결과는 같지만, 등록 시 리스트에 추가하는 구조라면 프로퍼티가 중복되고, 정적 초기화 순서에 따라 레지스트리보다 먼저 실행될 위험도 있습니다. 자동 등록 변수는 .cpp에 하나만 두고, 명시적으로 호출한다면 main이나 단일 초기화 함수에서 한 번만 부르는 것이 확실합니다. (정적 라이브러리에 넣은 자동 등록 .cpp는 아무도 그 안의 심볼을 참조하지 않으면 링커가 오브젝트 파일째 버려 등록이 아예 실행되지 않는 문제도 있습니다.)

베이스 클래스 멤버 누락

struct Base { int id; };
struct Derived : Base { std::string name; };
// Derived만 등록하면 id는 조회되지 않음

파생 타입을 등록할 때 베이스의 프로퍼티도 함께 넣어야 합니다. 등록 함수를 appendBaseProperties(info)처럼 나눠 두면 여러 파생 타입에서 재사용할 수 있습니다. RTTR은 RTTR_ENABLE(Base) 매크로로 상속 관계를 선언하면 베이스 프로퍼티를 함께 조회해 줍니다.

const 객체에 setter 호출

// 잘못된 예: const를 벗겨서 setter 호출
void loadFromJson(const void* obj, const TypeInfo& type, const nlohmann::json& j) {
    fromJsonReflection(const_cast<void*>(obj), type, j);  // 원본이 const 객체면 미정의 동작
}

// 올바른 예: 쓰기 API는 처음부터 non-const 포인터만 받음
void loadFromJson(void* obj, const TypeInfo& type, const nlohmann::json& j);

void*를 거치면 컴파일러가 const 위반을 잡아 주지 못하므로, 읽기 API와 쓰기 API의 포인터 타입을 처음부터 구분해 둡니다.

한계를 우회하는 방법

private 멤버

private 멤버는 등록 코드에서 직접 접근할 수 없으므로, public 접근자를 연결하거나 등록 함수를 friend로 선언합니다.

class SecretData {
    int secret_ = 0;
public:
    int getSecret() const { return secret_; }
    void setSecret(int v) { secret_ = v; }
};

info.properties.push_back({
    "secret", "int",
    [](const void* o, void* out) {
        *static_cast<int*>(out) = static_cast<const SecretData*>(o)->getSecret();
    },
    [](void* o, const void* v) {
        static_cast<SecretData*>(o)->setSecret(*static_cast<const int*>(v));
    }
});

접근자를 거치면 setter에서 값 검증을 할 수 있다는 장점도 있습니다.

템플릿 타입의 이름

std::vector<int>나 std::map<std::string, int> 같은 타입은 typeid(T).name()의 결과가 컴파일러마다 다르고(GCC·Clang은 맹글링된 이름) 길기 때문에, 앞의 reflectTypeName<T>()처럼 직접 이름을 정해 주는 헬퍼를 두는 것이 좋습니다.

플러그인의 타입 등록과 해제

플러그인이 로드될 때 자기 타입을 등록했다면, 언로드할 때 반드시 제거해야 합니다. 레지스트리에 남은 getter·setter 람다의 코드는 언로드된 공유 라이브러리 안에 있으므로, 제거하지 않으면 다음 호출에서 크래시가 납니다.

void TypeRegistry::unregisterType(const std::string& name) {
    types_.erase(name);
}

void onPluginUnload(const std::vector<std::string>& typeNames) {
    for (const auto& name : typeNames) {
        TypeRegistry::instance().unregisterType(name);
    }
}

코드 생성

구조체 정의를 파싱해 등록 코드를 생성하는 도구를 두면 수동 등록을 거의 없앨 수 있습니다. libclang이나 Clang LibTooling으로 헤더를 읽어 user_reflection.generated.cpp를 만들고, CMake의 add_custom_command로 빌드 전에 생성하게 하는 방식입니다. Unreal Engine의 UHT(UPROPERTY 매크로를 읽는 헤더 도구)나 Qt의 moc가 이 방식의 대표적인 예입니다.

확장

포맷별 직렬화 분리

JSON, 바이너리, 프로토콜 버퍼처럼 포맷이 여러 개라면, 프로퍼티 순회와 포맷별 출력을 방문자로 분리합니다.

struct SerializationVisitor {
    virtual ~SerializationVisitor() = default;
    virtual void visitInt(const std::string& name, int value) = 0;
    virtual void visitString(const std::string& name, const std::string& value) = 0;
};

void serializeWithVisitor(const void* obj, const TypeInfo& type, SerializationVisitor& v) {
    for (const auto& p : type.properties) {
        if (p.typeName == "int") {
            int val{};
            p.getter(obj, &val);
            v.visitInt(p.name, val);
        } else if (p.typeName == "std::string") {
            std::string val;
            p.getter(obj, &val);
            v.visitString(p.name, val);
        }
    }
}

프로퍼티 메타데이터

에디터 UI 힌트나 검증 규칙, 이전 버전과의 호환을 위한 별칭을 프로퍼티에 붙일 수 있습니다.

struct PropertyInfo {
    std::string name;
    std::string typeName;
    Getter getter;
    Setter setter;
    std::unordered_map<std::string, std::string> metadata;  // "min", "max", "editor", "alias"
};

info.properties.push_back({
    "screenWidth", "int", getter, setter,
    {{"min", "0"}, {"max", "7680"}, {"editor", "slider"}}
});

// 이름이 바뀐 필드: 예전 JSON의 "old_id"도 읽을 수 있게
info.properties.push_back({"id", "int", idGetter, idSetter, {{"alias", "old_id"}}});

로더는 프로퍼티 이름으로 값을 찾지 못하면 alias 메타데이터의 이름으로 한 번 더 찾는 식으로 구현합니다.

스키마 생성

등록된 정보로 JSON Schema나 OpenAPI 문서, 다른 언어용 클라이언트 코드를 생성할 수도 있습니다.

std::string toJsonSchema(const TypeInfo& type) {
    nlohmann::json schema;
    schema["type"] = "object";
    schema["properties"] = nlohmann::json::object();
    for (const auto& p : type.properties) {
        schema["properties"][p.name]["type"] = typeNameToJsonType(p.typeName);  // "int" → "integer"
    }
    return schema.dump(2);
}

성능

타입 이름으로 찾는 getType(name)은 unordered_map이라 평균 O(1)이고, 프로퍼티 이름으로 찾는 findProperty는 선형 탐색입니다. 프로퍼티가 수십 개를 넘고 이름 조회가 잦다면 이름 → 인덱스 맵을 함께 두는 것이 좋습니다.

std::function 호출은 간접 호출이라 인라인되지 않으므로, 멤버를 직접 읽는 코드보다 느립니다. 대부분의 직렬화에서는 문자열 처리와 I/O 비용이 훨씬 크지만, 대량의 객체를 직렬화하는 경로가 프로파일에서 병목으로 드러나면 그 타입만 전용 직렬화 함수를 두는 식으로 대응할 수 있습니다. 반복 루프에서는 getType() 결과를 한 번만 조회해 포인터를 재사용합니다.

const TypeInfo* userType = TypeRegistry::instance().getType("User");
for (const auto& user : users) {
    out.push_back(toJsonReflection(&user, *userType));
}

C++26 정적 리플렉션과의 관계

정적 리플렉션(P2996)은 2025년 6월 C++26에 채택되었고, 2026년 기준 GCC 16(-std=c++26 -freflection)과 Bloomberg의 clang-p2996 포크에서 쓸 수 있습니다. 이 글의 런타임 레지스트리와 달리 모든 조회가 컴파일 타임에 끝나므로, 등록 코드 없이 멤버를 순회할 수 있습니다.

#include <meta>
#include <string>

struct User {
    int id;
    std::string name;
};

template <typename T>
void serialize(const T& obj) {
    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))) {
        auto name = std::meta::identifier_of(m);   // "id", "name"
        const auto& value = obj.[:m:];              // obj.id, obj.name 로 컴파일됨
        // write(name, value);
    }
}

문자열 이름으로 런타임에 타입을 찾는 기능(에디터, 스크립트 바인딩, 플러그인)은 정적 리플렉션만으로는 생기지 않습니다. 이런 기능이 필요하면, 정적 리플렉션으로 이 글의 TypeRegistry를 자동으로 채우는 조합이 현실적입니다. 등록 코드를 손으로 쓰지 않아도 되므로 멤버 누락 문제가 사라집니다. 문법과 주의점은 C++26 리플렉션 기초(#26-1)에 정리했습니다.

방식장점단점
수동 등록동작이 명확하고 의존성이 없음반복 작업, 멤버 누락 위험
매크로반복 감소오류 메시지가 읽기 어렵고, 여전히 멤버마다 한 줄
RTTR메서드·생성자·상속까지 지원외부 의존성, 유지보수가 정체됨
코드 생성등록 자동화빌드 단계와 도구 관리 비용
C++26 정적 리플렉션표준, 등록 불필요지원 컴파일러가 아직 적음

같이 보면 좋은 글