리플렉션
위키피디아에서는 다음과 같이 설명하고 있다.
Reflection is the ability of a process to examine, introspect, and modify its own structure and behavior.
런타임에 객체의 타입을 보는 Type Introspection을 포함해 구조와 행동까지 수정하는 것이 Reflection이다. 예를 들어 타입을 알 수 없는 객체가 있고 이 객체에 'doSomething'이라는 메소드가 있을 경우 호출하고 싶다고 하면, 리플렉션을 사용해서 다음과 같이 코드를 작성할 수 있다.
// JAVA
Method method = foo.getClass().getMethod("doSomething", null);
method.invoke(foo, null);
C++는 기본적으로 리플렉션을 지원하지 않지만 Unreal에는 C++ 클래스, 구조체, 함수, 멤버 변수 및 열거형에 대한 정보를 수집, 쿼리 및 조작하는 자체 시스템이 존재한다.
게임 엔진에 런타임 리플렉션이 필요한 이유
동적으로 링크되는 다른 모듈들의 사용자 정의 클래스 객체들도 엔진 내의 시스템이 관리해야 하기 때문이다. 컴파일 타임에는 해당 클래스들을 모르기 때문에 동적인 타입 정보가 필요하다. 따라서 리플렉션이 필요한 것이다.
물론 사용자 정의 타입 객체를 만들 때, 엔진의 어떤 기능이 필요하시면 "가상함수나 인터페이스를 구현해주세요"하는 식으로 접근할 수 있다. 하지만 런타임 리플렉션이 있으면 사용자가 그런것들을 구현하지 않아도 엔진이 어느정도 자동으로 기능을 제공해주므로 편리하다.
언리얼 엔진의 리플렉션
언리얼에서는 리플렉션 시스템을 프로퍼티라고 부른다. 왜냐하면 리플렉션은 그래픽 용어이기도 하기 때문이다. 프로퍼티 시스템에 등록했으면 하는 타입에 매크로를 달아두면 Unreal Header Tool(UHT)가 프로젝트를 컴파일할 때 정보를 수집한다. 예를 들면 다음 코드와 같다.
// 리플렉션이 있는 타입은 이 헤더파일을 인클루드해서 UHT에 알린다.
#include "MyObject.generated.h"
// MyObject는 리플렉션 되는 클래스임을 나타낸다.
UCLASS()
class UMyObject : public UObject
{
// 자동 생성된 코드가 여기에 삽입된다는 마커.
GENERATED_BODY()
// MyFunc는 리플렉션 되는 멤버 함수임을 나타낸다.
UFUNCTION()
void MyFunc(int, double) {}
// MyData는 리플렉션 되는 멤버 변수임을 나타낸다.
UPROPERTY()
int32 MyData;
// 리플렉션이 되지 않는 것을 섞어도 된다.
// 하지만 리플렉션 시스템에 보이지 않으므로 주의해야한다.
uint8 MyNum;
// 예를 들면 리플렉션 되지 않는 UObject 포인터를 저장하는 것은
// 가비지 컬렉터가 레퍼런스를 확인할 수 없기 때문에 위험하다.
UObject* MyObjectRef;
}
// MyEnum은 리플렉션 되는 열거형임을 나타낸다.
UENUM()
enum class EMyEnum
{
....
};
// MyStruct는 리플렉션 되는 구조체임을 나타낸다.
USTRUCT()
struct FMyStruct
{
GENERATED_BODY()
UPROPERTY()
int32 MyData;
// 구조체의 멤버함수는 UFUNCTION으로 지정할 수 없다!
// UFUNCTION()
void MyFunc(double) {}
}
이 외에도 각각의 리플렉션 매크로는 여러 지정자들을 조합해서 특성이나 기능을 추가할 수 있다.예를 들면 EditAnywhere
, BlueprintCallable
등이 있다. 각각의 키워드가 하는 일은 공식 홈페이지의 레퍼런스를 살펴보기 바란다. 또한 언리얼에서는 클래스와 구조체를 명확히 구분해서 사용하고 있다.
클래스 (UCLASS) | 구조체(USTRUCT) |
주로 포인터로 다룸 | 주로 값으로 다룸 |
가비지 컬렉션 대상 | 가비지 컬렉션 대상이 아님 |
UObject의 자손이어야함 | 부모 타입이 없어도 됨 |
타입 명이 U나 A로 시작해야함 | 타입 명이 F로 시작해야함 |
언리얼 엔진 내의 여러가지 시스템들이 이 리플렉션 객체에 의존한다.
- 네트워크 리플리케이션
- 블루프린트와 C++ 연동
- 에디터의 디테일 패널
- 자동 시리얼라이제이션
- 가비지 컬렉션
이 시스템들을 생각해보면 런타임에 타입 유추가 필요하거나 타입에 따라 다른 기능을 해야하기 때문에 리플렉션이 왜 필요한지는 유추해 낼 수 있을 것이다.
가비지 컬렉션
가비지 컬렉션은 더 이상 필요하지 않은 객체를 가비지 컬렉터가 메모리에서 할당 해제해주는 방법이다. 예를 들어 다음과 같은 상황을 보자.
- 더 이상 사용되지 않는 객체인데 delete를 하지 않음(메모리 누수)
- 아직 사용중인 객체인데 delete해버림(허상 포인터, Dangling pointer)
- 할당되지 않은 영역에 대해 delete해버림(미정의 동작, undefined behaviour)
프로그램의 규모가 방대해질수록 이것들을 일일이 신경쓰기가 어렵기 때문에 등장한 개념이라고 볼 수 있다. 또 게임에서 메모리 할당과 해제가 일어났을 때 그것이 게임플레이어를 방해해선 안되기 때문에도 가비지 컬렉션을 사용한다.
예를 들어 RPG 게임에서 다른 플레이어가 접속을 종료해 해당 메모리를 해제하는 상황이라고 가정해보자. 이 플레이어와 관련된 아이템, 인벤토리 등등이 모두 해제되고 파괴될 것이다. 만약 이 작업들을 컴퓨터(또는 게임)가 바쁠 때 수행한다면 게임이 잠시 멈출 수도 있을 것이다. 따라서 원활한 게임 진행을 위해서 이러한 해제 작업을 가비지 컬렉터에게 알리고, 가비지 컬렉터는 비교적 여유로울 때 메모리 해제 작업을 차근차근 처리하는 것이다.
정리하자면, 프로그래머가 메모리 관리에 대해 수동적으로 처리해줘야하는 부담을 덜어준다고 보면 될 것이다.
- 장점
- 메모리 누수 또는 유효하지 않은 포인터 접근, 할당되지 않은 메모리 접근을 방지
- 많은 메모리를 한꺼번에 해제할 때의 프리징 현상 방지(하지만 메모리 할당 및 해제는 상당히 높은 cost의 작업인건 똑같기 때문에 부하는 비슷할 수도 있음)
- 단점
- 해제 시점을 예측하기가 힘듬
- 언제 어떤 메모리를 해제할 지에 대한 결정을 해야하므로 오버헤드 발생
참조 카운터 방식의 가비지 컬렉션
일반적인 C++ 프로그램에서 널리 쓰이는 방식이며, RAII 스타일의 포인터 객체인 std::shared_ptr
과 언리얼 TSharedPtr
을 사용한다. 대상 객체로의 참조 카운터를 갱신해주면서, 참조 카운터가 0이 되면 delete를 대신 호출해 해제한다. 개별 객체에 대해서 필요 없어졌다는 사실을 즉각적으로 알 수 있으나 대신에 포인터 객체를 사용하기 때문에 raw 포인터보다는 약간의 성능 비용이 추가된다는 단점이 있다.
만약에 객체 간 순환 참조가 있다면 어떻게 될까? 참조 카운터가 영영 0이 되지 않으므로 해제 되지 않는다. 이를 해결하기 위해서 기존의 참조를 강한 참조(strong reference)라고 하고 약한 참조(weak reference)라는 개념을 만들었다. 위의 그림으로 봤을 때 객체 A가 객체 B를 소유하면, 즉 소유권 방향의 참조는 강한 참조인 shared_ptr
를 사용하고 그외에는 약한 참조인 weak_ptr
을 사용한다.
weak_ptr
은 참조 카운터에 영향을 주지 않으며, 해당 메모리를 참조하는 마지막 shared_ptr
이 제거되어 카운터가 0이 되면 아무것도 가리키지 않게 된다.
C++의 이러한 포인터에 대한 더욱 자세한 내용은 https://modoocode.com/252에 아주 잘 나와있다!
언리얼 엔진의 가비지 컬렉션
언리얼 엔진에서는 참조(Reference) 그래프를 만들어 어느 오브젝트가 사용중이고 어느 것이 사용중이지 않은지 확인한다. 루트에는 Root set가 있으며 UObject가 아래에 추가된다. 가비지 컬렉션이 발생하면 엔진은 Root set부터 시작해서 트리를 검색한다. 여기서 찾지 못한 오브젝트는 참조되지 않는 오브젝트이므로 해제하고 제거한다. 아래의 그림들을 통해 과정을 살펴보자.
준비단계에서는 모든 객체의 도달 가능 마킹(flag)을 해제한다.
Mark 단계에서는 Root set으로부터 도달 가능한 객체를 마킹한다.
Sweep 단계에서는 도달 불가능한, 즉 마킹되지 않은 객체를 해제한다. 이러한 방식을 Mark-Sweep 방식의 가비지 컬렉션이라고 부른다. 그림처럼 언리얼 엔진에서는 모든 UObject가 가비지 컬렉션의 일부가 된다.
그런데 Root set에서는 어떻게 아래 UObject로 링크를 따라 내려가는 걸까?
그것은 바로 UPROPERTY가 객체 내의 UObject 포인터 위치를 가비지 컬렉터에게 알려주기 때문이다.
이처럼 언리얼 엔진은
- Mark-Sweep GC로 관리되는 UObject류 객체
- TSharedPtr, TWeakPtr등을 사용하는 일반 C++객체
로 분류되어 있다. 따라서 일반 C++ 객체가 UObject를 참조하는 것과 같이 혼용해서 사용할 때는 주의해야 한다!
출처