ShellEngine 리플렉션 시스템

Shell·2026년 3월 19일

해당 글은 ShellEngine의 리플렉션을 만들 때의 기억을 되살려 작성한 글입니다.

개요

2024년 초기에 엔진을 개발하다 이런 의문이 생겼다.

'에디터를 구현한다면 어떻게 클래스가 가지고 있는 변수들을 에디터에 띄우지?'
'Inspector에서 클래스마다 컴포넌트 멤버를 그리는 코드를 짜야 하나?'
'객체를 직렬화할 때도 또 같은 멤버 목록을 적어야 하나?'

위의 의문 외에도 멤버 목록을 수동으로 작성한다면 GC에서 추적 할 때도 한 번 더 써야 할 것이다.
즉, 같은 정보를 세 군데에 하드코딩 하게 될 것이고 멤버 하나 바뀌면 세 군데를 다 고쳐야 할 것이다.

이 문제를 해결하기 위해 타입 정보를 한 곳에 정의하고 여러 시스템이 그걸 읽는 구조로 만들고 싶었다.

표준 RTTI로는 부족하다

C++ 표준 RTTI도 런타임 타입 정보를 제공한다.
하지만 게임 엔진에서 쓰기 위해 필요한 정보를 얻기에는 많이 부족하다.

  • 멤버 변수 목록을 순회할 수 있어야 한다.
  • 컨테이너 원소 타입을 런타임에 알아야 한다.
  • Inspector에서 숨길지, 직렬화할지, GC가 추적할지를 멤버마다 설정할 수 있어야 한다.
  • 문자열 이름으로 프로퍼티를 찾고, 실제 인스턴스에서 값을 읽고 써야 한다.

이런건 C++ 표준 RTTI로는 불가능하다.
그래서 SCLASS, PROPERTY 같은 매크로로 타입 메타데이터를 수동 등록하는 구조를 택했다.
Unreal Engine의 접근과 비슷하지만 규모는 훨씬 작고 내가 직접 구조를 결정할 수 있었다.

하나의 메타데이터

관련 커밋: e29388a - 2024년 5월 4일

설계의 핵심은 단순했다. PROPERTY()로 멤버를 한 번 등록하면 그 정보를 여러 시스템이 가져다 쓰는 구조다.

Serialization

  • 저장 가능 여부와 타입 정보로 필드를 순회해 직렬화한다.

GC

  • SObject* 참조 여부를 읽어 마킹 대상을 결정한다.

Editor

  • Inspector에서 프로퍼티 이름과 타입을 읽어 UI를 자동으로 그린다.
  • constant, invisible 같은 옵션으로 편집 방식을 제어한다.

멤버가 하나 추가되면 PROPERTY() 한 줄이다.
그러면 Inspector에 자동으로 나타나고, 직렬화에 포함되고, GC가 추적한다.
수동으로 했다면 세 군데에 손댔을 것을 한 곳으로 줄일 수 있었다.

컨테이너 리플렉션

관련 커밋: 81be944 - 2024년 5월 7일

스칼라 값 하나를 다루는 건 쉬웠다.
int, float, std::string은 타입 정보만 있으면 읽고 쓸 수 있다. 문제는 컨테이너였다.

엔진 데이터는 대부분 컨테이너다. std::vector<Component*>, std::map<std::string, int>, 그리고 중첩된 컨테이너들..
여기서 막히는 지점은 생각보다 많다.
원소 타입을 어떻게 일반화해서 꺼낼 것인가, 삽입과 삭제를 컨테이너 종류와 무관하게 다룰 수 있는가, 컨테이너 순회는 어떻게?

이를 위해 컨테이너 타입의 프로퍼티는 PropertyIterator라는 클래스로 추상화하여 타입을 숨기고 InsertToContainer()와 Begin처럼 조작 할 수 있는 함수를 노출 시켰다.
만약 컨테이너가 아닌 프로퍼티가 Begin과 같은 함수를 호출한다면 빈 Iterator를 반환하게 했다.

