본문으로 바로가기

[Unreal Engine 4] 메모리 관리

category UnrealEngine/기능 2021. 7. 22. 03:33

메모리 관리는 안정성이 높고 버그가 없는 프로그램을 작성하는 과정에서 늘 중요한 주제다.

dangling pointer는 이미 메모리에서 지워진 대상을 참조하는 포인터이며, 추적하기 어려운 버그를 만드는 대표적인 사례다.

UE4의 UObject 참조 카운팅 시스템은 UObject 클래스로부터 파생된 액터와 클래스의 메모리를 관리하는 기본적인 수단으로, 이를 통해 UE4 프로그램 내에서 메모리가 관리된다.

만약 UObject에서 파생하지 않은 C++ 클래스를 작성한다면 TSharedPtr/TWeakPtr 를 사용하면 된다.

이번 글에선 메모리 관리와 코드 디버깅 방법을 설명한다.

 

메모리 관리 기능의 도움을 받으면 메모리 해제를 잊는 실수를 걱정하지 않아도 된다.

메모리 관리를 하는 프로그램에서는 동적으로 <indexentry content="managed memory:allocating, ConstructObject used">에 의해 객체를 참조하는 포인터의 수를 기억한다.

참조하는 포인터가 없을 때는 자동으로 즉시 지워지거나, 표시한 후의 다음 차례의 가비지 컬렉션때 지워진다.

 

UE4에서 메모리 관리는 자동으로 이루어진다. 엔진 내에서 사용하는 모든 객체의 메모리 할당은 NewObject<> 또는 SpawnActor<> 함수를 사용해야 한다.

 

Actor 클래스의 파생이 아닌 UObject 파생 객체를 생성할 때는 항상 NewObject<>를 사용해야 한다.

Actor 클래스의 파생이라면 SpawnActor<>를 사용하면 된다.

 

메모리 할당 ( 객체 생성 )

예를 들어 UObject에서 파생된 UAction 타입의 객체를 생성하려는 상황을 생각해보자.

UCLASS(BlueprintType, Blueprintable,
    meta=(ShortTooltip="Base class for any Action Type"))

class TUTORIAL_API UAction : public UObject{
    GENERATED_BODY()
    
    public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Properties) FString Text;
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Properties) FKey ShortcutKey;
};

다음과 같이 UAction 클래스의 인스턴스를 생성하면 된다.

UAction* action{ NewObject<UAction>(GetTransientPackage(), 
                                    UAction::StaticClass()),
			            /* ... */};

여기서 GetTransientPackage는 실제로는 'Outer' 인데, Outer는 생성된 인스턴스를 소유하게 될 사람을 설정하는 것

GetTransientPackage는 '임시 저장소' 라고 보면 되는데, UE4의 최상위 패키지라고 보면 된다. 단순한 데이터 집합체다

(즉 Package -> World -> Level -> UObject 순이다. Package가 World를 갖고, World가 Level을 갖고...)

Package가 생성된 UAction 객체의 소유주가 된다는 것이다.

GetTransientPackage() 는 UE4 Docs 에서
'절대 저장해서는 안되는 객체를 임시로 저장하는 데 유용한 최상위 패키지를 반환합니다' 라고 적혀있다.

세번째의 '...' 인자(선택적)부터는 메모리 관리 시스템이 어떤 방식으로 UObject를 다룰지 지정하는 파라미터의 조합이다

 

NewObject와 매우 비슷한 함수로 'ConstructObject<>'가 있는데, 이 함수는 생성 시점에 좀 더 많은 파라미터를 제공한다. 만일 특정 속성을 초기화하고 싶다면 유용하게 사용할 수 있지만, 일반적으로는 NewObject면 충분하다.

 

메모리 해제 ( 객체 파괴 )

UObject 인스턴스는 참조 카운트를 지원해 모든 참조가 사라지면 가비지 컬렉션 대상이 된다. ConstructObject 또는 NewObject를 사용한 UObject 클래스 파생 인스턴스도 참조 카운트가 0으로 떨어지기 전에 UObject::ConditionalBeginDestroy 함수를 호출해 수동으로 메모리에서 해제할 수도 있다.

UObject* object { NewObject<UObject>( ... ) };
object->ConditionalBeginDestroy();

이 개념은 모든 UObject 파생 클래스에 적용할 수 있다. 따라서 예를 들어 이전에 만든 UAction 객체를 해제하고 싶다면 다음 부분을 추가하면 된다.

action->ConditionalBeginDestroy();

ConditionalBeginDestroy 함수는 메모리 해제 절차를 시작하며, override 가능한 BeginDestroyFinishDestroy 함수를 호출한다.

