2024. 6. 28. 18:37ㆍUnreal Engine
언리얼을 처음 사용 할 때 UROPERTY() 같은 것은 사용하였는데
왜 사용하는지는 잘 몰랐다. 메모리 관리에 있어서 가비지 컬렉터가 어떠한 방식으로 돌아가는지도 잘 몰랐었다.
UPROPERTY() 리플렉션 시스템에 의해 관리된다.
리플렉션(Reflection)은 프로그램이 실행시간에 자기 자신을 조사하는 기능입니다.
코드 작성을 하다보면 UPROPERTY, UCLASS, USTRUCT 등의 매크로를 사용하게 되는데
이 코드들은 리플렉션이 해당 클래스, 변수 함수를 감지 할 수 있게 해줍니다.
1
|
#include "FileName....geterated.h"
|
cs |
이 헤더는 아마 C++클래스를 새로 만들 때 자동으로 들어가는 모습을 볼 수 있습니다. 이 헤더가 있으면 이제 UPROPERTY, UFUNCTION, USTRUCT 같은 것들을 사용할 수 있게 됩니다. 그리고 언리얼 4.6 이후에서는 UCLASS와 함께 GENERATED_BODY() 매크로가 따라오게 됩니다. 이런 매크로는 구조체, 클래스에 필수로 들어가야 하는데 추가적인 함수나 typedef를 주입해주기 때문이라고 한다.
코드를 작성하게 되면 이후 UHT(Unreal Header Tool)가 프로젝트 컴파일 시점에 해당 정보를 수집하게 됩니다. 결국 자동적으로 들어온 헤더와 GENERATED_BODY()등을 통해 UPROPERTY 같은 것들을 작성하면 리플렉션 시스템에 해당 클래스, 변수 등을 노출되어 진다.
UBT(Unreal Build Tool) / UHT(Unreal Header Tool)
언리얼엔진은 이 2가지로 리플렉션을 보완합니다. UBT는 전체 헤더 파일들 중 리플렉션 시스템에 들어오는 파일들을 기억하고 이후 이중 어떤 것이든 변경이 된 것이라면 UHT를 이용해서 리플렉션 데이터를 수집, 업데이트합니다.
이후 수집이 된 정보는 별개의 C++ 코드인 filename.generated.h, .cpp로 저장하게 됩니다.
Garbage Collection
리플렉션 시스템을 어느정도 이해했다고 가정하고 가비지컬렉션을 설명하겠습니다.
언리얼에서는 더이상 참조되지 않거나 명시적으로 소멸 예약시킨 UObject 를 주기적으로 정리하는 가비지 컬렉션(garbage collection) 스키마를 사용합니다. 엔진에서는 레퍼런스 그래프를 만들어 어느 오브젝트가 아직 사용중이고 어느 것이 고아가 되었는지를 알아냅니다. 이 그래프 루트에는 "루트 세트"라 지정된 오브젝트 세트가 있습니다. 어떤 오브젝트도 루트 세트에 추가시킬 수 있습니다. 가비지 컬렉션이 발생하면, 엔진은 루트 세트부터 시작해서 알려진 UObject 레퍼런스 트리를 검색하여 참조된 오브젝트를 전부 추적할 수 있습니다. 참조되지 않은 오브젝트, 즉 트리 검색에서 찾지 못한 것들은 더이상 필요치 않은 오브젝트라 가정하고 제거합니다.
가비지 컬렉션은 mark and sweep 방식으로 작동한다.
UPROPERTY()등 매크로를 사용하게 되면 리플렉션 시스템에 노출되게 되고 가비지컬렉터 대상이 된다.
공식문서에 따르면 언리얼에서는 주기적으로 UObject를 관리하는 GC 스키마를 사용한다고 합니다. 엔진에서는 이 래퍼런스 그래프(Reference graph)를 만들어서 어떤 게 사용되는지 아닌지 알아낸다고 한다.
그리고 위 그래프의 'Root' 에는 'Root set'이라 지정된 오브젝트의 set이 있다고 합니다. 어떤 오브젝트들도 이 set에 추가될 수 있으며 GC 발생 시 이 root set부터 알려진 UObject 레퍼런스 트리를 검색해 참조된 오브젝트를 전부 추적할 수 있다고 한다.
다음과 같은 참조 그래프가 있다고 했을 때
가비지 컬렉션이 활성화되면 Root Set 부터 참조 그래프를 따라가 참조된 오브젝트를 MARK합니다.
이 과정에서 참조되지 않은 것들을 삭제 할 목록으로 추가합니다.
이 과정에서 mark 되지 않은 오브젝트를 sweep 하게되며 이러한 방식이 mark and sweep이다.
언리얼 가비지 콜렉터도 이와 비슷하게 작동된다.
GC는 다음의 4가지 단계의 동작을 한다.
대부분은 GarbageCollection.cpp 의 CollectGarbageInternal(..)에 의해 실행된다.
1. Mark Unreachable
2. Mark Reachable
3. Sweep
4. Shrink Hash Table
1. Mark Unreachable
해당 과정은 병렬로 진행이 되며 모든 UObject들을 Parrallel을 통해 탐색하며 병렬 시행중에 'Unreachable' 플래그를 세워둔다
이러한 과정은
GarbageCollection.cpp → PerformReachabilityAnalysis(..) → FRealtimeGC::MarkObjectsAsUnreachable
과정을 통해 이루어진다.
2. Mark Reachable
이 단계에서는 UPROPERTY() 를 재귀적으로 따라가며 도달한 모든곳에 대해서 삭제되기로 예정된 오브젝트 ( PendingKill(UE4) or Garbage (UE5)) 를 제외하고 Unreachable 플래그를 지우게 됩니다.
이 과정은 GarbageCollection.cpp > PerformReachabilityAnalysis(...) -> FRealtimeGC::MarkObjectsAsUnreachable 에 의해 이루어진다.
루드를 돌면서 만약 PendingKill(UE4) or Garbage (UE5) 을 만나게 된다면 해당 UPROPERTY 를 NULL로 만들어주고
이것들은 더이상 가비지컬렉터가 참조를 따라가지 않게된다.
언리얼 5에서는 가비지 오브젝트가 완전히 클리어 되기 전까지 메모리에 남아있다.
가비지컬렉터가 직접 수집해주지 않는다고 한다. IsValid() 를 사용하면 false를 리턴할 뿐이다.
OnEndPlay나 soft/weak pointer를 사용 할 때
해당 객체가 보유한 메모리가 모든 강한 참조를 직접 null로 처리할 때까지 해제되지 않는다는 것을 의미한다.
3. Sweep
이제 여기서 'Unreachable' 플래그가 붙은 오브젝트들을 전부 치워주게 됩니다.
이 단계에서는 보류 중인 모든 가비지가 제거되면 UnhashUnreachableObjects를 통해 UObjects의 해시를 해제합니다.
이 단계에서 소요된 시간은 이 프레임이 완료되지 않은 경우 "도달할 수 없는 개체를 점진적으로 제거하는 데 소요된 시간(FinishDestroyed: %d, Destroyed: %d / %d)" 형식으로 LogGarbage Log를 통해 기록되고, 이 프레임이 완료되었으면 “GC purged %i objects (%i -> %i) in %.3fms” 내에 제거했습니다" 형식으로 기록됩니다.
4. Shrink Hash Table
이제 위 Sweep 단계에서 모든 가비지 제거 후, 해시 테이블이 압축된 후에 불리게 되는 단계입니다.
이 해시 테이블은 엔진이 모든 UObject들을 순회하지 않고도 자기 타입의 오브젝트를 찾을 수 있게 해주는 테이블입니다.
엔진의 로그에서 “Compacting FUObjectHashTables data took %6.2fms”. 같은 이야기가 보이면 이제 이 단계의 작업이 진행된 것입니다.
Garbage Collector 를 재대로활용하기 위한 팁
1. UObject에서 포인터는 UPROPERTY를 추가하자
포인터는 UPROPERTY() 를 추가하여 GC에서 추적이 가능하도록 해야한다.
2. 일반클래스의 메모리를 관리하려면 TWeakObjectPtr' / 'FWeakObjectPtr 를 사용하자
UObject*에 대해 UPROPERTY 를 사용하지 않은 경우에는 TWeakObjectPtr을 사용하자
어떤 오브젝트들을 참조하는 UI 요소가 있다고 했을 때 이들은 TWeakObjectPtr를 사용하는것이 좋다.
만일 그냥 포인터를 받아오게 되면 이 요소들은 UI가 삭제되기 전까지 삭제될 수 없기 때문이다.
TWeakObjectPtr는 참조하는 대상이 파괴되는 경우 nullptr로 세팅하는 기능이 있기 때문에 유용하게 사용 할 수 있다.
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의 부담을 줄여줄 수 있을 것이다.
'Unreal Engine' 카테고리의 다른 글
UE5 PossessedBy() 와 OnRep_ 함수 (0) | 2024.07.25 |
---|---|
UnrealEngine EnhancedInput (0) | 2024.07.25 |
커스텀 엑터 생성하기 (0) | 2024.06.26 |
메모리 관리, 스마트 포인터, 디버깅 (0) | 2024.06.26 |
클래스 생성 (0) | 2024.06.26 |