이 부분은 정말 어려웠다. 하지만 개발하면서 템플릿을 다루는 것에 많이 익숙해졌다.

template<typename ThisType, typename T, typename VariablePointer, VariablePointer ptr>
class PropertyData : public IPropertyData<T>
{
...
  auto Begin(void* sobject) const -> PropertyIteratorT override
  {
      if constexpr (IsContainer<T>()) // 컴파일 시간에 결정됨
      {
          if constexpr (std::is_member_pointer_v<VariablePointer>)
          {
              T& container = static_cast<ThisType*>(sobject)->*ptr;
              return PropertyIteratorT{ PropertyIteratorData<T>{ &container, container.begin() } };
          }
          else
          {
              T& container = *ptr;
              return PropertyIteratorT{ PropertyIteratorData<T>{ &container, container.begin() } };
          }
      }
      else
          return PropertyIteratorT{};
  }
}

컨테이너별 분기문으로 해결하는 방법도 있다. 그런데 그러면 Inspector, 직렬화, GC 코드가 각자 똑같은 분기를 갖게 된다. PropertyIterator와 컨테이너 메타데이터를 공통 인터페이스로 만든 이유였다.
컨테이너 타입이 늘어나도 한 군데만 고치면 됐다.

PropertyOption

PROPERTY(curAnim, core::PropertyOption::invisible)
AnimationData* curAnim = nullptr;

타입 정보만 있는 리플렉션은 아직도 에디터에서 쓰기에 부족했다. "이 멤버는 Inspector에 보여주지 않는다", "이 멤버는 저장하지 않는다" 같은 세부 제어가 필요해졌다.

그래서 PropertyOption으로 각 프로퍼티에 속성을 붙일 수 있게 했다.
constant는 편집 불가, invisible은 Inspector에서 숨김, noSave는 직렬화 제외.
옵션 하나가 추가될 때마다 엔진 여러 시스템의 표현력이 같이 올라갔다.

sobjPtr
이전 GC 게시물에서도 언급한 부분이지만, 헤더 의존성을 줄이기 위해 전방 선언을 쓰는 경우가 있다.
그런데 그 포인터가 GC가 추적해야 하는 SObject* 계열이면, 컴파일 시점에 리플렉션이 이를 판별하지 못한다. GC와 직렬화 쪽에서 참조를 놓치게 된다.
sobjPtr 옵션은 "이 포인터는 SObject 취급해야 한다"는 의도를 명시적으로 전달하기 위해 만들었다.

DLL 경계에서 타입이 깨지는 문제

리플렉션을 만들면서 같은 타입인데 모듈이 다르면 주소 비교가 깨지는 문제를 마주쳤다.
엔진은 렌더, 코어, 에디터, 유저 코드를 DLL로 나누고 있었는데, 모듈이 각자 다른 TypeInfo 인스턴스를 갖게 되면 GC가 참조 판정을 틀리거나 Inspector가 타입 비교에 실패한다.
특히 리플렉션 등록 코드는 헤더파일에 등록되기 때문에 각 모듈별로 중복된 데이터가 생성됐다.

이 때문에 직렬화 복원이 실패하거나, Inspector 등록이 어긋나거나, 플러그인 리로드 시 등록 순서가 꼬이는 문제가 발생했다. 원인은 리플렉션인데 크래시는 전혀 다른 곳에서 났다.

이를 위해 타입의 size와 타입의 이름을 해시화 하고 비교하게 했다. 또한 core모듈에서 모든 타입을 관리하는 STypes클래스를 만들었다.

각 모듈에서 타입을 등록할 때 STypes에 이미 있는지 검사하고 등록한다. 이렇게 하니 모듈 경계를 넘어도 하나의 타입은 전부 같은 타입으로 인식 되게 할 수 있었다.

함수 리플렉션

