게임 개발/언리얼 C++

언리얼C++ - 언리얼 엔진의 메모리 관리

싹난 감자 2024. 11. 19. 15:46

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

C++은 저수준으로 메모리 주소에 직접 접근하는 포인터를 사용해 오브젝트를 관리.

프로그래머가 직접 할당(new)과 해지(delete) 짝 맞추기를 해야한다.

이를 잘 지키지 못하는 경우 다양한 문제가 발생할 수 있음.

잘못된 포인터 사용 예시

  • 메모리 누수(Leak): new를 했는데 delete짝을 맞추지 못함. 힙에 메모리가 그대로 남아있음.
  • 허상(Dangling) 포인터: (다른 곳에서) 이미 해제해 무효화된 오브젝트의 주소를 가리키는 포인터
  • 와일드(Wild) 포인터: 값이 초기화되지 않아 엉뚱한 주소를 가리키는 포인터.

잘못된 포인터 값을 다양한 문제를 일으키며, 한 번의 실수가 프로그램을 종료시킴.

게임 규모가 커지고 구조가 복잡해질수록 프로그래머가 실수할 확률은 크게 증가.

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

가비지 컬렉션 시스템

프로그램에서 더 이상 사용하지 않는 오브젝트를 자동으로 감지해 메모리를 회수하는 시스템.

동적으로 생성된 모든 오브젝트 정보를 모아둔 저장소를 활용해 사용되지 않는 메모리를 추적함.

마크 스윕(Mark-Sweep) 방식의 가비지 컬렉션

  1. 저장소에서 최초 검색을 시작하는 루트 오브젝트를 표기한다.
  2. 루트 오브젝트가 참조하는 객체를 찾아 마크(Mark)한다.
  3. 마크된 객체로부터 다시 참조하는 객체를 찾아 마크하고 이를 계속 반복한다.
  4. 이제 저장소에는 마크된 객체와 마크되지 않은 객체의 두 그룹으로 나뉜다.
  5. 가비지 컬렉터가 저장소에서 마크되지 않은 객체(가비지)들의 메모리를 회수한다. (Sweep)

 

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

마크-스윕 방식의 가비지 컬렉션 시스템을 자체적으로 구축함.

지정된 주기마다 몰아서 없애도록 설정되어 있음. (GCCycle. 기본 값 60초)

성능 향상을 위해 병렬 처리, 클러스터링과 같은 기능을 탑재함.

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

관리되는 모든 언리얼 오브젝트의 정보를 저장하는 전역 변수 : GUObejctArray (G는 전역을 의미)

언리얼 엔진이 활성화된 순간 누구나 접근할 수 있음.

GUObjectArray의 각 요소에는 플래그(Flag)가 설정되어 있음.

가비지 컬렉터가 참고하는 주요 플래그

  • Garbage 플래그: 다른 언리얼 오브젝트로부터의 참조가 없어 회수 예정인 오브젝트.
  • RootSet 플래그: 다른 언리얼 오브젝트로부터 참조가 없어도 회수하지 않는 특별한 오브젝트. 최초의 가비지 컬렉터가 참조가 되는지 안되는지 파악하기 위해 시작하는 시드(Seed)오브젝트.

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

가비지 컬렉터는 지정된 시간에 따라 주기적으로 메모리를 회수. (기본 값 60초)

Garbage 플래그로 설정된 오브젝트를 파악하고 메모리를 안전하게 회수함.

Garbage 플래그는 수동으로 설정하는 것이 아닌, 시스템이 알아서 설정함.

한 번 생성된 오브젝트를 삭제하기 위해서는 C++의 delete 키워드를 사용해서 바로 삭제하는 것이 아니라 레퍼런스 정보를 없앰으로써 가비지 컬렉터가 자동으로 메모리를 회수하도록 설정해야함.

RootSet 플래그 설정

GUObjectArray가 제공하는 AddToRoot 함수를 호출해 루트셋 플래그를 설정하면 최초 탐색 목록으로 설정됨.

루트셋으로 설정된 언리얼 오브젝트는 메모리 회수로부터 보호받음.

RomoveFromRoot 함수를 호출해 루트셋 플래그를 제거할 수 있음.