다른 객체가 참조하고 있다면, ConditionalBeginDestroy를 호출해선 안된다.
참조 카운트를 확인하지 않고 무조건적으로 객체를 파괴하기 때문에 dangling pointer가 생길 수 있다.

 

메모리 추적을 위한 스마트 포인터 사용

C++의 표준에는 unique_ptr, shared_ptr, weak_ptr이 있는 것처럼, UE4에도 스마트 포인터가 존재한다.

TSharedPtr은 모든 객체를 참조 카운트 방식으로 만든다.

UObject 파생 객체들은 이미 참조 카운트 방식이다.

즉, UObject와 그 파생 클래스들은 TSharedPtr을 사용할 수 없다.

 

대체 클래스인 TWeakPtr도 참조 카운트 객체를 지원하는데 삭제를 방지할 수 없다는 특이한 속성을 갖고 있다.

 

raw pointer를 사용하지 않으면서, C++ 코드에서 수동으로 UObject 파생이 아닌 객체를 추적하고 삭제한느 상황이라면 TSharedPtr, TSharedRef 같은 스마트 포인터가 좋은 후보다.

new 키워드를 사용해 동적으로 할당되는 객체를 사용한다면, 이를 참조 카운트를 지원하는 포인터로 감싸서 자동으로 메모리 해제가 일어나도록 할 수 있다.

  • TSharedPtr : 스레드로부터 안전한 (ESPMode::ThreadSafe를 인자로 넘긴다면) 참조 카운트 포인터 타입으로, 공유 객체를 나타낸다. 참조 카운트가 없을 때 할당 해제 된다.
  • TAutoPtr : 스레드로부터 안전하지 않은 공유 포인터
  • TSharedRef : null이 불가능한 TSharedPtr이다. TSharedPtr로 변환이 가능하다.
  • TUniquePtr : 고유한 소유권을 가지는 포인터이다. 포인터를 명시적으로 소유하며 공유할 수 없다.
  • TWeakPtr : 참조하는 객체를 소유하지 않기 때문에, 생명주기에 영향을 주지 않는다. ( 즉, 파괴할 수 없다, 참조 카운트에 영향을 주지 않는다. ) 사전 경고 없이 null이 될 수 있으며 객체에 대한 일시적인 안전한 접근을 제공한다.
    SharedPtr의 순환참조 문제 또한 해결 가능.
  • TScopedPointer : 스코프(블록)의 끝에서 자동 삭제되는 포인터

raw pointer 또는 다른 스마트 포인터의 사본을 사용하는 예제이다.

// UObject에서 파생되지 않은 C++ 클래스
class MyClass {};
TSharedPtr<MyClass> sharedPtr { new MyClass() };

TSharedPtr<MyClass> ptr { sharedPtr };

 

weakPtr과 sharedPtr은 약간의 차이가 있다. weakPtr은 참조 카운트가 0으로 내려갈 때 오브젝트를 지우지 않고 유지하는 기능이 없다.

raw pointer를 weakPtr 로 사용할 때, raw pointer가 ConditionalBeginDestroy로 삭제될 때, weakPtr은 null이 된다.

이때 코드를 사용해 여전히 인스턴스가 살아 있는지 확인할 수 있다.

if ( ptr.IsValid() ) {

}

 

FStructure는 TSharedPtr, TSharedRef, TWeakPtr 클래스를 사용해서 raw pointer를 감쌀 수 있다.

만약 UObject를 가리키고 싶다면 TWeakObjectPointer 또는 UPROPERTY()를 사용해야 한다.

UObject obj;
TWeakObjectPtr<UObject> weakObjPtr { obj };


// .h
UPROPERTY()
TArray<FSoundEffect> greets; // 이제부터 참조 카운트가 제대로 계산된다.
UObject* obj; // 이제부터 참조 카운트가 제대로 계산된다.

UPROPERTY() 선언은 UE4에게 제대로 된 메모리 관리를 받아야 한다는 것을 알려준다.

UPROPERTY() 선언이 없으면 TArray가 제대로 동작하지 않는다.

 

TScopedPointer를 선언할 때는 다음과 같은 문법을 따른다.

{
    TScopedPointer<AActor> warrior(this);
} // 이곳에서 삭제됨

 

가비지 컬렉션 강제 수행

메모리가 가득 차거나 일부를 비우고 싶을 때 강제로 가비지 컬렉션을 수행할 수 있다.

보통 강제로 가비지 컬렉션을 수행할 일은 별로 없지만, 더 이상 사용하지 않는 매우 큰 텍스처가 있을 때는 수행을 검토해볼 수 있다.

GetWorld()->ForceGarbageCollection(true);