C++26 리플렉션 기초: ^^ 연산자, std::meta::info, template for로 멤버 순회하기
들어가며: 구조체 멤버를 자동으로 순회하고 싶다
User, Order, Product 같은 구조체가 늘어날 때마다 JSON 직렬화, DB 매핑, 로그 출력 코드를 손으로 쓰면, 멤버를 추가하거나 이름을 바꿀 때마다 그 코드도 같이 고쳐야 합니다. 빠뜨리면 컴파일은 되고 데이터만 조용히 빠집니다. 저도 새 필드를 추가해 놓고 직렬화 함수에 넣는 것을 잊어서, 클라이언트에서 “값이 안 온다”는 제보를 받고서야 알게 된 적이 여러 번 있습니다.
C++는 오랫동안 이 문제를 매크로, 수동 등록, Boost.PFR 같은 트릭으로 우회해 왔습니다. C++26에서 드디어 정적 리플렉션(static reflection)이 표준에 들어왔습니다. 2025년 6월 WG21 회의에서 P2996 “Reflection for C++26”과 함께 이를 실용적으로 만드는 제안들(template for 확장 문, [[=...]] 어노테이션, std::define_static_array, access_context)이 채택되었습니다.
주의할 점이 하나 있습니다. 표준화 과정에서 API가 여러 번 바뀌었기 때문에 인터넷에 떠도는 예제 상당수(std::meta::name_v<^^T>, nonstatic_data_members_of<^^T> 같은 템플릿 형태, 일반 for (constexpr ...) 루프)는 채택된 문법과 다르고 컴파일되지 않습니다. 이 글의 예제는 채택된 형태(P2996R13 기준)로 작성했고, GCC 16이나 Compiler Explorer의 clang-p2996에서 확인할 수 있습니다.
C++26 리플렉션의 큰 그림
| 구분 | 런타임 리플렉션 (Java, C#) | C++26 정적 리플렉션 |
|---|---|---|
| 조회 시점 | 실행 중 | 컴파일 중 |
| 실행 파일 크기 | 메타데이터 테이블 포함 | 쓴 만큼만 코드 생성 |
| 런타임 비용 | 조회마다 발생 | 없음 (손으로 쓴 코드와 동일) |
| 문자열로 임의 타입 찾기 | 기본 제공 | 직접 레지스트리를 생성해야 함 |
C++26 리플렉션은 세 가지 도구의 조합으로 이해하면 쉽습니다.
^^(reflection operator): 엔티티(타입, 변수, 멤버, 함수, 네임스페이스, 열거자)를std::meta::info라는 값으로 바꿉니다.std::meta::메타함수:info값을 받아 이름, 타입, 멤버 목록 같은 정보를 돌려주는consteval함수들입니다.[: r :](splice):info값을 다시 코드(타입, 표현식, 멤버 접근)로 되돌립니다.
여기에 template for(확장 문)가 더해져, 멤버 목록을 순회하면서 멤버마다 코드를 펼칠 수 있게 됩니다.
flowchart LR A["코드 엔티티\nstruct User"] -->|"^^"| B["std::meta::info\n(컴파일 타임 값)"] B -->|"std::meta::*\n메타함수"| C["멤버 목록, 이름, 타입"] C -->|"template for"| D["멤버마다 코드 전개"] B -->|"[: r :]"| E["다시 코드로\n타입·표현식·멤버 접근"]
^^ 연산자와 std::meta::info
#include <meta>
struct Point { int x; int y; };
constexpr std::meta::info r_point = ^^Point; // 타입 반사
constexpr std::meta::info r_int = ^^int;
constexpr std::meta::info r_x = ^^Point::x; // 멤버 반사
^^는 원래 ^ 한 글자로 제안되었지만, Clang의 Objective-C 블록 확장이 ^를 이미 쓰고 있어 충돌 때문에 두 글자로 바뀌었습니다.
std::meta::info는 내부 구조를 알 수 없는 불투명(opaque) 스칼라 타입이고 컴파일 타임에만 존재하는 값입니다. 그래서 이 타입의 변수는 constexpr이어야 하고, 값을 다루는 함수는 consteval이거나 상수 평가 문맥에서 호출되어야 합니다. info를 런타임 변수에 담으려고 하면 컴파일 에러가 납니다. consteval std::meta::info r = ...처럼 변수에 consteval을 붙이는 예제도 보이는데, consteval은 함수에만 붙일 수 있는 지정자라 이것도 틀린 문법입니다.
같은 엔티티를 반사한 값은 서로 같고(^^int == ^^int), 다른 엔티티는 다릅니다. 비교는 ==로 합니다.
메타함수로 정보 조회하기
자주 쓰는 메타함수는 많지 않습니다.
| 메타함수 | 반환 | 설명 |
|---|---|---|
identifier_of(r) | std::string_view | 선언된 이름(식별자). 이름이 없는 엔티티면 상수 평가 실패 |
has_identifier(r) | bool | 이름이 있는지 먼저 확인 |
display_string_of(r) | std::string_view | 사람이 읽을 표시 문자열(구현마다 다를 수 있음, 로그용) |
type_of(r) | info | 멤버·변수의 타입 |
nonstatic_data_members_of(r, ctx) | std::vector<info> | 비정적 데이터 멤버 목록 (선언 순서) |
enumerators_of(r) | std::vector<info> | 열거자 목록 |
bases_of(r, ctx) | std::vector<info> | 기반 클래스 목록 |
is_public(r), is_static_member(r) 등 | bool | 성질 검사 |
#include <meta>
#include <string_view>
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);
access_context: 어떤 멤버를 볼 수 있는가
멤버를 나열하는 함수들은 두 번째 인자로 접근 문맥을 받습니다. 표준화 막바지에 추가된 부분이라 오래된 예제에는 없습니다.
constexpr auto ctx_cur = std::meta::access_context::current(); // 이 코드 위치에서 접근 가능한 멤버만
constexpr auto ctx_all = std::meta::access_context::unchecked(); // private 포함 전부
auto members = std::meta::nonstatic_data_members_of(^^Point, ctx_all);
리플렉션으로 private 멤버를 읽을 수 있게 되면 캡슐화가 깨진다는 우려가 있었고, 그 절충으로 “접근 권한을 무시하겠다”는 의도를 코드에 명시하게 한 것입니다. 직렬화 라이브러리처럼 모든 필드를 봐야 하는 곳은 unchecked(), 일반 코드는 current()를 쓰는 것이 원칙입니다.
template for로 멤버 순회하기
nonstatic_data_members_of가 돌려주는 것은 std::vector<std::meta::info>인데, 이 벡터는 컴파일 타임에만 존재할 수 있어서 일반 for 루프로 돌면서 각 원소를 [: :]에 넣을 수 없습니다. 루프 변수 m이 멤버마다 다른 타입의 코드를 만들어야 하기 때문입니다. 이것을 해결하는 것이 C++26의 확장 문(expansion statement) template for입니다. 컴파일러가 루프 본문을 원소 개수만큼 펼쳐서, 각 복사본에서 m이 상수가 되게 합니다.
컴파일 타임 벡터를 template for에 넘기려면 정적 저장소에 있는 배열로 바꿔야 하는데, 그 역할을 std::define_static_array가 합니다.
#include <meta>
#include <iostream>
#include <string>
struct User {
int id;
std::string name;
bool active;
};
template <typename T>
void print_fields(const T& obj) {
constexpr auto ctx = std::meta::access_context::unchecked();
template for (constexpr std::meta::info m :
std::define_static_array(std::meta::nonstatic_data_members_of(^^T, ctx))) {
std::cout << std::meta::identifier_of(m) << " = " << obj.[:m:] << '\n';
}
}
int main() {
User u{1, "Alice", true};
print_fields(u);
}
// id = 1
// name = Alice
// active = 1
obj.[:m:]가 멤버 접근 스플라이스입니다. m이 User::name을 반사한 값이라면 이 표현식은 obj.name과 똑같이 컴파일됩니다. 멤버를 추가하면 이 함수는 아무 수정 없이 새 멤버까지 출력합니다.
열거형 ↔ 문자열
리플렉션의 가장 즉각적인 쓸모는 열거형 이름 변환입니다. 지금까지는 magic_enum이 컴파일러의 __PRETTY_FUNCTION__ 문자열을 파싱하는 트릭으로 구현했고, 그래서 값 범위 제한(기본 -128~127)이 있었습니다. C++26에서는 열거자 목록을 직접 순회합니다.
#include <meta>
#include <string_view>
#include <optional>
#include <type_traits>
template <typename E> requires std::is_enum_v<E>
constexpr std::string_view enum_to_string(E value) {
template for (constexpr auto e : std::define_static_array(std::meta::enumerators_of(^^E))) {
if (value == [:e:]) return std::meta::identifier_of(e);
}
return "<unknown>";
}
template <typename E> requires std::is_enum_v<E>
constexpr std::optional<E> string_to_enum(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 Color { Red, Green = 10, Blue = 1000 };
static_assert(enum_to_string(Color::Blue) == "Blue"); // 값 범위 제한 없음
static_assert(string_to_enum<Color>("Green") == Color::Green);
멤버 순회로 자동 JSON 직렬화 만들기
멤버 타입에 따라 다른 코드를 내야 할 때는 스플라이스한 표현식의 타입을 decltype으로 얻어 if constexpr로 분기하는 방법이 가장 읽기 쉽습니다.
#include <meta>
#include <string>
#include <vector>
#include <type_traits>
template <typename T> std::string to_json(const T& v);
inline std::string quote(const std::string& s) {
std::string out = "\"";
for (char c : s) { if (c == '"' || c == '\\') out += '\\'; out += c; }
return out + "\"";
}
template <typename T>
std::string to_json_value(const T& v) {
if constexpr (std::is_same_v<T, bool>) return v ? "true" : "false";
else if constexpr (std::is_arithmetic_v<T>) return std::to_string(v);
else if constexpr (std::is_same_v<T, std::string>) return quote(v);
else if constexpr (requires { v.begin(); v.end(); }) {
std::string out = "[";
bool first = true;
for (const auto& e : v) { if (!first) out += ","; out += to_json_value(e); first = false; }
return out + "]";
}
else return to_json(v); // 중첩 구조체는 재귀
}
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::string(std::meta::identifier_of(m))) + ":";
using M = std::remove_cvref_t<decltype(obj.[:m:])>;
out += to_json_value<M>(obj.[:m:]);
}
return out + "}";
}
struct Address { std::string city; int zip; };
struct Customer { int id; std::string name; std::vector<int> orders; Address address; };
// to_json(Customer{7, "Kim", {1, 2}, {"Seoul", 4520}})
// {"id":7,"name":"Kim","orders":[1,2],"address":{"city":"Seoul","zip":4520}}
실제로는 이 정도를 직접 쓰기보다 리플렉션을 지원하기 시작한 라이브러리를 쓰게 될 것입니다. 예를 들어 Glaze는 이미 P2996 경로를 실험적으로 지원합니다. 다만 직접 한 번 써 보면 라이브러리가 무엇을 해 주는지, 그리고 컴파일 에러가 났을 때 어디를 봐야 하는지 이해하는 데 도움이 됩니다. 제가 처음 이 코드를 쓸 때 가장 많이 막힌 곳은 std::define_static_array를 빠뜨려서 “expansion statement의 범위가 상수 표현식이 아니다”는 에러를 받는 부분이었습니다.
타입 비교에서 주의할 점
type_of(m) == ^^std::string처럼 info끼리 비교해 분기할 수도 있지만, std::string은 std::basic_string<char>의 별칭(alias)이라 반사한 값이 별칭 자체를 가리킬 수 있어 비교가 기대와 다를 수 있습니다. 별칭을 풀려면 std::meta::dealias를 쓰는데, 위 예제처럼 decltype으로 실제 타입을 얻어 기존 타입 특성(std::is_same_v)으로 비교하는 편이 실수가 적습니다.
어노테이션 [[=...]]
필드마다 “직렬화에서 제외”, “JSON 키 이름은 다르게” 같은 설정을 붙이고 싶을 때 C++26 어노테이션을 씁니다(P3394). 문법은 속성처럼 생겼지만 = 뒤에 상수 표현식 값을 적는다는 점이 다릅니다.
#include <meta>
#include <string_view>
struct skip_t {};
inline constexpr skip_t skip{};
struct rename { std::string_view name; };
struct Account {
int id;
[[=rename{"user_name"}]] std::string name;
[[=skip]] std::string password_hash; // 직렬화하지 않음
};
리플렉션 쪽에서는 annotations_of_with_type(m, ^^T)로 특정 타입의 어노테이션을 찾고, std::meta::extract<T>(a)로 값을 꺼냅니다.
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, ^^skip_t).empty()) {
constexpr auto renames = std::define_static_array(
std::meta::annotations_of_with_type(m, ^^rename));
constexpr std::string_view key = renames.size() > 0
? std::meta::extract<rename>(renames[0]).name
: std::meta::identifier_of(m);
// key와 obj.[:m:]로 직렬화
}
}
일반 속성([[nodiscard]] 같은)은 컴파일러가 모르는 것을 무시해도 되지만, 어노테이션은 값이 있고 리플렉션으로 읽을 수 있다는 것이 핵심 차이입니다. Rust의 #[serde(rename = "...")] 같은 선언적 설정을 C++에서도 표준 문법으로 할 수 있게 된 셈입니다.
스플라이스 문법 정리
| 형태 | 의미 | 예 |
|---|---|---|
[: r :] | 표현식 (변수, 열거자, 함수) | if (v == [:e:]) |
typename [: r :] | 타입 | typename [: ^^int :] x = 0; |
obj.[: r :] | 멤버 접근 | obj.[:m:] |
&[: r :] | 비정적 멤버면 멤버 포인터 | auto p = &[: ^^Point::x :]; |
template [: r :] | 템플릿 | template [: r :]<int> |
템플릿 안처럼 의존적인 문맥에서 스플라이스가 타입을 뜻한다면 typename을 앞에 붙여야 합니다. 일반 템플릿 코드의 typename T::value_type과 같은 이유입니다.
컴파일러 지원과 대체 경로
2026년 기준 상황은 이렇습니다.
- GCC 16:
-std=c++26 -freflection으로 P2996 구현을 제공합니다. 버전과 빌드에 따라template for에 별도 플래그가 필요한 경우도 있으니 릴리스 노트를 확인하세요. - Clang: 본가(LLVM)에는 아직 병합 중이고, Bloomberg가 관리하는 clang-p2996 포크가 표준안을 가장 충실히 따릅니다. Compiler Explorer에서 “x86-64 clang (reflection)” 계열 컴파일러로 바로 실험할 수 있습니다.
- MSVC: 2026년 9월 기준 공개된 구현이 없습니다.
여러 컴파일러를 지원해야 하는 라이브러리라면 기능 테스트 매크로로 경로를 나눕니다.
#if defined(__cpp_impl_reflection)
#include <meta>
#define HAS_STD_REFLECTION 1
#else
#define HAS_STD_REFLECTION 0
#endif
__cpp_impl_reflection은 언어 기능, __cpp_lib_reflection은 <meta> 헤더의 라이브러리 지원을 나타냅니다. 구현마다 초기 버전의 값이 다를 수 있으므로 특정 숫자와 비교하기보다 정의 여부로 먼저 분기하는 편이 안전합니다.
C++17/20에서 쓸 수 있는 대안
- Boost.PFR: 집합체(aggregate) 구조체의 필드를 인덱스로 순회합니다. C++20과 Boost 1.84 이상에서는
boost::pfr::get_name<I, T>()로 필드 이름도 얻을 수 있습니다. 등록이 전혀 필요 없어서 C++26 리플렉션 이전에 가장 가까운 선택지입니다. 생성자가 있거나 기반 클래스·private 멤버가 있는 클래스에는 쓸 수 없습니다.
#include <boost/pfr.hpp>
#include <iostream>
struct Point { int x; int y; };
int main() {
Point p{10, 20};
boost::pfr::for_each_field(p, [](const auto& field, std::size_t i) {
std::cout << i << ": " << field << '\n';
});
std::cout << boost::pfr::get_name<0, Point>() << '\n'; // "x" (C++20, Boost 1.84+)
}
- magic_enum: 열거형 ↔ 문자열. 값 범위 제한(기본 -128~127)이 있고, 범위를 넓히면 컴파일 시간이 늘어납니다.
- 매크로로 필드 목록 한 곳에 모으기:
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(User, id, name)처럼 필드를 한 번만 나열하게 하는 방식입니다. 필드를 추가할 때 매크로에도 넣어야 한다는 점은 남지만, 직렬화 함수를 통째로 손으로 쓰는 것보다 누락 가능성이 훨씬 낮습니다. - RTTR 같은 등록 기반 런타임 리플렉션: 게임 에디터처럼 런타임에 문자열로 타입과 속성을 찾아야 할 때 적합합니다. 등록 코드를 따로 유지해야 하고, 라이브러리 유지보수가 활발하지 않다는 점은 감안해야 합니다.
C++26 리플렉션이 이런 대안을 곧바로 모두 대체하지는 않습니다. 컴파일러 지원이 고르게 퍼지기 전까지는, 새 코드에서는 Boost.PFR이나 매크로로 필드 목록을 한 곳에 모으는 구조를 유지하다가, 지원 컴파일러로 옮길 때 그 부분만 리플렉션으로 바꾸는 식의 점진적 전환이 현실적입니다.
옛 문법 예제, define_static_array 누락, 별칭 비교 같은 실수
| 증상 | 원인 | 해결 |
|---|---|---|
name_v, members_of<...> 같은 이름을 찾을 수 없음 | 폐기된 초기 문법 예제 | identifier_of(r), nonstatic_data_members_of(r, ctx) 같은 함수 호출 형태로 |
| ”expansion statement의 범위가 상수 표현식이 아님” | std::vector<info>를 그대로 template for에 넣음 | std::define_static_array(...)로 감싸기 |
nonstatic_data_members_of 인자 개수 오류 | access_context 인자 누락 (옛 예제) | access_context::current() 또는 unchecked() 추가 |
info 변수 선언에서 에러 | consteval 변수, 또는 constexpr 누락 | constexpr std::meta::info r = ^^T; |
identifier_of에서 상수 평가 실패 | 이름 없는 엔티티(익명 멤버, 일부 템플릿 특수화) | has_identifier로 먼저 확인하거나 display_string_of 사용 |
| 타입 비교가 항상 false | 별칭(std::string)과 실제 타입 비교 | dealias 사용 또는 decltype + is_same_v |
^^부터 [[=v]]까지 한 표로
| 항목 | 요점 |
|---|---|
^^ | 엔티티를 컴파일 타임 값 std::meta::info로 |
| 메타함수 | identifier_of, type_of, nonstatic_data_members_of(r, ctx), enumerators_of |
access_context | current()는 접근 가능한 멤버만, unchecked()는 전부 |
template for + define_static_array | 멤버 목록을 순회하며 멤버마다 코드 전개 |
[: r :] | info를 다시 코드(값·타입·멤버 접근)로 |
[[=v]] | 값을 가진 어노테이션, annotations_of_with_type로 조회 |
| 지원 | GCC 16, clang-p2996(실험), MSVC 미지원 → 기능 테스트 매크로로 대체 경로 |
참고 자료
- P2996: Reflection for C++26
- P1306: Expansion statements
- P3394: Annotations for Reflection
- bloomberg/clang-p2996
- Boost.PFR
이전 글: [C++ 실전 가이드 #25-3] 커스텀 Range 작성 다음 글: [C++ 실전 가이드 #26-2] 컴파일 타임 프로그래밍 기법