루트셋으로 설정한 뒤에는 직접 제거해주어야 하기 때문에 관리 복잡성이 증가하며 루트셋 오브젝트로 강제하는 것은 가비지 컬렉터로 관리할 수 없게 되기 때문에 메모리 최적화에 손해가 된다.

오브젝트가 절대적으로 해제되지 않아야하는 특수한 경우가 아니라면 AddToRoot를 사용하기 보다 스마트 포인터(TSharedPtr 또는 TWeakObjectPrt)나 UPROPERTY 매크로를 사용하여 자동으로 관리되게 하는 것을 권장.

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

메모리 누수 문제

  • 언리얼 오브젝트는 가비지 컬렉터를 통해 자동으로 해결.
  • C++ 오브젝트는 직접 신경써야 함. (스마트 포인터 라이브러리 활용)

댕글링 포인터 문제

  • 언리얼 오브젝트는 이를 탐지하기 위한 함수를 제공함. ::IsValid()
  • C++ 오브젝트는 직접 신경써야 함. (스마트 포인터 라이브러리 활용)

와일드 포인터 문제

  • 언리얼 오브젝트에 UPROPERTY 속성을 지정하면 자동으로 nullptr로 초기화 해줌.
  • C++ 오브젝트의 포인터는 직접 nullptr로 초기화 할 것. (또는 스마트 포인터 라이브러리 활용)

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

  • 언리얼 엔진 방식으로 참조를 설정한 언리얼 오브젝트
    • UPROPERTY로 참조된 언리얼 오브젝트 (대부분의 경우 이를 사용)
    • AddReferencedObject 함수를 통해 참조를 설정한 언리얼 오브젝트
  • 루트셋(RootSet)으로 지정된 언리얼 오브젝트

오브젝트 선언의 기본 원칙

오브젝터 포인터는 가급적 UPROPERTY로 선언하고, 메모리는 가비지컬렉터가 자동으로 관리하도록 위임한다.

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

UPROPERTY를 사용하지 못하는 일반 C++ 클래스 내에 언리얼 오브젝트가 멤버 변수로 들어가 언리얼 오브젝트를 관리해야는 경우 FGCOjbect 클래스를 상속받은 후 AddReferencedObjects 함수를 구현한다.

함수 구현부에서 관리할 오브젝트를 추가.

컨텐츠 제작에서 자주 사용하는 방법은 아님.

 

언리얼 오브젝트 관리 원칙

생성된 언리얼 오브젝트를 유지하기 위해 레퍼런스 참조 방법을 설계할 것

  • 언리얼 오브젝트 내의 언리얼 오브젝트: UPROPERTY 사용
  • 일반 C++ 오브젝트 내의 언리얼 오브젝트: FGCObject의 상속 후 구현

생성된 언리얼 오브젝트는 강제로 지우려 하지 말 것

  • 참조를 끊는다는 생각으로 설계할 것
  • 가비지 컬렉터에게 회수를 재촉할 수는 있음 (ForceGarbageCollection 함수)
  • 콘텐츠 제작에서 Actor를 소멸하기 위해 Destroy 함수를 사용할 수 있으나, 결국 내부 동작은 동일함 (플래그를 설정해주고 가비지 컬렉터에게 위임)

실습 예제

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

프로젝트 설정에서 가비지 컬렉션 GCCycle 시간을 3초로 단축 설정

새로운 GameInstance의 두 함수를 오버라이드

  • Init: 어플리케이션이 초기화될 때 호출
  • Shutdown: 어플리케이션이 종료될 때 호출

테스트 시나리오

  • 플레이 버튼을 누를 때 Init 함수에서 오브젝트를 생성
  • 3초 이상 대기해 가비지 컬렉션을 발동
  • 플레이 중지를 눌러 Shutdown 함수에서 생성한 오브젝트의 유효성을 확인

// Fill out your copyright notice in the Description page of Project Settings.

#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:

	// UPROPERTY가 아닌 변수와 UPROPERTY 지정한 변수만들기
	TObjectPtr<class UStudent> NonPropStudent;

	UPROPERTY()
	TObjectPtr<class UStudent> PropStudent;
	
	TArray<TObjectPtr<class UStudent>> NonPropStudents;

	UPROPERTY()
	TArray<TObjectPtr<class UStudent>> PropStudents;
};

MyGameInstance.h