멤버 변수 리플렉션만으로도 꽤 많은 걸 할 수 있었다. 그런데 에디터 기능이 커지면서 값을 읽고 쓰는 것 이상이 필요해졌다. 컴포넌트에 "이 버튼을 누르면 이 함수가 실행된다"를 만들고 싶었다. 디버그 호출, 에디터 스크립팅 확장, 사용자 정의 액션등..

함수 리플렉션은 그래서 구현했다.
에디터와 사용자 코드를 연결하려면 값뿐 아니라 호출 가능한 함수 프로퍼티도 필요했기 때문이다.

한계

매크로 기반 설계는 편리하지만, 그만큼 C++ 매크로가 단순 문자열 치환에 불과하다는 제약을 그대로 가져온다.

한계 1
다중 상속 시 부모 타입을 모두 등록할 수 없다
현재 구조에서는 부모 클래스에 선언된 SCLASS 매크로를 통해 부모를 알 수 있다.
하지만 다중 상속이 생기면 나머지 부모 타입은 리플렉션에서 보이지 않는다. 해결하려면 프로그래머가 SCLASS 선언 시 부모 타입을 직접 명시적으로 모두 등록하게 만들어야 한다. 지금은 그렇게 강제하는 구조가 없다.

한계 2
전방 선언된 타입은 정확히 판별하지 못한다
전방 선언만 있는 클래스는 컴파일 시점에 완전한 타입 정보가 없어서, 리플렉션이 그것이 SObject 계열인지 판단할 수 없다. sobjptr 프로퍼티 옵션으로 우회하고 있지만, 이 옵션을 빠뜨리면 GC가 참조를 놓친다.
두 한계가 겹치는 경우엔 실수하기 더 쉬운 구조다.

이를 해결하려면 언리얼 엔진의 UHT처럼 별도의 코드 파싱 단계를 두면 될 것이다.
헤더를 직접 분석해 메타데이터를 생성하면 매크로의 한계를 대부분 넘어설 수 있다.

다만 현재 엔진 규모에서는 그 비용을 치를 이유가 없어서 보류했다.
규모가 커지거나 다중 상속이 실제로 필요해지는 시점이 오면 그때 다시 꺼낼 문제다.

완성된 시스템보다 이런 트레이드오프를 인식하고 있는 쪽이 더 중요하다고 생각한다.
지금의 설계가 어디까지 버틸 수 있고 어디서 무너지는지를 알고 있는 것이 다음 단계로 넘어갈 때 방향을 잘못 잡지 않는 방법이라고 생각한다.

마무리

리플렉션은 잘못 키우면 금방 엔진 전역에 흩어진 예외 처리 묶음이 된다. 메타데이터는 한 번만 정의한다, 타입 비교는 모듈 경계를 넘어도 안정적이어야 한다, 예외 케이스는 분기 코드가 아니라 옵션으로 명시한다. 이 기준을 붙잡으려고 노력했다.

이 시스템을 만들면서 배운 점은 리플렉션은 단순히 타입을 보여주는 기능이 아니다라는 것이다. 서로 다른 시스템이 같은 객체를 같은 방식으로 이해하게 만드는 공통 언어에 가깝다. Inspector가 멤버를 그리고, 직렬화가 필드를 저장하고, GC가 참조를 추적하는 그 모든 과정이, 하나의 메타데이터 위에서 돌아가게 됐을 때 비로소 이 시스템이 제 역할을 하고 있다는 걸 느꼈다.


배운점

  • 리플렉션을 구현하면서 템플릿 메타프로그래밍 실력이 많이 늘었다
  • 헤더에 정의 되는 함수는 모듈간 경계를 신경 써야 한다
  • 코드에 예외를 추가하는 방식보단 하나로 추상화 하기
  • C++ 매크로의 한계
  • 언리얼 엔진의 프로퍼티와 UHT에 대해
profile
개발하며 배웠던 것들 기록용 블로그

0개의 댓글