언리얼 엔진은 C++ 기반으로 코드를 작성한다. C++은 객체지향 패러다임(다형성, 상속 등)을 가지면서 동시에 포인터를 이용해 메모리를 직접 관리할 수 있다는 특징이 있는 언어다. 언리얼 엔진의 C++에서는 포인터를 어떤 식으로 이용할 수 있을까?
우리가 흔히 알고 있는 포인터 객체의 형태는 자료형* 객체명
으로 구성된다. 언리얼 엔진의 UObject 객체는 보통 월드에서 활동하고 있는 액터를 참조할 때나, Contents 폴더에서 리소스들을 참조할 때, 액터끼리 서로 상호작용할 때 등등 다양한 용도로 포인터 객체가 쓰인다.
UObject는 언리얼 엔진에서 자동으로 가비지 컬렉션 (Garbage Collection)을 통해 메모리 정리 과정을 거친다. 그렇다면 원래 포인터를 사용하던대로 해도 되는 것인가?
본래 C++의 포인터는 언리얼의 (스마트)포인터 클래스들과 구분을 두기 위해 raw pointer, 한글로는 원시 포인터라는 표현을 많이 쓴다. 이는 우리가 평소에 알던 포인터 사용 시 위험을 모두 지니고 있다. 프로그래머가 직접 new, delete를 통해 메모리 할당, 메모리 해지를 시켜줘야 한다.
이 작업을 제대로 수행하지 못하면, 다음과 같은 문제가 발생한다.
구조가 복잡해질수록 실수할 확률이 올라가고, 여러 객체가 유기적으로 동작하는 게임의 특성상 더 오류가 발생하기 쉽다.
UPROPERTY()
는 변수 앞에 쓸 수 있는 언리얼 C++ 매크로이다. 이 매크로는 '언리얼의 리플렉션 시스템'에서 살펴보았듯, 리플렉션 시스템에 추가하도록 지정하는 역할을 한다. 리플렉션 시스템에 등록되면, 가비지 컬렉션을 통해 더이상 사용하지 않는 포인터 객체 등을 정리해준다. 원시 포인터 형태의 객체여도, UPROPERTY()
로 지정된 객체는 가비지 컬렉션에 의해 정리가 된다.
하지만, 정리가 된 이후에는 따로 nullptr로 초기화되지 않아, 댕글링 포인터가 될 가능성이 매우 높다. 따라서 유효하지 않은 주소를 호출할 가능성이 있는 것이다. 가비지 컬렉션으로 정리는 해주었지만, 뒷처리가 깔끔하지 않은 이 상황을 위해 TObjectPtr
이라는 클래스가 등장한다.
TObjectPtr
, 정확히는 TObjectPtr<T>
는 UObject 객체의 원시 포인터를 담는 포인터 클래스이다. 언리얼5에서는 헤더파일에서 생 포인터 대신 TObjectPtr를 통해 포인터 객체를 담는 것을 추천하고 있다.
// UCameraComponent 포인터를 담는 CameraComp
UPROPERTY(VisibleAnywhere)
TObjectPtr<UCameraComponent> CameraComp;
위처럼 헤더파일에서 선언을 할 수 있다. TObjectPtr은 포인터 객체를 담고 있지만, 별도의 Get 함수 없이 원시 포인터처럼 ->나 * 연산자를 통해 객체 조작을 할 수 있다.
// -> 연산자를 통해 바로 객체 멤버에 접근 가능
CameraComp->bUsePawnControlRotation = false;
또한 UPROPERTY로 지정을 해준 CameraComp 변수는 가비지 컬렉션에 의해 관리가 된다. 이는 원시 포인터때와 다르게 가비지 컬렉션으로 정리가 되면, 자동으로 nullptr를 가리키게 바뀌어 댕글링 포인터 문제를 일으키지 않는다.
그렇다면 원시 포인터는 위험하기만 하고 쓸모가 없는 것일까? 그것은 아니다. 보통 TObjectPtr<T>는 헤더 파일의 멤버로만 쓰이고, .cpp 파일의 함수나 짧은 범위에서는 원시 포인터를 계속 사용한다. 이는 TObjectPtr이 원시 포인터에 약간의 처리를 가한 클래스이기 때문에, 약간의 오버헤드가 발생하기 때문이다.
함수의 인자를 통해 잠깐 전달되거나 간단한 처리에서는 굳이 TObjectPtr을 통해 포인터를 덮어줄 필요가 없으므로 원시 포인터를 사용하는 것이 일반적이다. 예를 들어 플레이어가 사망했을 때 해당 애니메이션을 재생하는 예제를 보자.
void AMyCharacter::PlayDeadAnimation()
{
UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
AnimInstance->StopAllMontages(0.0f);
AnimInstance->Montage_Play(DeadMontage, 1.0f);
}
이런 경우엔 잠깐 UAnimInstance 객체를 불러와 멤버 함수 몇가지만 호출하면 되기 때문에, 굳이 TObjectPtr로 포인터를 덮어주지 않는다. 여기서 메모리가 누수될 위험도 없고, 객체가 갑자기 delete될 경우도 현저히 없기 때문이다. (그 경우엔 이미 플레이가 망가져있을 것이다.)
원시 포인터와 UObject 포인터를 조금 더 안전하게 사용하게 해주는 TObjectPtr을 알아봤다. 그렇다면 UObject 객체가 아닌 객체에 대한 포인터 표현은 어떻게 할까? 물론 원시 포인터로 할 수 있을 것이다. 하지만 C++를 조금 공부한 이들이라면, Modern C++에서는 원시 포인터 말고 스마트 포인터 사용도 꽤나 잦다는 것을 알고 있을 것이다.
언리얼 엔진에서는 C++11 스마트 포인터들에 대응하는 언리얼 엔진만의 스마트 포인터 클래스들로 활용할 수 있다. (UObject 객체엔 사용 불가)
C++에서는 unique_ptr, shared_ptr, weak_ptr라는 클래스들이, 언리얼 엔진에서는 각각 TUniquePtr, TSharedPtr, TWeakPtr라는 클래스로 존재한다. 하나씩 살펴보자.
C++의 unique_ptr에 대응하는 스마트 포인터 클래스이다. 가장 큰 특징이라면 역시나 고유한 소유권을 가진다는 것에 있다. 또한 소유권은 복사하는 것이 불가능하고, 무조건 '이전'만 가능하다.
TUniquePtr<FMyBox> Box; // 선언
Box = MakeUnique<FMyBox>(10, 20); // 초기화
// 소유권 이전
TUniquePtr<FMyBox> Box2 = MoveTemp(Box);
if (!Box)
{
UE_LOG(LogTemp, Log, TEXT("Box 소유권 이전됨"));
}
C++에서 unique_ptr은 std::move
함수를 통해 소유권 이전을 진행한 것처럼, 언리얼 엔진에서는 MoveTemp
함수를 통해 소유권 이전을 진행한다. 위의 코드에서 Box 객체는 더이상 처음 초기화했던 객체의 소유권을 가지고 있지 않다. 이는 Box2로 넘어갔고, Box는 nullptr가 저장된다.
또한, TUniquePtr은 지정된 지역(스코프)를 넘어가면, 참조되는 오브젝트가 자동으로 소멸된다. 이런 특성을 통해 편한 메모리 관리가 가능하다.
TSharedPtr 클래스는 C++의 shared_ptr 클래스에 대응된다. 참조 카운팅 방식을 통해 포인터를 관리하고, 참조 카운트가 0이 되면 객체가 소멸되는 방식이다. 카운팅 방식을 사용하다보니, 소유권에 대한 유일성은 존재하지 않고 복사가 가능하다.
// FMyBox 타입 TSharedPtr 객체 생성
TSharedPtr<FMyBox> Box1(new FMyBox(10, 20));
// 포인터 객체 복사 (참조 카운팅 +1)
TSharedPtr<FMyBox> Box2 = Box1;
// Box1의 참조 포인터를 nullptr로 설정. 아직 참조 카운트가 1이 아니기 때문에 객체는 생존.
Box1 = nullptr;
TSharedPtr은 관리가 편하고 TUniquePtr처럼 소유권에 대한 문제를 크게 생각할 필요가 없어서 많이 사용된다. 하지만 TSharedPtr에도 '순환 참조'라는 문제가 존재한다. 이를 간단하게 알아보기 위해 예제를 살펴보자.
class FPerson
{
public:
TSharedPtr<FPerson> Friend; //추가
int32 Age = 10;
};
간단히 FPerson이라는 클래스가 있다고 해보자. 이는 FPerson 형태의 TSharedPtr 객체인 Friend를 멤버 변수를 가지고 있다.
FPerson* Person1 = new FPerson();
Person1->Age = 32;
FPerson* Person2 = new FPerson();
Person2->Age = 24;
{
TSharedPtr<FPerson> SharedPerson1 = MakeShareable(Person1);
TSharedPtr<FPerson> SharedPerson1 = MakeShareable(Person2);
Person1->Friend = Person2;
Person2->Friend = Person1;
// 본래는 지역의 끝이라 TSharedPtr로 선언된 Person1과 Person2는 메모리 삭제되어야 함.
}
// 서로의 Friend에 참조가 남아있는 상태. 참조 순환 문제 발생.
AB_LOG(Warning, TEXT("%d"), Person1->Age); // 32
AB_LOG(Warning, TEXT("%d"), Person2->Age); // 24
// TSharedPtr 리셋으로 참조 순환 구조가 삭제됨.
Person1->Friend.Reset();
AB_LOG(Warning, TEXT("%d"), Person1->Age); // 쓰레기값
AB_LOG(Warning, TEXT("%d"), Person2->Age); // 쓰레기값
FPerson을 통해 순환 구조를 보인 모습이다. 본래는 중괄호 구역이 끝나면, 참조하는 구역이 없기에 삭제가 될 것이라고 예상한다. 하지만 서로의 Friend 안에 참조가 되어있기 때문에, 아직 참조 카운팅이 0이 되지 않아 삭제가 되지 않았다. 이 예제 같은 경우엔 Person1, Person2로 주소를 잡고 있었기 때문에 Friend 멤버를 Reset 할 수 있었지만, 그렇지 않은 경우엔 메모리 누수가 발생한다.
언뜻 보면 TSharedPtr과 매우 비슷하다. 이 클래스도 참조 카운팅 방식을 사용한다. 하지만 TSharedPtr과 다르게 nullptr로 선언이 불가능하고, 항상 유효한 객체를 담고 있어야 한다. 즉,
// 유효한 객체 담지 않아 컴파일 에러
TSharedRef<FMyObjectType> UnassignedReference;
// nullptr로 선언해 컴파일 에러
TSharedRef<FMyObjectType> NullAssignedReference = nullptr;
// 컴파일 상에서는 넘어가지만 nullptr을 담고 있다면 런타임 에러
TSharedRef<FMyObjectType> NullAssignedReference = NullObject;
이런 경우 모두 에러가 날 수 있다는 것이다. 결국 TSharedPtr와 비교했을 때 더 존재 유무가 확실해야 할 때, 확실한 참조가 보장되어야하는 상황에서 좋은 표현이 될 수 있다. 반대로 TSharedPtr보다는 유연성이 떨어지기 때문에, nullptr인지 확인하고 무언가 상황을 처리해야하는 경우엔 TSharedPtr이 좋은 선택이다.
위의 참조 순환같은 상황을 방지하기 위해, 약한 참조를 지원하는 TWeakPtr 클래스가 있다. C++에서의 weak_ptr 클래스에 대응된다. 이는 TSharedPtr과 비슷하지만, 참조 카운팅 수를 올리지 않는다는 특징이 있다. 즉, TWeakPtr로 아무리 참조해봐야, 카운팅 숫자는 변함이 없다는 것이다. 따라서 본래 객체의 카운팅 숫자가 갑자기 0이 되어버리면, 소멸되어 자동으로 nullptr를 담게 된다.
결국 TWeakPtr에서 중요한 것은, 참조하는 객체가 소멸되었는지 아닌지 판단해주는 것이다.
// Box 객체와 그것을 약한 참조하는 BoxObserver 객체
TSharedRef<FMyBox> Box = MakeShared<FMyBox>(10, 20);
TWeakPtr<FMyBox> BoxObserver(Box);
// Box 객체 소멸
Box.Reset();
// 객체가 아직 살아있는지 확인 (Pin)
if (BoxObserver.Pin())
{
// false기 때문에 실행 X
}
위와 같이 .Pin()
함수를 통해 객체의 생존 여부를 파악할 수 있다.
So useful