// Fill out your copyright notice in the Description page of Project Settings.

#include "MyGameInstance.h"
#include "Student.h"

// 오브젝트가 유효한지 확인하는 함수
void CheckUObjectIsValid(const UObject* InObject, const FString& InTag)
{
    // 일반적으로 ::InValid 함수를 사용해 오브젝트에 대한 유효성 검사를 함
    // 이번 예제에서는 더 딥하게 들어가서 더 정교하게 체크할 수 있는 함수를 사용

    // IsValidLowLevel: null 포인터도 함께 체크됨
    if (InObject->IsValidLowLevel())
    {
        UE_LOG(LogTemp, Log, TEXT("[%s] 유효한 언리얼 오브젝트"), *InTag);
    }
    else
    {
        UE_LOG(LogTemp, Log, TEXT("[%s] 유효하지 않은 언리얼 오브젝트"), *InTag);
    }
}

// null값인지 체크하는 함수
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>();
}

void UMyGameInstance::Shutdown()
{
    Super::Shutdown();

    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"));
}

MyGameInstance.cpp

 

프로젝트 세팅-Engine-Garbage Collection에서 사이클 3초로 변경

NonPropStudent은 널 포인터가 아니지만 유효하지 않음

PropStudent는 널 포인터가 아니지만 유효함

 

null 포인터인지 아닌지만 보고 오브젝트가 유효한지 판단하면 댕글링 포인터 문제가 발생할 수 있다.

언리얼 오브젝트의 선언에서 언리얼 오브젝트의 클래스 멤버 변수를 선언할 때는 UPROPERTY를 지정해주어야 댕글링 포인터 문제에서 벗어날 수 있음.

 

언리얼 엔진은 일반 C++과 다르게 널이 아니고 엔진 시스템 상에서 유효해야 사용 가능한 객체가 됨

  • 널인 경우 → 사용 불가
  • 널이 아니지만 유효하지 않은 경우 → 사용 불가

언리얼 오브젝트가 유효(Valid)하다는 의미는 두 가지 조건을 만족하는 것.

  1. 널이 아니어야 함
  2. 삭제 예정이거나 가비지컬렉션(회수) 예정이지 않아야 함.

가비지 컬렉터가 회수해 갔는지 안갔는지는 사실 큰 의미는 없음. Valid하지 않아서 어차피 사용하지 않음

Unreal에서는 가비지 컬렉션 시스템에 의해 메모리가 관리되므로, 단순히 포인터를 선언하고 객체를 생성한다고 해서 수명이 보장되는 것도 아님. Unreal은 메모리 관리와 최적화를 위해 UPROPERTY를 통한 참조가 없는 객체를 주기적으로 수거할 수 있음.

UPROPERTY를 지정하면 Unreal의 가비지 컬렉터는 해당 객체를 엔진이 관리하는 메모리 그래프에 포함시켜, 참조를 추적하고 수거하지 않도록 보호함. 반대로 UPROPERTY가 없는 경우, Unreal은 그 객체를 필요 없다고 판단해 가비지 컬렉션 중에 수거할 수 있음.

일반 C++ 오브젝트에서 언리얼 오브젝트를 관리하는 경우

UCLASS()
class UNREALMEMORY_API UMyGameInstance : public UGameInstance
{
	GENERATED_BODY()
	
public:
	
	virtual void Init() override;

	virtual void Shutdown() override;

private:

	// UPROPERTY가 아닌 변수와 UPROPERTY 지정한 변수만들기
	TObjectPtr<class UStudent> NonPropStudent;

	UPROPERTY()
	TObjectPtr<class UStudent> PropStudent;

	TArray<TObjectPtr<class UStudent>> NonPropStudents;

	UPROPERTY()
	TArray<TObjectPtr<class UStudent>> PropStudents;

	// 일반 객체이기 때문에 UPROPERTY를 사용할 수 없어서
	// 값이 어떻게 될지 보장할 수 없기 때문에 null로 초기화
	class FStudentManager* StudentManager = nullptr;
};

MyGameInstance.h

void UMyGameInstance::Init()
{
    Super::Init();

    NonPropStudent = NewObject<UStudent>();
    PropStudent = NewObject<UStudent>();

    NonPropStudents.Add(NewObject<UStudent>());
    PropStudents.Add(NewObject<UStudent>());

    // 일반 오브젝트이기 때문에 new
    StudentManager = new FStudentManager(NewObject<UStudent>());
}

