언리얼 엔진의 메모리 관리 방식을 파악하고, 언리얼 오브젝트의 메모리를 관리하는 예제 실습을 한다.

  • 언리얼 엔진의 메모리 관리 시스템의 이해
  • 안정적으로 언리얼 오브젝트 포인터를 관리하는 방법의 학습

 


인프런 이득우님의 '언리얼 프로그래밍 Part1 - 언리얼 C++의 이해' 강의를 참고하였습니다. 

😎 [이득우의 언리얼 프로그래밍 Part1 - 언리얼 C++의 이해] 강의 들으러 가기!

 

 

 

목차

     

     


     

     

     

    언리얼 엔진의 자동 메모리 관리


     

     

    C++ 언어 메모리 관리의 문제점

     

    • C++은 low 레벨 언어로, 메모리 주소에 직접 접근하는 포인터를 사용해서 오브젝트를 관리한다.
    • 그러다보니 프로그래머가 직접 할당(new), 해지(delete) 짝 맞추기를 해야 한다.
    • 이를 잘 지키지 못하는 경우 다양한 문제가 발생할 수 있다.
    • 잘못된 포인터 사용 예시  
      • 메모리 누수(Leak) : new를 했는데 delete 짝을 맞추지 못함. 힙에 메모리가 그대로 남아있음.
      • 허상(Dangling) 포인터 : (다른 곳에서) 이미 해제해 무효화된 오브젝트의 주소를 가리키는 포인터
      • 와일드(Wild) 포인터 : 값이 초기화되지 않아 엉뚱한 주소를 가리키는 포인터.
    • 잘못된 포인터 값은 다양한 문제를 일으키며, 한 번의 실수는 프로그램을 종료시킨다.
    • 게임 규모가 커지고 구조가 복잡해질수록 프로그래머가 실수할 확률은 크게 증가한다.

     

    C++ 이후에 나온 언어 Java/C# 은 이런 고질적인 문제를 해결하기 위해 포인터를 버리고
    대신 가비지 컬렉션 시스템(Garbage Collection)을 도입했다.


     

     

    가비지 컬렉션 시스템

     

    • 프로그램에서 더 이상 사용하지 않는 오브젝트를 자동으로 감지해 메모리를 회수하는 시스템
    • 동적으로 생성된 모든 오브젝트 정보를 모아둔 저장소를 사용해 사용되지 않는 메모리를 추적
    • 마크-스윕(Mark-Sweep) 방식의 가비지 컬렉션
      1. 저장소에서 최초 검색을 시작하는 루트 오브젝트 표기한다.
      2. 루트 오브젝트가 참조하는 객체를 찾아 마크(Mark)한다.
      3. 마크된 객체로부터 다시 참조하는 객체를 찾아 마크하고 이를 계속 반복한다.
      4. 이제 저장소에는 마크된 객체와 마크되지 않은 객체의 두 그룹으로 나뉜다.
      5. 가비지 컬렉터가 저장소에서 마크되지 않은 객체(가비지)들메모리를 회수한다. (Sweep)

    모든 저장소는 최초에 0번을 가지고 있다. 루트 객체로부터 참조되고 있는 객체들은 1번이라고 표시를 하고, 참조되지 않는 객체는 기본값인 0번을 가지게 됩니다. 가비지 콜렉터가 동작할 때 1번이라 표시된 객체들은 살려두고 0번이라 표시된 객체들은 더 이상 참조되지 않는다고파악해서자동으로 메모리로부터 회수를 해줍니다.

     


     

     

    언리얼 엔진의 가비지 컬렉션 시스템

     

    • 마크-스윕 방식의 가비지 컬렉션 시스템을 자체적으로 구축함.
    • 지정된 주기마다 몰아서 없애도록 설정되어 있다. ( GCCycle, 기본 값 60초 )
    • 성능 향상을 위해 병렬 처리, 클러스터링과 같은 기능을 탑재함. Project Setting 에서 관련 옵션 설정이 가능하다.

     

    Project Setting에서 가비지 콜렉터 주기를 수정할 수 있다. 또한 병렬처리, 클러스트링 관련 사항도 수정할 수 있다.

     


     

     

    가비지 컬렉션을 위한 객체 저장소 

     

    • GUObjectArray: 관리되는 모든 언리얼 오브젝트의 정보를 저장하는 전역 변수
      • 언리얼 엔진에서 관리되는 모든 언리얼 오브젝트의 정보를 저장하는 전역 변수다. G 접두사는 전역을 의미한다. 이는 언리얼 엔진이 활성화된 순간에 누구나 접근 가능하다.
    • GUObjectArray의 각 요소에는 플래그(Flag) 가 설정되어 있음.
      • Garbage 플래그 : 다른 언리얼 오브젝트로부터의 참조가 없어 회수 예정인 오브젝트. GC 는 이 플래그로 설정된 오브젝트를 파악 후 안전하게 회수한다. 시스템이 자동 회수하므로 수동 회수할 필요가 없다.
      • RootSet 플래그 : 다른 언리얼 오브젝트로부터 참조가 없어도 회수하지 않는 특별한 오브젝트

     

     

    가비지 컬렉터는 GUObjectArray 에 있는 플래그를 확인해

    빠르게 회수해야 할 오브젝트를 파악하고 메모리에서 제거한다.


     

     

    가비지 컬렉터의 메모리 회수

     

    • 가비지 컬렉터는 지정된 시간에 따라 주기적으로 메모리를 회수한다. (기본 값 60초)
    • Garbage 플래그로 설정된 오브젝트를 파악하고 메모리를 안전하게 회수함.
    • Garbage 플래그 수동으로 설정하는 것이 아닌, 시스템이 알아서 설정함.

     

     

     

    한 번 생성된 언리얼 오브젝트는 바로 삭제가 불가능함

    레퍼런스 정보를 없앰으로써 언리얼 GC가 자동으로 메모리를 회수하도록 설정하는 것이다.

     


     

     

    루트셋 플래그의 설

     

    • 만약 시스템이 실행되는 동안 제거되지 않아야 하는 오브젝트라면, GUObjectArray 에서 제공하는 AddToRoot 함수를 호출해 루트셋 플래그를 설정하면 최초 탐색 목록으로 설정됨. (그럼으로 메모리 회수가 되지 않음)
    • 루트셋으로 설정된 언리얼 오브젝트는 메모리 회수로부터 보호받음.
    • RemoveFromRoot 함수를 호출해 루트셋 플래그를 제거할 수 있음

     

     

    이렇게 콘텐츠 관련 오브젝트에 루트셋을 설정하는 방법은 권장되지는 않음


     

     

    언리얼 오브젝트를 통한 포인터 문제의 해결

     

    • 메모리 누수 방지
      • 언리얼 오브젝트는 GC를 통해 자동으로 해결.
      • C++ 오브젝트는 직접 신경써야 함. (스마트 포인터 라이브러리를 활용하거나 직접 관리하는 방법이 있음)
    • 댕글링 포인터 문제
      • 어떤 오브젝트의 포인터가 외부로부터 해지되어 유효하지 않은지를 파악할 수 있어야 한다.
        언리얼 오브젝트는 이를 탐지하기 위한 함수로 IsValid() 를 제공한다.
      • C++ 오브젝트는 직접 신경써야 함. (스마트 포인터 라이브러리를 활용하거나 직접 관리하는 방법이 있음)
    • 와일드 포인터 문제
      • 언리얼 오브젝트에 UPROPERTY 속성을 지정하면 자동으로 nullptr 로 초기화 해 줌.
      • C++ 오브젝트의 포인터는 직접 nullptr 로 초기화 할 것 (또는 스마트 포인터 라이브러리를 활용)

     

     

    회수되지 않는 언리얼 오브젝트

     

    언리얼 엔진은 어떤 형태로 언리얼 오브젝트의 회수를 결정할까?

    • 언리얼 엔진 방식으로 참조를 설정한 언리얼 오브젝트 ( 대부분의 경우 이를 사용 )
      • UPROPERTY 로 참조된 언리얼 오브젝트 포인터는 GC로부터 회수 되지 않음.
      • UPROPERTY 를 사용할 수 없는 상황이라면 AddReferencedObject 함수를 통해 참조를 설정한 언리얼 오브젝트
    • 루트셋(RootSet)으로 지정된 언리얼 오브젝트
      • 이 방식은 해당 오브젝트가 굉장히 중요하다는 것을 의미하기 때문에 많이 사용하지는 않는다. 

     

    (오브젝트 선언의 기본 원칙을 따르자)

    오브젝트 포인터는 가급적 UPROPERTY 로 선언하고,

    메모리는 가비지컬렉터가 자동으로 관리하도록 위임한다.


     

     

    일반 클래스에서 언리얼 오브젝트를 관리하는 경우

     

    • UPROPERTY 를 사용하지 못하는 일반 C++ 클래스가 언리얼 오브젝트를 관리해야 하는 경우
      • FGCObject 클래스를 상속받은 후 AddReferenceObjects 함수를 구현한다.
      • 함수 구현 부에서 관리할 언리얼 오브젝트를 추가해 줌.
    • 콘텐츠 제작에서 자주 사용되지는 않는다. 아래의 예제코드에서 사용법을 익혀두자.

     


     

     

    언리얼 오브젝트의 관리 원칙

     

    • 생성된 언리얼 오브젝트를 유지하기 위해서 레퍼런스 참조 방법을 설계할 것.
      • 언리얼 오브젝트 내의 언리얼 오브젝트:  UPROPERTY 사용
      • 일반 C++ 오브젝트 내의 언리얼 오브젝트:  FGCObject 상속받은 후 AddReferencedObjects 함수를 사용.
    • 생성된 언리얼 오브젝트는 강제로 지우지 말 것 ⇒ 생성하고 GC에게 맡긴다.
      • 참조를 끊는다 생각으로 설계할 것
      • 가비지 컬렉터에게 회수를 재촉할 수는 있음 ( ForceGarbageCollection 함수 )
      • 콘텐츠 제작에서 액터 오브젝트 소멸을 위해 Destroy 함수를 사용할 수 있으나, 바로 삭제는 안되고 플래그가 설정되는 것으로 내부 동작은 동일함. ( 가비지컬렉터에 위임 )

     

     

     

     

    코드 실습


     

     

    가비지 컬렉션 테스트 환경 제작

     

    • 프로젝트 설정에서 가비지 컬렉션 GCCycle 시간을 3초로 단축 설정
    • 새로운 GameInstance 의 두 함수를 오버라이드
      • Init : 어플리케이션이 초기화될 때 호출
      • Shutdown : 어플리케이션이 종료될 때 호출
    • 테스트 시나리오
      • 플레이 버튼을 누를 때 Init 함수에서 오브젝트를 생성하고
      • 3초 이상 대기해 가비지 컬렉션을 발동
      • 플레이 중지를 눌러 Shutdown 함수에서 생성한 오브젝트의 유효성을 확인

     

     


     

     

    가비지 콜렉션 주기 변경

     

     


     

     

    예제 코드

     

    StudentManager.h

    #pragma once
    #include "CoreMinimal.h"
    
    class UNREALMEMORY_API FStudentManager : public FGCObject // GCObject 상소
    {
    public:
    	FStudentManager(class UStudent* InStudent) : SafeStudent(InStudent) {}
    
    	virtual void AddReferencedObjects(FReferenceCollector& Collector) override;
    
    	virtual FString GetReferencerName() const override
    	{
    		return TEXT("FStudentManager");
    	}
    
    	const class UStudent* GetStudent() const { return SafeStudent; }
    
    private:
    	class UStudent* SafeStudent = nullptr;
    };

     


    StudentManager.cpp

    #include "StudentManager.h"
    #include "Student.h"
    
    void FStudentManager::AddReferencedObjects(FReferenceCollector& Collector)
    {
    	if (SafeStudent->IsValidLowLevel())
    	{
    		Collector.AddReferencedObject(SafeStudent); // 등록
    	}
    }

     

     

     

    예제 코드

     

    MyGameInstance.h

    #pragma once
    #include "CoreMinimal.h"
    #include "Engine/GameInstance.h"
    #include "MyGameInstance.generated.h"
    
    UCLASS()
    class UNREALMEMORY_API UMyGameInstance : public UGameInstance
    {
    	GENERATED_BODY()
    	
    public:
    	virtual void Init() override;
    	virtual void Shutdown() override;
    
    private:
    	TObjectPtr<class UStudent> NonPropStudent;
    
    	UPROPERTY() // 댕글링 포인터 문제에서 벗어나기 위해 UPROPERTY를 붙임
    	TObjectPtr<class UStudent> PropStudent;
    
    	TArray<TObjectPtr<class UStudent>> NonPropStudents;
    	
        // TArray, TSet, TMap의 타입 인자로 언리얼 오브젝트 포인터가 들어갈 때, 반드시 UPROPERTY를 붙여줘야 안전하게 언리얼 오브젝트를 관리할 수 있다.
    	UPROPERTY()  
    	TArray<TObjectPtr<class UStudent>> PropStudents;
    
    	class FStudentManager* StudentManager = nullptr;
    };

     

     

    MyGameInstance.cpp

    #include "MyGameInstance.h"
    #include "Student.h"
    #include "StudentManager.h"
    
    void CheckUObjectIsValid(const UObject* InObject, const FString& InTag)
    {
    	if (InObject->IsValidLowLevel())
    	{
    		UE_LOG(LogTemp, Log, TEXT("[%s] 유효한 언리얼 오브젝트"), *InTag);
    	}
    	else
    	{
    		UE_LOG(LogTemp, Log, TEXT("[%s] 유효하지 않은 언리얼 오브젝트"), *InTag);
    	}
    }
    
    void CheckUObjectIsNull(const UObject* InObject, const FString& InTag)
    {
    	if (nullptr == InObject)
    	{
    		UE_LOG(LogTemp, Log, TEXT("[%s] 널 포인터 언리얼 오브젝트"), *InTag);
    	}
    	else
    	{
    		UE_LOG(LogTemp, Log, TEXT("[%s] 널 포인터가 아닌 언리얼 오브젝트"), *InTag);
    	}
    }
    
    void UMyGameInstance::Init()
    {
    	Super::Init();
    
    	NonPropStudent = NewObject<UStudent>();
    	PropStudent = NewObject<UStudent>();
    
    	NonPropStudents.Add(NewObject<UStudent>());
    	PropStudents.Add(NewObject<UStudent>());
    
    	StudentManager = new FStudentManager(NewObject<UStudent>());
    }
    
    void UMyGameInstance::Shutdown()
    {
    	Super::Shutdown();
    
    	const UStudent* StudentInManager = StudentManager->GetStudent();
    
    	delete StudentManager;
    	StudentManager = nullptr;
    
    	CheckUObjectIsNull(StudentInManager, TEXT("StudentInManager"));
    	CheckUObjectIsValid(StudentInManager, TEXT("StudentInManager"));
    
    	CheckUObjectIsNull(NonPropStudent, TEXT("NonPropStudent"));
    	CheckUObjectIsValid(NonPropStudent, TEXT("NonPropStudent"));
    
    	CheckUObjectIsNull(PropStudent, TEXT("PropStudent"));
    	CheckUObjectIsValid(PropStudent, TEXT("PropStudent"));
    
    	CheckUObjectIsNull(NonPropStudents[0], TEXT("NonPropStudents"));
    	CheckUObjectIsValid(NonPropStudents[0], TEXT("NonPropStudents"));
    
    	CheckUObjectIsNull(PropStudents[0], TEXT("PropStudents"));
    	CheckUObjectIsValid(PropStudents[0], TEXT("PropStudents"));
    }

     


     

     

     

    실행화면

     

     

    결론: UPROPERTY를 붙여줘야만 댕글링 포인터 문제로부터 벗어날 수 있다.

     

     

    처음에 public FGCObject를 상속받지 않았을 때