C++ 컴파일 타임 리플렉션 | C++26 Reflection·magic_enum·매크로 직렬화·검증
구조체마다 to_json을 손으로 쓰는 문제
게임 엔진의 Transform, RigidBody 같은 컴포넌트를 JSON으로 저장하거나, 설정 파일의 키를 구조체 멤버에 매핑하거나, API 요청 객체의 필수 필드를 검사하려면 멤버의 이름과 타입과 개수를 알아야 합니다. Java나 C#은 런타임 리플렉션으로 이를 조회하지만 C++에는 그런 표준 기능이 없어서, 지금까지는 타입마다 직렬화 함수를 손으로 쓰거나 매크로나 코드 생성기에 의존해 왔습니다.
struct Transform { float x, y, z; float rx, ry, rz; };
// 컴포넌트마다 to_json/from_json을 직접 작성
std::string to_json(const Transform& t) { /* 6개 필드를 하나씩 */ }
void from_json(const json& j, Transform& t) { /* 6개 필드를 하나씩 */ }
이 방식의 진짜 문제는 코드 양보다 동기화입니다. 구조체에 필드를 추가하고 to_json만 고치고 from_json은 잊으면, 컴파일은 되지만 저장했다 불러온 객체에서 그 필드만 기본값으로 돌아옵니다. enum을 문자열로 바꾸는 switch도 같은 문제를 가져서, 열거자를 추가했는데 to_string을 고치지 않으면 로그에 빈 문자열이나 "unknown"이 찍힙니다. 컴파일 타임 리플렉션은 타입 선언 자체에서 멤버 목록을 꺼내 이런 코드를 생성하므로, 선언과 직렬화 코드가 어긋날 여지를 없앱니다.
flowchart LR
subgraph problem[수동 작성]
P1[User 구조체] --> P2[to_json 수동 작성]
P3[Order 구조체] --> P4[to_json 수동 작성]
P2 --> P5[멤버 추가 시 누락 위험]
P4 --> P5
end
subgraph solution[컴파일 타임 리플렉션]
S1[타입 선언] --> S2[^^T 또는 magic_enum]
S2 --> S3[멤버 자동 추출]
S3 --> S4[직렬화·검증 코드 생성]
end
C++26 Reflection (P2996) 개요
C++26에 채택된 리플렉션
2025년 6월 WG21 회의에서 P2996 “Reflection for C++26”이 채택되었고, 함께 쓰는 제안들도 같이 들어갔습니다. 멤버 목록을 순회하며 코드를 펼치는 확장 문 template for(P1306), 컴파일 타임 벡터를 정적 배열로 바꾸는 std::define_static_array(P3491), 필드에 값을 붙이는 어노테이션 [[=...]](P3394), 접근 권한을 명시하는 std::meta::access_context(P3547)입니다. 표준화 과정에서 API가 여러 번 바뀌어서, 인터넷의 예제 중 std::meta::name_v<^^T>나 nonstatic_data_members_of<^^T> 같은 템플릿 변수 형태, 일반 for (constexpr ...) 루프, consteval 변수 선언은 채택된 문법이 아니며 컴파일되지 않습니다. 이 글의 C++26 예제는 채택된 형태로 작성했습니다.
| 도구 | 역할 | 예 |
|---|---|---|
^^ | 엔티티를 컴파일 타임 값 std::meta::info로 | constexpr auto r = ^^User; |
메타함수 (consteval) | info로 이름·타입·멤버 목록 조회 | identifier_of(r), type_of(r), nonstatic_data_members_of(r, ctx), enumerators_of(r) |
[: r :] (스플라이스) | info를 다시 코드로 | obj.[:m:], typename [:t:] |
template for | 컴파일 타임 범위를 원소마다 펼치기 | 멤버마다 직렬화 코드 생성 |
[[=v]] | 필드에 값 어노테이션 | [[=skip]] std::string cache; |
std::meta::info 값은 컴파일 타임에만 존재하므로 변수는 constexpr로 선언하고, 이를 다루는 함수는 상수 평가 문맥에서 호출됩니다. 기초 문법은 C++26 리플렉션 기초(#26-1)에 따로 정리했고, 이 글은 직렬화·검증에 쓰는 방법과 C++26 이전의 대안에 집중합니다.
#include <meta>
struct Point { int x; int y; };
static_assert(std::meta::identifier_of(^^Point) == "Point");
static_assert(std::meta::identifier_of(^^Point::x) == "x");
static_assert(std::meta::type_of(^^Point::x) == ^^int);
identifier_of는 컴파일 타임에 만들어진 문자열을 가리키는 std::string_view를 돌려주므로, 런타임 문자열이 필요하면 쓰는 곳에서 std::string으로 복사합니다. 익명 멤버처럼 이름이 없는 엔티티에 호출하면 상수 평가가 실패하므로, 그런 경우가 있을 수 있으면 std::meta::has_identifier로 먼저 확인합니다.
access_context
멤버를 나열하는 메타함수는 어떤 위치에서 접근 가능한 멤버를 보고 싶은지를 두 번째 인자로 받습니다. access_context::current()는 호출한 위치에서 접근 가능한 멤버만, access_context::unchecked()는 private까지 전부를 돌려줍니다. 리플렉션이 캡슐화를 우회하는 도구가 되지 않도록, private을 보려면 코드에 그 의도를 명시하게 한 것입니다. 아래 직렬화 예제는 private 멤버도 저장해야 하는 직렬화 계층을 가정해 unchecked()를 썼지만, 일반 코드에서는 current()가 기본입니다.
기능 테스트 매크로
#if defined(__cpp_impl_reflection)
#include <meta>
#define HAS_STD_REFLECTION 1
#else
#define HAS_STD_REFLECTION 0
#endif
GCC 16은 -std=c++26 -freflection으로 구현을 제공하고, Clang은 Bloomberg의 clang-p2996 포크를 Compiler Explorer에서 쓸 수 있습니다. MSVC는 아직 공개된 구현이 없으므로, 여러 컴파일러를 지원하는 코드는 이 매크로로 대체 경로를 둡니다. 초기 구현의 매크로 값은 버전마다 다를 수 있어 특정 숫자와 비교하기보다 정의 여부로 분기하는 편이 안전합니다.
C++26 리플렉션으로 직렬화·검증·enum 변환
자동 JSON 직렬화
#include <meta>
#include <string>
#include <type_traits>
inline std::string quote(std::string_view s) { return "\"" + std::string(s) + "\""; }
template <typename T>
std::string to_json(const T& obj) {
std::string out = "{";
bool first = true;
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))) {
if (!first) out += ",";
first = false;
out += quote(std::meta::identifier_of(m)) + ":";
using M = std::remove_cvref_t<decltype(obj.[:m:])>;
if constexpr (std::is_same_v<M, std::string>) out += quote(obj.[:m:]);
else if constexpr (std::is_same_v<M, bool>) out += obj.[:m:] ? "true" : "false";
else if constexpr (std::is_arithmetic_v<M>) out += std::to_string(obj.[:m:]);
else out += to_json(obj.[:m:]); // 중첩 구조체
}
return out + "}";
}
struct User { int id; std::string name; std::string email; };
// to_json(User{1, "Alice", "alice@example.com"})
// {"id":1,"name":"Alice","email":"alice@example.com"}
std::vector<std::meta::info>는 컴파일 타임에만 존재할 수 있어서 template for에 바로 넘길 수 없고, std::define_static_array로 정적 배열로 바꿔야 합니다. 이걸 빠뜨리면 “확장 문의 범위가 상수 표현식이 아니다”는 에러가 나는데, 처음 쓸 때 가장 자주 보는 에러입니다. 문자열 이스케이프(따옴표, 역슬래시, 제어 문자)는 예제를 짧게 하려고 생략했으므로 실제로는 JSON 라이브러리의 이스케이프 함수를 거쳐야 합니다.
필수 필드 검증 (어노테이션)
어노테이션으로 “이 필드는 비어 있으면 안 된다” 같은 규칙을 필드 선언 옆에 붙이고, 검증 함수가 그 어노테이션을 읽게 할 수 있습니다.
#include <meta>
#include <string>
#include <vector>
struct required_t {};
inline constexpr required_t required{};
struct SignupForm {
[[=required]] std::string email;
[[=required]] std::string password;
std::string nickname; // 선택
};
template <typename T>
std::vector<std::string> missing_required(const T& obj) {
std::vector<std::string> missing;
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))) {
if constexpr (!std::meta::annotations_of_with_type(m, ^^required_t).empty()) {
if (obj.[:m:].empty()) missing.emplace_back(std::meta::identifier_of(m));
}
}
return missing;
}
// missing_required(SignupForm{"", "pw", ""}) → {"email"}
규칙이 필드와 같은 줄에 있으니, 필드를 추가하거나 이름을 바꿔도 검증 목록을 따로 고칠 일이 없습니다.
enum 변환
#include <meta>
#include <optional>
#include <string_view>
#include <type_traits>
template <typename E> requires std::is_enum_v<E>
constexpr std::string_view enum_name(E v) {
template for (constexpr auto e : std::define_static_array(std::meta::enumerators_of(^^E))) {
if (v == [:e:]) return std::meta::identifier_of(e);
}
return "<unknown>";
}
template <typename E> requires std::is_enum_v<E>
constexpr std::optional<E> enum_from_name(std::string_view name) {
template for (constexpr auto e : std::define_static_array(std::meta::enumerators_of(^^E))) {
if (name == std::meta::identifier_of(e)) return [:e:];
}
return std::nullopt;
}
enum class Status { Idle, Running = 100, Stopped = 5000 };
static_assert(enum_name(Status::Stopped) == "Stopped");
컴파일러가 열거자 목록을 직접 알려 주므로, 뒤에서 볼 magic_enum과 달리 값이 5000이어도 범위 설정이 필요 없습니다.
C++26 이전의 대안 1: magic_enum
magic_enum은 C++17 헤더 전용 라이브러리로, enum과 문자열 사이의 변환과 열거자 순회를 매크로 없이 제공합니다. vcpkg(vcpkg install magic-enum)로 설치하거나 GitHub에서 헤더를 받아 include하면 됩니다.
#include <magic_enum.hpp>
#include <iostream>
enum class Color { Red, Green, Blue };
int main() {
std::cout << magic_enum::enum_name(Color::Green) << "\n"; // Green
auto c = magic_enum::enum_cast<Color>("Blue"); // std::optional<Color>
auto d = magic_enum::enum_cast<Color>(2); // 정수에서도 변환
if (c && d && *c == *d) std::cout << "same\n";
for (auto [value, name] : magic_enum::enum_entries<Color>()) {
std::cout << static_cast<int>(value) << " -> " << name << "\n";
}
}
enum_cast는 실패하면 빈 std::optional을 돌려주므로, 사용자 입력처럼 잘못된 문자열이 올 수 있는 곳에서는 has_value()를 확인하고 써야 합니다. 확인 없이 .value()를 부르면 std::bad_optional_access 예외가 납니다.
동작 원리와 그에서 나오는 제약
magic_enum은 표준 리플렉션 없이 __PRETTY_FUNCTION__(MSVC는 __FUNCSIG__)를 이용합니다. template <auto V> f() 같은 함수의 시그니처 문자열에는 V가 열거자 이름으로 찍히는데, 정의되지 않은 값이면 (Color)7처럼 숫자로 찍힙니다. 라이브러리는 정해진 정수 범위의 값을 하나씩 이 함수에 넣어 보고, 이름이 나오는 값만 열거자로 수집합니다. 그래서 다음 제약이 생깁니다.
- 스캔 범위는 기본으로 -128부터 127까지입니다. 이 범위 밖의 열거자는 존재하지 않는 것처럼 취급되어
enum_name이 빈 문자열을 돌려줍니다. - 같은 값을 가진 열거자(별칭)는 구분할 수 없어서 그중 하나의 이름만 나옵니다.
- 범위를 넓힐수록 인스턴스화할 함수 수가 늘어 컴파일 시간이 길어집니다.
범위 밖의 값을 가진 enum은 범위를 넓혀야 합니다. 전역 매크로는 magic_enum.hpp를 include하기 전에 정의해야 하고, 특정 enum만 조정하려면 customize::enum_range를 특수화하는 편이 영향 범위가 작습니다.
// 방법 1: 전역 범위 (include 전에 정의)
#define MAGIC_ENUM_RANGE_MIN 0
#define MAGIC_ENUM_RANGE_MAX 2048
#include <magic_enum.hpp>
// 방법 2: 특정 enum만
enum class BigEnum { A = 0, B = 1000, C = 2000 };
template <>
struct magic_enum::customize::enum_range<BigEnum> {
static constexpr int min = 0;
static constexpr int max = 2048;
};
전역 매크로를 헤더 하나에서만 정의하고 다른 번역 단위에서는 정의하지 않으면 같은 enum에 대해 서로 다른 결과가 나올 수 있으므로, 전역 설정은 빌드 옵션(-DMAGIC_ENUM_RANGE_MAX=2048)으로 주는 편이 안전합니다. 비트 플래그 enum은 magic_enum::enum_flags_name 같은 별도 함수를 씁니다.
nlohmann/json과 함께 쓰기
#include <magic_enum.hpp>
#include <nlohmann/json.hpp>
#include <stdexcept>
enum class Status { Idle, Running, Stopped };
void to_json(nlohmann::json& j, Status s) {
j = std::string(magic_enum::enum_name(s));
}
void from_json(const nlohmann::json& j, Status& s) {
auto value = magic_enum::enum_cast<Status>(j.get<std::string>());
if (!value) throw std::runtime_error("Invalid Status: " + j.get<std::string>());
s = *value;
}
이 두 함수를 enum과 같은 네임스페이스에 두면 nlohmann/json이 ADL로 찾아 씁니다. nlohmann/json 자체의 NLOHMANN_JSON_SERIALIZE_ENUM 매크로도 있지만, 그쪽은 매핑 목록을 손으로 적어야 하므로 열거자를 추가할 때 같은 동기화 문제가 남습니다. CLI 옵션 파싱에도 같은 방식을 쓸 수 있어서, 잘못된 값이 들어오면 enum_entries로 허용되는 값 목록을 출력해 주면 됩니다.
C++26 이전의 대안 2: 구조체 멤버 순회
Boost.PFR
집합체(aggregate) 구조체라면 Boost.PFR이 가장 손이 덜 갑니다. 구조체 바인딩과 집합체 초기화 규칙을 이용해 등록 없이 필드 개수와 각 필드를 꺼내고, C++20과 Boost 1.84 이상에서는 필드 이름도 얻을 수 있습니다.
#include <boost/pfr.hpp>
#include <iostream>
#include <string>
struct User { int id; std::string name; std::string email; };
int main() {
User u{1, "Alice", "alice@example.com"};
boost::pfr::for_each_field(u, [](const auto& field, std::size_t i) {
std::cout << boost::pfr::names_as_array<User>()[i] << "=" << field << "\n";
});
static_assert(boost::pfr::tuple_size_v<User> == 3); // 필드 수가 바뀌면 컴파일 에러
}
생성자나 private 멤버, 가상 함수, 기반 클래스가 있는 타입은 집합체가 아니므로 쓸 수 없습니다. 마지막 줄의 static_assert는 리플렉션을 쓰지 않는 손 작성 직렬화에도 유용합니다. 직렬화 함수 옆에 필드 수를 고정해 두면, 누군가 필드를 추가했을 때 직렬화 함수를 고치라는 컴파일 에러가 납니다.
std::tie로 멤버를 튜플로 묶기
Boost를 쓸 수 없다면 멤버를 튜플로 묶는 함수를 하나 두고 std::apply로 순회할 수 있습니다.
#include <iostream>
#include <string>
#include <tuple>
struct User { int id; std::string name; std::string email; };
auto as_tuple(const User& u) { return std::tie(u.id, u.name, u.email); }
template <typename T>
void print_members(const T& t) {
std::apply([](const auto&... members) {
((std::cout << members << " "), ...);
}, as_tuple(t));
}
이 방식은 이름을 얻을 수 없고, as_tuple의 멤버 목록을 손으로 유지해야 합니다. 특히 std::tie(u.name, u.id, u.email)처럼 순서를 선언과 다르게 적으면 타입이 맞는 한 컴파일 에러 없이 엉뚱한 필드에 값이 들어가므로, 역직렬화에 쓸 때는 위의 static_assert와 테스트로 보완해야 합니다.
X-Macro
X-Macro는 멤버 목록을 매크로 하나에 정의하고, 같은 목록을 서로 다른 X 정의로 여러 번 펼쳐 구조체 선언과 직렬화 코드를 만드는 기법입니다. 이름은 #name으로 문자열화하므로 Boost나 C++26 없이도 필드 이름을 얻을 수 있습니다.
#include <nlohmann/json.hpp>
#include <string>
#define CONFIG_FIELDS(X) \
X(int, port, 8080) \
X(std::string, host, "localhost") \
X(bool, ssl, false)
struct Config {
#define DECLARE_FIELD(type, name, def) type name = def;
CONFIG_FIELDS(DECLARE_FIELD)
#undef DECLARE_FIELD
};
inline void to_json(nlohmann::json& j, const Config& c) {
#define WRITE_FIELD(type, name, def) j[#name] = c.name;
CONFIG_FIELDS(WRITE_FIELD)
#undef WRITE_FIELD
}
inline void from_json(const nlohmann::json& j, Config& c) {
#define READ_FIELD(type, name, def) \
if (j.contains(#name)) c.name = j.at(#name).get<type>();
CONFIG_FIELDS(READ_FIELD)
#undef READ_FIELD
}
필드를 추가할 때는 CONFIG_FIELDS 한 곳만 고치면 선언, 기본값, 직렬화, 역직렬화가 함께 바뀝니다. 목록 매크로가 X를 인자로 받게 하면 매번 #define X/#undef X로 같은 이름을 재정의하지 않아도 되고, 여러 X-Macro 목록을 한 파일에서 써도 이름이 충돌하지 않습니다. 대신 매크로 확장 결과는 디버거에서 보기 어렵고, 에러 메시지가 매크로 확장 위치를 가리켜 원인을 찾기 번거롭다는 비용이 있습니다.
컴파일러별 폴백 구성
#if defined(__cpp_impl_reflection)
template <typename T>
std::string to_json(const T& obj); // C++26 리플렉션 기반 범용 구현
#else
template <typename T>
struct Serializer; // 타입마다 특수화 (Boost.PFR 또는 X-Macro로 생성)
template <typename T>
std::string to_json(const T& obj) { return Serializer<T>::to_json(obj); }
#endif
호출부는 어느 경로든 to_json(obj)만 쓰게 해 두면, 툴체인이 C++26을 지원하게 되었을 때 폴백 구현만 걷어 내면 됩니다.
흔한 에러
채택되지 않은 C++26 문법
리플렉션을 구현하지 않은 컴파일러(GCC 15 이하, 본가 Clang, MSVC)에서 쓰거나, 표준화 이전의 옛 예제를 복사하면 컴파일되지 않습니다.
// 옛 문법: 채택된 C++26에는 없음
consteval auto info = ^^MyStruct; // consteval은 변수에 못 붙임
auto name = std::meta::name_v<^^MyStruct>; // name_v 없음
for (constexpr auto m : std::meta::nonstatic_data_members_of<^^T>) {} // 템플릿 형태·일반 for 아님
// 채택된 문법
constexpr auto info2 = ^^MyStruct;
constexpr auto name2 = std::meta::identifier_of(info2);
magic_enum이 빈 문자열을 돌려줌
열거자 값이 스캔 범위(-128~127) 밖에 있다는 신호입니다. 위의 enum_range 특수화나 MAGIC_ENUM_RANGE_*로 범위를 넓힙니다. 매크로로 넓혔는데도 그대로라면 매크로를 magic_enum.hpp보다 늦게 정의했거나, 다른 헤더가 먼저 magic_enum.hpp를 include했는지 확인합니다.
private 멤버를 나열했는데 접근 에러
멤버를 나열할 때 쓴 access_context와 실제로 obj.[:m:]를 쓰는 위치의 권한이 맞지 않는 경우입니다. access_context::unchecked()로 private 멤버까지 나열한 뒤 스플라이스로 읽는 것은 표준이 허용하는 사용법이지만, 그만큼 캡슐화를 우회한다는 뜻이라 직렬화 계층 같은 곳에만 쓰는 것이 좋습니다. 일반 코드는 current()로 그 위치에서 접근 가능한 멤버만 다룹니다.
비용
세 방식 모두 멤버 목록을 컴파일 타임에 확정하므로, 생성되는 직렬화 코드는 손으로 쓴 코드와 같은 형태이고 런타임 오버헤드는 사실상 없습니다. magic_enum의 enum_name은 컴파일 타임에 만든 이름 배열에서 값을 찾는 것이고, enum_cast는 열거자 수만큼 문자열을 비교하므로 열거자 수에 비례합니다. 비용은 주로 컴파일 시간에서 납니다. magic_enum은 스캔 범위의 값마다 함수 템플릿을 인스턴스화하므로 범위를 크게 넓히면 빌드가 눈에 띄게 느려지고, C++26 리플렉션과 Boost.PFR도 타입마다 템플릿을 펼치므로 많은 타입에 적용하면 컴파일 시간이 늘어납니다. X-Macro는 전처리 단계의 텍스트 치환이라 추가 비용이 가장 작습니다.
이전 글: C++ 실전 가이드 #26-2: 컴파일 타임 프로그래밍 기법 다음 글: C++ 실전 가이드 #27-1: Boost 라이브러리 활용