언리얼 메모리 관리 시스템
스마트 포인터와 가비지 컬렉터는 메모리 관리를 위한 개념들로 프로그래밍 영역에서 굉장히 일반적으로 통용되는 개념이다. 언리얼은 스마트 포인터와 가비지 컬렉터(GC)를 모두 사용하여 메모리를 관리하는데, 두 개념 모두 c++로 자체 제작한 코드를 이용한다. 순수 c++을 이용하는 상황에서는 스마트 포인터들을 사용하고, UObject와 관련된 부분에서는 GC를 사용한다.
Smart Pointer
1. TUniquePtr
메모리의 유일한 소유권을 넘겨주고, 해제를 자동화한 개념. (c++ stl의 unique_ptr과 대응된다.)
일반적인 포인터와 달리 객체가 파괴될 때 소유한 메모리도 해제한다.
아래 예제를 보자.
해당 코드대로 실행 시 메모리는 해제되지 않고 남아있게 된다. 이러한 문제를 메모리 누수 부른다. 메모리가 누수되지 않도록 하기 위해선 따로 delete을 호출해 주어야 한다.
void Temp()
{
FData* NewData = new FData();
// delete NewData;
}
하지만 프로그램을 개발하다보면 해제 자체를 잊어버리게 되는 경우도 있고, 예상치 못한 예외상황으로 인해 delete이 호출되지 않게 되는 경우도 생긴다. 이처럼 일반적으로 자주 발생하는 실수가 반복되지 않도록 하기 위해 좀 더 진보된 방법으로 메모리를 관리할 필요가 있다.
아래의 경우 TUniquePtr 객체가 파괴되며 자동으로 메모리도 해제된다. 예외상황이 발생하더라도 객체가 블럭을 나갈때 파괴되면서 메모리를 해제시켜주므로 메모리 걱정을 할 필요가 없다. 또한 TUniquePtr은 c++의 연산자 오버로딩을 이용하여 포인터로서 필요한 대부분의 기능을 갖추고 있으므로 일반적인 포인터처럼 사용할 수 있다.
void Temp()
{
TUniquePtr<FData> NewData = TUniquePtr<FData>(new FData());
//NewData->SomeData;
}
하지만, TUniquePtr은 하나의 포인터로 단 하나만의 메모리를 다루기 위한 개념이다. 이는 여러 TUniquePtr가 같은 메모리를 참조할 수 없음을 의미한다. 그러므로 설계 수준에서 이 포인터에 유일성의 대한 특성이 고려되어야 후에 문제가 발생하지 않을 것이다.
이 유일참조의 특성에서 생길 수 있는 고민이 하나 더 있는데, 여러 TUniquePtr가 하나의 메모리를 참조하는게 원천적으로 막혀있기 때문에, 객체의 소유권을 넘겨주는 방법도 일반적이지는 않다는 것이다.
TUniquePtr의 참조 이동은 아래와 같이 명시적으로 해야 한다. (stl의 std::move와 대응된다.)
TUniquePtr<FData> NewData = TUniquePtr<FData>(new FData());
TUniquePtr<FData> OtherData = MoveTemp(NewData);
2. TSharedPtr
메모리의 공유까지 포함하여 메모리 해제의 자동화를 다루는 개념이다. (c++ stl의 shared_ptr과 대응된다.)
TUniquePtr과 마찬가지로 메모리 해제를 다루며, TUniquePtr에서는 불가능했던, 여러 포인터가 같은 메모리를 참조하는 상황까지 다룬다. 원리는 다음과 같다.
어떤 포인터가 객체 메모리를 참조할 때마다 레퍼런스 카운트가 1 증가하고 해지할 때마다 1 내려간다.
그러다 레퍼런스 카운트가 0이 될 때 객체 메모리를 해제한다.
(여기서 항상 헷갈려하는 부분은 TSharedPtr 객체 자체에 refCnt라는 변수가 있을거라고 생각하는 것이다. refCount는 TSharedPtr 객체가 아닌 Control Block이라는 다른 메모리에 할당되어 공유된다는 사실을 정확히 알아야 헷갈리지 않을 수 있다.)
void Temp()
{
{
// ControlBlock refCnt == 1
TSharedPtr<FData> Data = MakeShareable(New FData());
{
// refCnt == 2
TSharedPtr<FData> OtherData1 = Data;
// refCnt == 3
TSharedPtr<FData> OtherData2 = Data;
}
// OtherData1 파괴 -> refCnt == 2
// OtherData2 파괴 -> refCnt == 1
}
// Data 파괴 -> refCnt == 0 -> 메모리 해제
}
메모리관리에 동시참조까지 가능하기 때문에, 스마트 포인터들 중에서도 가장 많이 사용한다.
하지만 이 포인터에는 순환참조(Circular Reference)라는 치명적인 약점이 하나 있다.
struct FData
{
TSharedPtr<FData> Other;
};
void Temp()
{
{
// ControlBlock1 refcnt == 1
TSharedPtr<FData> A = MakeShareable(new FData());
// ControlBlock2 refcnt == 1
TSharedPtr<FData> B = MakeShareable(new FData());
// ControlBlock2 refcnt == 2
A->Other = B;
// ControlBlock1 refcnt == 2
B->Other = A;
}
// 이 시점에 두 객체의 메모리가 모두 해제되길 기대하지만,
// A 파괴 -> ControlBlock1 refcnt == 1
// B 파괴 -> ControlBlock2 refcnt == 1
// TSharedPtr만 파괴되고, 할당된 객체 메모리들은 서로 참조하며 죽지 않고 남아있게 됨.
}
관리자는 죽고 관리대상들끼리 서로를 바라보며 살아있게 된다. 그러므로 TSharedPtr을 사용할 때에는, 이 순환참조 문제를 반드시 주의하며 사용해야 한다.
3. TWeakPtr
TSharedPtr에서 순환참조를 주의해야 한다는 것은 알았지만, 그 말이 순환참조를 하지 말라는 의미가 될 수는 없다. 큰 구조의 소프트웨어를 개발하다보면 순환참조는 필연적이기 때문이다. 그렇다면 순환참조를 하면서도 위의 문제를 해결할 수 있는 방법을 알아야한다. 이를 위해 보조 포인터로 나온 개념이 TWeakPtr이다.
TWeakPtr은 다음과 같은 규칙으로 순환참조를 끊는다.
1. TWeakPtr은 TSharedPtr를 통해서만 복사 생성, 대입 연산이 가능하다.
2. TWeakPtr은 TSharedPtr를 참조하되, 레퍼런스 카운트를 증가시키지 않는다.
3. TWeakPtr을 통해 객체를 참조하려면 반드시 TSharedPtr로 변환하여 사용해야 한다.
이 과정은 다음을 코드로 보면 다음과 같다.
void Temp()
{
{
// refcnt = 1
TSharedPtr<FData> SP(new FData());
// SP로부터 복사,
// TWeakPtr이 참조,
// refcnt = 1
TWeakPtr<FData> WP = SP;
{
// TSharedPtr로 변환, 이 방법밖에 없음
// refcnt = 2
TSharedPtr<FData> SP2 = WP.Pin();
// SP2 소멸,
// refcnt = 1
}
// SP 소멸,
// refcnt = 0
}
}
TWeakPtr을 사용하려면 위 예제처럼 사용해야하고,
그 프로세스대로 사용하여 순환참조를 피할 수 있다.
struct FData
{
// 이 객체에서 "할당"이 아닌 "참조"를 해야하는 상황인지 판단하여 참조를 해야한다면,
// 순환참조를 위해 WeakPtr을 이용한다.
TWeakPtr<FData> Other;
};
void Temp()
{
{
// ControlBlock1 refCnt == 1
TSharedPtr<FData> A = MakeShareable(new FData());
// ControlBlock2 refcnt == 1
TSharedPtr<FData> B = MakeShareable(new FData());
// weak reference -> ref count가 증가하지 않음 -> ControlBlock2 refcnt == 1
A->Other = B;
// weak reference -> ControlBlock1 refcnt == 1
B->Other = A;
// ControlBlock1 refcnt == 2
TSharedPtr<FData> UseA = A->Other.Pin();
// UseA로 하고싶은거 함.
//..
// ControlBlock2 refcnt == 2
TSharedPtr<FData> UseB = B->Other.Pin();
// UseB로 하고싶은거 함.
//..
}
// A 파괴 -> ControlBlock1 refcnt == 1
// B 파괴 -> ControlBlock2 refcnt == 1
// UseA 파괴 -> ControlBlock1 refcnt == 0
// 메모리 해제
// UseB 파괴 -> ControlBlock2 refcnt == 0
// 메모리 해제
}
Garbage Collector
c++을 이용한 어플리케이션을 개발하는 경우 모든 메모리는 개발자가 직접 관리해주어야 하고, 보편적으로는 스마트 포인터를 이용한다.
언리얼 엔진에서는 물론 스마트 포인터를 지원하지만, 동시에 가비지 컬렉터도 지원한다.
다만, 언리얼 엔진에서 관리대상으로 지정된 객체들에 대해서만 가비지 컬렉팅이 가능하고, 로우 레벨 c++ 코드를 이용하는 경우는 똑같이 직접 개발자가 메모리를 관리해주어야한다.
관리대상으로 지정된 객체들은 UObject의 모든 하위 클래스들이다.
UObject는 언리얼에서 제공하는 NewObject, SpawnActor, CreateDefaultSubobject와 같은 함수를 통해서만 생성이 가능하다. (c++의 new를 이용해서는 안된다.)
생성 과정에서 GC의 추적 대상이 된다.
GC는 주기적으로 GC 루트 오브젝트들에서부터 하위로 참조 그래프 탐색해나가며, 참조 그래프 상에 포함되어 있지 않은 UObject들을 자동으로 제거한다.
UObject는 다음과 같은 방식으로 참조 그래프에 추가될 수 있다.
1. 강한 참조(UPROPERTY)로 참조되는 경우 (대상 UObject를 참조하는 객체도 참조되고 있어야한다.)
2. UObject::AddReferencedObjects 호출을 통해 등록되는 경우 (호출하는 주체 객체도 참조되고 있어야한다.)
3. UObject::AddToRoot로 루트 집합에 추가되는 경우 (일반적으로 많이 쓰이지는 않는다.)
만약 UObject가 위의 경우를 충족시키지 못한다면, GC 사이클을 통해 도달할수 없는 객체로 마킹될 것이고 가비지 컬렉팅을 통해 파괴될 것이다.
객체를 Outer로 다른 객체에 전달한다고 해서 자동으로 다른 객체의 수명이 유지되는 것은 아니다. (DefaultSubobject의 경우도 마찬가지)
접근 가능한 개체를 강제로 제거하려면 해당 개체에 대해 MarkPendingKill을 호출하여 다음 GC 주기에서 객체가 강제로 제거되도록 할 수 있다.
개체의 소멸이 반드시 동일한 프레임에서 모두 발생하는 것은 아니다. 가비지 컬렉팅이 시작되면 먼저 BeginDestroy를 호출하고 준비가 되면 FinishDestroy를 호출한다.
GC는 게임 스레드에서 실행되므로 함수 호출 중 객체가 제거되지는 않는다.
객체의 수명을 유지하는 가장 일반적인 방법은 UPROPERTY를 사용하는 것이지만 Actor와 Component는 다르게 동작한다. Level은 Actor를 참조하고 Actor는 Component를 참조한다. 둘 다 UObject::AddReferencedObjects를 구현함으로써 가비지 컬렉팅을 피할 수 있다. 즉, Level은 Actor, Component에 대한 강한 참조가 없더라도 수동으로 파괴하거나 해당 레벨이 언로드될 때까지 가비지 컬렉팅되지 않는다.
가비지 컬렉터는 수집된 객체의 참조 변수들에 대해 다음과 같은 작업을 수행한다.
1. UPROPERTY로 등록된 raw pointer를 nullptr로 세팅한다.
2. UPROPERTY로 등록된 컨테이너(TArray, TSet, TMap), 원소들은 nullptr로 세팅하되 컨테이너가 지워지지는 않는다.
코드에서 AActor나 UActorComponent를 참조하는 경우, AActor::Destroy, UActorComponent::DestroyComponent를 통해 Actor와 Component가 파괴되는 상황에 대비해야할 것이다. 이 함수들은 객체들의 pending kill을 마킹함으로써 가비지 컬렉팅을 진행시킨다. 물론 GC작업 후 코드에서 해당 객체들을 참조하는 포인터들은 nullptr로 세팅되기 때문에 null 체킹으로도 충분하겠지만, 이미 파괴 표시가 된 객체들을 참조하기 꺼려지는 경우 IsPendingKill을 이용하면 된다. 만약 두 경우 모두 체크하고 싶다면 IsValid 함수를 이용하면 될 것이다.
다음으로 UObject가 GC로부터 해제될 수 있는 상황들을 예제를 통해 알아보자.
GC로 인한 UObject 제거
//..
UPROPERTY()
UObject *obj1;
UObject *obj2;
//..
void GameUtils::Temp()
{
obj1 = NewObject<UObject>();
obj2 = NewObject<UObject>();
// 강제 GC
GEngine->ForceGarbageCollection(true);
}
이 예제에서 obj1이 가리키는 객체는 UPROPERTY로 누군가 자신을 참조한다는 것을 증명했기 때문에,
GC 이후에도 살아남지만 obj2가 가리키는 객체는 제거된다.
심지어 더 심각한 문제는 다음 예제를 보면 알 수 있다.
Raw pointer의 위험성
void UTestActor::Init()
{
obj1 = NewObject<UObject>();
obj2 = NewObject<UObject>();
}
void UTestActor::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (obj2 != nullptr)
{
UE_LOG(LogTemp, Warning, TEXT("obj is alive!"));
}
}
// 이후 다른 컴포넌트에서 GEngine->ForceGarbageCollection(true); 호출
위 예제는 TestActor에서 raw pointer로 참조되는 obj2를 생성하고 매 틱 obj2가 살아있는지 확인한다.
그리고 다른 컴포넌트에서 GC를 하여 obj2가 제거되기를 기대한다.
하지만, 저 로그는 GC 이후에도 계속 출력된다.
그 이유는 사용된 포인터가 raw pointer이기 때문에, GC로 obj2가 가리키는 객체 메모리가 제거되었음에도 obj2는 이를 알지 못한다. 텅 빈 공간만 가리키고 있으면서, 그곳에 객체가 여전히 살아있다고 믿는 셈이다.
그 메모리 공간이 계속 빈 채로 남아있든, 아니면 다른 객체가 그 메모리를 사용하든 크래시는 피할 수 없다.
이것을 댕글링 포인터(Dangling pointer)라고 부른다.
위 예제가 성공적으로 GC를 테스트하기 위한 예제가 되려면 obj2를 raw pointer가 아닌 TWeakObjectPtr를 사용해야하고, 이것을 이용하더라도 GC가 방지되지는 않겠지만 적어도 제거됐는데 인지도 못하는 상황은 피할 수 있다.
(TWeakObjectPtr는 TWeakPtr과는 다른 객체이다.)
TWeakObjectPtr
TWeakObjectPtr이 왜 필요할까? 기본적인 스토리부터 다시 이야기해보자. UObject를 이용하는 경우 GC가 돌기 때문에 메모리 걱정 없이 포인터를 이용할 수 있다. 같은 이유로 모든 UObject들은 GC로부터 파괴될 수 있기 때문에 UPROPERTY를 이용하여 수명을 유지하도록 해야한다. 하지만 UPROPERTY는 내부적으로는 강한 참조를 이용하기 때문에 과하게 사용하면 가비지 컬렉팅 비용이 커지는 문제가 생긴다. 예를들면 UObjectA와 UObjectB가 정의되어 있다고 가정해보자. 이 두 객체가 멤버 변수로 서로에 대한 UPROPERTY 포인터를 갖고 있다면 어떻게 될까? UPROPERTY는 강하게 서로에 대해 참조하므로 Circular Reference 문제가 생기게 된다. 일반적인 C++ 프로그램에서는 메모리 누수로 이어질 Circular Reference 문제지만 언리얼에서는 자체 구현된 GC가 주기적으로 Collect를 통해 루트로부터 붙어있지 않은 UObject들을 마킹 후 수집하므로 누수 문제로는 이어지지 않는다. 하지만 이처럼 참조 그래프가 비대해지는 문제는 피할 수 없다. 이는 결국 GC의 컬렉팅 비용을 증가시킬 것이다. 이러한 문제를 최적화하기 위해 TWeakObjectPtr을 이용하는것이 좋다.
위 UObjectA, UObjectB의 예제에서 한쪽이 UPROPERTY로 강하게 참조했다면 다른쪽에서는 TWeakObjectPtr로 약하게 참조하는 것이다. 이처럼 참조 순환을 끊어주는 습관은 프로그램이 큰 만큼 GC의 부담을 줄여줄 수 있을 것이다.
당연한 얘기지만 raw pointer를 이용하여 순환참조를 끊는것은 의미가 없다. 순환참조 자체는 끊을 수 있겠지만 더 위험한 댕글링 포인터 문제가 발생하기 때문이다. 반면에 TWeakObjectPtr는 참조하는 대상이 파괴되는 경우 nullptr로 세팅하는 기능이 있기 때문에 raw pointer와 달리 안전하게 이용할 수 있다.
void GameUtils::Temp()
{
AActor* ActorFromSomewhere;
// 할당
TWeakObjectPtr<AActor> WeakActor;
WeakActor = ActorFromSomewhere;
// 값
AActor* Actor = WeakActor.Get();
// 사용
if (WeakActor.Get())
{
ACharacter* Character = Cast<ACharacter>(WeakActor);
}
// 본체가 파괴되면,
ActorFromSomewhere->Destroy();
bool IsValid = WeakActor.Get() != nullptr; //false
// GC가 돌면, Cast<AMyCharacter>(MyActor)는 crash 발생,
// Cast<AMyCharacter>(MyWeakActor)는 잘 동작한다.
// TArray에서 사용
APawn* TestPawn = GetWorld()->SpawnActor<APawn>(MyPawnClass, FVector(100.f, 100.f, 0.f), FRotator::ZeroRotator);
TestArray.Add(TWeakObjectPtr<APawn>(TestPawn));
TWeakObjectPtr<APawn> WeakPtr(TestPawn);
TestArray.Remove(WeakPtr);
}
예제 정리 및 테스트
Raw Pointer, UPROPERTY Pointer, TWeakObjectPtr에 대하여,
Actor를 하나 생성하고 바로 Destroy 했을 때와,
Actor를 하나 생성하고 바로 Destroy 후 GC를 동작시켰을 때의 상황을 테스트해보자.
Raw Pointer
1. GC 생략 버전
// header
class AMyActor* MyActor;
// source
void ABaseCharacter::BeginPlay()
{
Super::BeginPlay();
FActorSpawnParameters Params;
MyActor = GetWorld()->SpawnActor<AMyActor>(Params);
MyActor->Destroy();
//GEngine->ForceGarbageCollection(true);
}
// source
void ABaseCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (MyActor != nullptr)
{
UE_LOG(LogTemp, Error, TEXT("MyActor data : %d"), MyActor->Data);
}
else
{
UE_LOG(LogTemp, Error, TEXT("MyActor is nullptr"));
}
}
로그를 통해 MyActor가 파괴되었지만 MyActor가 nullptr이 아니고, 메모리의 데이터를 참조해온다.
MyActor의 Destroy는 호출을 했지만 아직 GC를 수행하지 않았기 때문에 남아있는 객체를 참조한다고 볼 수 있다.
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
2. GC 적용 버전
BeginPlay에서 다음 코드 주석을 제거한다.
//..
GEngine->ForceGarbageCollection(true);
//..
로그를 통해 GC를 수행해도 여전히 메모리 어딘가를 참조하고 있다는 것을 알 수 있다.
GC를 수행했다는 것은 메모리를 해제했다는 것인데, 그럼에도 불구하고 데이터를 참조해 온다는것은 매우 위험하다.
후에 이 메모리에 어떤 값이 쓰여지게 되면 Crash가 날 것이다.
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
UPROPERTY Pointer
1. GC 생략 버전
BeginPlay는 Raw Pointer 예제와 동일하다.
// header
UPROPERTY()
class AMyActor* MyActor;
// source
void ABaseCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (MyActor != nullptr)
{
UE_LOG(LogTemp, Error, TEXT("MyActor data : %d"), MyActor->Data);
}
else
{
UE_LOG(LogTemp, Error, TEXT("MyActor is nullptr"));
}
}
로그를 통해 MyActor가 파괴되었음에도 데이터를 참조하는 것을 알 수 있다.
GC가 아직 돌지 않았기 때문에, 파괴되었지만 메모리는 남아있는 객체를 참조한다고 볼 수 있다.
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
2. GC 생략 & IsPendingKill
로그를 통해 1번과 결과가 같음을 확인
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
3. GC 생략 & IsValidLowLevel
로그를 통해 1번과 결과가 같음을 확인
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
LogTemp: Error: MyActor data : 15
4. GC 생략 & IsActorBeingDestroyed
// source
void ABaseCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (!MyActor->IsActorBeingDestroyed())
{
UE_LOG(LogTemp, Error, TEXT("MyActor data : %d"), MyActor->Data);
}
else
{
UE_LOG(LogTemp, Error, TEXT("MyActor is destroyed"));
}
}
로그를 통해 MyActor가 파괴됨을 확인, 정확하게 사용하려면 nullptr 체크와 병행해야 할 것이다.
LogTemp: Error: MyActor is destroyed
LogTemp: Error: MyActor is destroyed
LogTemp: Error: MyActor is destroyed
LogTemp: Error: MyActor is destroyed
4. GC 적용 버전
//..
GEngine->ForceGarbageCollection(true);
//..
로그를 통해 GC가 동작한 후에는 포인터가 nullptr로 세팅됨을 확인하였다.
LogTemp: Error: MyActor is nullptr
LogTemp: Error: MyActor is nullptr
LogTemp: Error: MyActor is nullptr
LogTemp: Error: MyActor is nullptr
즉, MyActor가 Destroy되었어도 GC가 동작하기 전까지는, IsActorBeingDestroyed에 대한 체크를 따로 하지 않은 경우에는 if문 내부로 들어갈 수 있을 것이고, 이는 개발자가 의도하지 않은 상황일 가능성이 크다.
TWeakObjectPtr
1. GC 생략 버전
BeginPlay는 Raw Pointer 예제와 동일하다.
// header
TWeakObjectPtr<class AMyActor> MyActor;
// source
void ABaseCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (MyActor.IsValid())
//if (MyActor.Get() != nullptr) // 이 코드로 수행하여도 결과는 같다.
{
UE_LOG(LogTemp, Error, TEXT("MyActor data : %d"), MyActor->Data);
}
else
{
UE_LOG(LogTemp, Error, TEXT("MyActor is nullptr"));
}
}
로그를 통해 MyActor가 파괴된 후 nullptr로 세팅됨을 확인.
// log
LogTemp: Error: MyActor is nullptr
LogTemp: Error: MyActor is nullptr
LogTemp: Error: MyActor is nullptr
LogTemp: Error: MyActor is nullptr
2. GC 적용 버전
// source
//..
GEngine->ForceGarbageCollection(true);
//..
로그를 통해 MyActor가 파괴된 후 nullptr로 세팅됨을 확인.
LogTemp: Error: MyActor is nullptr
LogTemp: Error: MyActor is nullptr
LogTemp: Error: MyActor is nullptr
LogTemp: Error: MyActor is nullptr
다음과 같은 기괴한 예제도 만들 수 있다.
// header
UPROPERTY()
class ATestActor* TestActor;
void ABaseCharacter::BeginPlay()
{
Super::BeginPlay();
FActorSpawnParameters Params;
TestActor = GetWorld()->SpawnActor<ATestActor>(Params);
TestActor->Destroy();
//GEngine->ForceGarbageCollection(true);
if (TestActor != nullptr)
{
UE_LOG(LogTemp, Error, TEXT("UPROPERTY TestActor data : %d"), TestActor->Data);
}
else
{
UE_LOG(LogTemp, Error, TEXT("UPROPERTY TestActor is nullptr"));
}
ATestActor* Ptr = TestActor;
if (Ptr != nullptr)
{
UE_LOG(LogTemp, Error, TEXT("Ptr TestActor data : %d"), Ptr->Data);
}
else
{
UE_LOG(LogTemp, Error, TEXT("Ptr TestActor is nullptr"));
}
TWeakObjectPtr<ATestActor> WeakPtr = Ptr;
if (WeakPtr.Get() != nullptr)
{
UE_LOG(LogTemp, Error, TEXT("WeakPtr TestActor data : %d"), WeakPtr->Data);
}
else
{
UE_LOG(LogTemp, Error, TEXT("WeakPtr TestActor is nullptr"));
}
}
LogTemp: Error: UPROPERTY TestActor data : 0
LogTemp: Error: Ptr TestActor data : 0
LogTemp: Error: WeakPtr TestActor is nullptr
결론
MyActor 생성 후 바로 파괴 후 GC적용 테스트 결과
GC 생략 | GC 적용 | 다른 추가 조건 | |
Raw Pointer | 파괴된 Object를 참조 | 해제된 메모리를 참조 | |
UPROPERTY Pointer | 파괴된 Object를 참조 | nullptr | Object가 파괴됨을 확인 |
TWeakObjectPtr | nullptr | nullptr |
Raw Pointer의 경우 해제된 메모리도 참조하기 때문에 절대로 다른 객체를 참조하기 위한 멤버 변수로 사용해서는 안될 것이다.
UPROPERTY Pointer의 경우 기본적인 용도로 봤을 때, 자신이 소유권을 갖고 있는 객체를 참조하기 위해 사용하는 것은 아무 문제가 없을 것이다.
하지만, 소유권이 없는 다른 객체를 참조하기 위해 사용되는 경우, 조건을 줄줄이 달고 사용해야 파괴된 상태에서 호출되는 상황을 피할 수 있을 것이다.
하지만 여전히, 과도한 레퍼런스 그래프를 만들어 GC가 힘들게 하기 때문에, 피할 수 있는 상황에서는 피하는 것이 좋을 것이다.
TWeakObjectPtr의 경우 소유권이 없는 객체를 참조하기 위한 용도로 아주 적합하다. 레퍼런스 카운트를 증가시키지 않기 때문에, GC 레퍼런스 그래프에도 부담을 주지 않고, Destroy가 되자마자 바로 null 체크가 가능하다.
또한 GetOwner와 같은 함수를 이용할 때, TWeakObjectPtr로 받으면 null 체크가 가능할 것이다.
'게임 엔진 > Unreal' 카테고리의 다른 글
[Unreal] 언리얼 Tick 시스템 (4) | 2021.01.20 |
---|---|
[Unreal] 언리얼 프로젝트 소스코드를 옮기는 방법 (Redirect) (0) | 2021.01.19 |
[Unreal] 언리얼 엔진 디버깅 (0) | 2021.01.12 |
[Unreal] [Example] 인공지능 에이전트 (AISense) (0) | 2021.01.10 |
[Unreal] 테이블 데이터 이용 방법 (DataTable) (0) | 2020.12.30 |