void UMyGameInstance::Shutdown()
{
    Super::Shutdown();

    const UStudent* StudentInManager = StudentManager->GetStudent();

    // StudentManager 소멸
    delete StudentManager;
    StudentManager = nullptr;

    // StudentManager가 지워졌을 때 StudentManager의 인자로 들어왔던 Student가 유효한지 확인
    CheckUObjectIsNull(StudentInManager, TEXT("StudentInManager"));
    CheckUObjectIsValid(StudentInManager, TEXT("StudentInManager"));
}

MyGameInstance.cpp

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"

/**
 * 
 */

 //일반 C++ 클래스는 접두사 F 붙여주기
class UNREALMEMORY_API FStudentManager
{
public:
	// 생성자에서 인수로 들어온 InStudent가 SafeStudent에 들어가게 함
	FStudentManager(class UStudent* InStudent) : SafeStudent(InStudent) {}

	// getter
	const class UStudent* GetStudent() const { return SafeStudent; }

private:
	// 일반 C++ 클래스이기 때문에 class UStudent*로 선언
	// 기본 값 null
	class UStudent* SafeStudent = nullptr;
};

StudentManager.h

Manager는 일반 C++ 객체이고 UPROPERTY같은 것을 전혀 쓸 수 없기 때문에 생성자로 UObject가 들어왔을 때 이것을 지킬 방법이 없음

가비지 컬렉션이 발동되면 객체 안에 있는 언리얼 오브젝트는 회수됨

 

일반 C++ 객체가 가진 언리얼 오브젝트를 지키려면 FGCObject 클래스를 상속받으면 됨.

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"

/**
 * 
 */

// 일반 C++ 클래스는 접두사 F 붙여주기
// 일반 C++ 객체가 언리얼 오브젝트를 지키려면
// 가비지 컬렉터에 언리얼 오브젝트를 관리하겠다고 알려야함
// FGCObject 클래스를 상속받아야함.
class UNREALMEMORY_API FStudentManager : public FGCObject
{
public:

	// FGCObject 클래스의 추상 함수 2가지를 구현해야함
	virtual void AddReferencedObjects(FReferenceCollector& Collector) override;
	virtual FString GetReferencerName() const override 
	{
		return TEXT("FStudentManager"); // 고유한 클래스 이름을 반환해주면 됨
	}

	// 생성자에서 인수로 들어온 InStudent가 SafeStudent에 들어가게 함
	FStudentManager(class UStudent* InStudent) : SafeStudent(InStudent) {}

	// getter
	const class UStudent* GetStudent() const { return SafeStudent; }

private:
	// 일반 C++ 클래스이기 때문에 class UStudent*로 선언
	// 기본 값 null
	class UStudent* SafeStudent = nullptr;
};

FStudentManager.h

// Fill out your copyright notice in the Description page of Project Settings.

#include "StudentManager.h"
#include "Student.h"

void FStudentManager::AddReferencedObjects(FReferenceCollector& Collector)
{
    if (SafeStudent->IsValidLowLevel())
    {
        // 인자로 들어온 콜렉터에 AddReferencedObject를 사용해
        // 관리할 언리얼 오브젝트를 등록해주면 됨
        Collector.AddReferencedObject(SafeStudent);
    }
}

유효하지 않았던 일반 C++ 객체 안의 언리얼 오브젝트가 유효하게 됨.

 

 


이득우의 언리얼 프로그래밍 Part1 | 인프런

https://www.inflearn.com/course/%EC%9D%B4%EB%93%9D%EC%9A%B0-%EC%96%B8%EB%A6%AC%EC%96%BC-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-part-1/dashboard

 

이득우의 언리얼 프로그래밍 Part1 - 언리얼 C++의 이해 강의 | 이득우 - 인프런

이득우 | 대기업 현업자들이 수강하는 언리얼 C++ 프로그래밍 전문 과정입니다. 언리얼 엔진 프로그래머라면 게임 개발전에 반드시 알아야 하는 언리얼 C++ 기초에 대해 알려드립니다., [사진] 언

www.inflearn.com