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 리플렉션은 세 가지 도구의 조합으로 이해하면 쉽습니다.

  1. ^^ (reflection operator): 엔티티(타입, 변수, 멤버, 함수, 네임스페이스, 열거자)를 std::meta::info라는 값으로 바꿉니다.
  2. std::meta:: 메타함수: info 값을 받아 이름, 타입, 멤버 목록 같은 정보를 돌려주는 consteval 함수들입니다.
  3. [: 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_contextcurrent()는 접근 가능한 멤버만, unchecked()는 전부
template for + define_static_array멤버 목록을 순회하며 멤버마다 코드 전개
[: r :]info를 다시 코드(값·타입·멤버 접근)로
[[=v]]값을 가진 어노테이션, annotations_of_with_type로 조회
지원GCC 16, clang-p2996(실험), MSVC 미지원 → 기능 테스트 매크로로 대체 경로

참고 자료

이전 글: [C++ 실전 가이드 #25-3] 커스텀 Range 작성 다음 글: [C++ 실전 가이드 #26-2] 컴파일 타임 프로그래밍 기법

같이 보면 좋은 글