언리얼 엔진5 메모리 관리 (GC, 스마트 포인터, 소프트 레퍼런싱)

seunghyun·2024년 7월 31일
0

UE 5

목록 보기
5/5

GC 도입 배경

메모리 관리는 C++의 난점 중 하나이다.

스택에 할당된 메모리는 범위만 잘 지켜주면 대부분은 괜찮지만, (해제까지 자동으로 되므로)
힙에 할당한 메모리는 물가에 내놓은 갓난 아이처럼 끝까지 잘 지켜봐 줘야 한다.

자칫 잘못하면 그대로 소중한 메모리의 누수로까지 이어진다.

C++로 직접 메모리를 관리할 경우 new, delete 짝을 못맞춰주면, 메모리 누수, 댕글링 포인터, double free, 와일드 포인터같은 문제가 나타날 수 있다.

(UObject의 경우 UPROPERTY 매크로을 지정하면 자동으로 nullptr로 초기화해주므로 와일드 포인터 문제가 일어나지 않는다)

그래서 후발 OOP언어인 Java, C#은 이 귀찮음/어려움을 해결하기 위해 GC(가비지 컬렉션) 시스템을 사용한다. (꼭 GC만 해결방법이 아니라, Modern C++에서부터는 정식 라이브러리에 완전히 편입된 스마트 포인터라는 클래스 템플릿도 메모리 관리 방법에 속한다)

GC란 프로그램에서 더 이상 사용하지 않는, 동적으로 생성되었던 모든 오브젝트 중, 더 이상 사용하지 않는 오브젝트를 자동으로 감지해서 메모리를 회수하는 시스템이다.

GC 알고리즘으로 일반적으로는 Mark-Sweep 알고리즘을 많이 사용한다.

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

즉, 바로 해제하지는 않고 주기가 돌아올 때까지 놔뒀다가, 한 번에 정리하는 식이다.
언제 수집하고 제거할지 특정할 수 없다는 단점이 있다.

언리얼 엔진에서는 이 Mark-Sweep 방식의 GC 시스템을 자체적으로 구축했고, 지정된 주기(GCCycle. 기본 값 60초)마다 몰아서 없애도록 설정되어 있다.

백그라운드에서 GC가 작동하는 것으로 인한 부하가 심해지지 않도록 성능 향상을 위해 병렬 처리 등과 같은 기능을 구현해서 탑재하고 있고, Project Settings 에서 옵션으로 설정이 가능하다.


UE GC를 위한 객체 저장소

언리얼 엔진에서는, 관리되는 모든 언리얼 오브젝트(UObject)의 정보를 저장하는 전역 변수 GUObjectArray가 있다. (전역을 의미하는 G(Global)로 시작한다)

관리대상으로 지정된 객체들은 UObject의 모든 하위 클래스들이다.
UObject는 언리얼에서 제공하는 NewObject, SpawnActor, CreateDefaultSubobject와 같은 함수를 통해서만 생성이 가능하다. (c++의 new를 이용해서는 안된다.)
생성 과정에서 GC의 추적 대상이 된다.

이 GUObjectArray의 각 요소에는 Flag 정보가 설정되어있고, 다양한 값을 설정할 수 있는데,
GC가 참고하는 주요 플래그는 Garbage 플래그와 Rootset 플래그이다.

  • Garbage 플래그가 있다면 회수 예정인 UObject 이다. 이 플래그는 수동 설정이 아니라 시스템이 알아서 설정해준다.
  • RootSet 플래그가 있다면 최초의 GC가 참조가 되는지 안되는지 시작하는 Seed오브젝트라고 할 수 있다. 다른 UObject로부터 참고가 없더라도 GC에 의해 수거되지 않는다.

즉 정리해보자면 다음과 같다.

언리얼 GC는 GUObjectArray에 있는 플래그를 확인해서, 주기적으로 빠르게 회수해야 할 UObject를 파악하고 메모리에서 제거한다.
RootSet객체를 지정해두고 RootSet부터 GC를 수행한다.
이때 RootSet이란 영구적으로 활성상태인 객체를 의미한다.

만약 특정 UObject가 영구적으로 회수되지 않게 하고 싶다면, AddToRoot 함수를 호출해 RootSet 플래그를 설정하여 최초 탐색 목록으로 설정한다.
루트셋으로 설정된 UObject는 메모리 회수로부터 보호받는다.
특정 시점에서 이제 회수해도 된다~싶으면 RemoveFromRoot 함수를 호출해서 Rootset 플래그를 제저할 수 있다.

다만 콘텐츠 관련 UObject에 Rootset을 설정하는 방법은 권장되진 않는다고 한다.

위 그림처럼, GC는 주기적으로 GC 루트 오브젝트들에서부터 하위로 참조 그래프 탐색해나가며, 참조 그래프 상에 포함되어 있지 않은 UObject들을 자동으로 제거한다.

그리고 UObject는 다음과 같은 방식으로 참조 그래프에 추가될 수 있다.

  1. 강한 참조(UPROPERTY)로 참조되는 경우 (대상 UObject를 참조하는 객체도 참조되고 있어야한다.)
  2. UObject::AddReferencedObjects 호출을 통해 등록되는 경우 (호출하는 주체 객체도 참조되고 있어야한다.)
  3. UObject::AddToRoot로 루트 집합에 추가되는 경우 (일반적으로 많이 쓰이지는 않는다.)

그림 출처: https://mikelis.net/memory-management-garbage-collection-in-unreal-engine/


언리얼 C++ 스마트 포인터

UObject는 GC를 통해 자동으로 해결되는데,
직접 동적 할당한 C++ 오브젝트가 있다면 TSharedPtr과 같은 언리얼 스마트 포인터 라이브러리를 활용하는 등의 방법으로 직접 신경써줘야 한다.

TUniquePtr

메모리의 유일한 소유권을 넘겨주고, 해제를 자동화한 개념. (c++ stl의 unique_ptr과 대응된다.)

일반적인 포인터와 달리 객체가 파괴될 때 소유한 메모리도 해제한다.

소유권 이전에서 Move Semantic 개념이 사용된다.

TSharedPtr

메모리의 공유까지 포함하여 메모리 해제의 자동화를 다루는 개념이다. (c++ stl의 shared_ptr과 대응된다.)

TUniquePtr과 마찬가지로 메모리 해제를 다루며, TUniquePtr에서는 불가능했던, 여러 포인터가 같은 메모리를 참조하는 상황까지 다룬다. 원리는 다음과 같다.

어떤 포인터가 객체 메모리를 참조할 때마다 레퍼런스 카운트가 1 증가하고 해지할 때마다 1 내려간다.

그러다 레퍼런스 카운트가 0이 될 때 객체 메모리를 해제한다.

(여기서 항상 헷갈려하는 부분은 TSharedPtr 객체 자체에 refCnt라는 변수가 있을거라고 생각하는 것이다. refCount는 TSharedPtr 객체가 아닌 Control Block이라는 다른 메모리에 할당되어 공유된다는 사실을 정확히 알아야 헷갈리지 않을 수 있다.)

하지만 이 포인터에는 순환참조 (Circular Reference) 라는 치명적인 약점이 하나 있다.

TWeakPtr

TSharedPtr에서 순환참조를 주의해야 한다는 것은 알았지만, 그 말이 순환참조를 하지 말라는 의미가 될 수는 없다. 큰 구조의 소프트웨어를 개발하다보면 순환참조는 필연적이기 때문이다. 그렇다면 순환참조를 하면서도 위의 문제를 해결할 수 있는 방법을 알아야한다. 이를 위해 보조 포인터로 나온 개념이 TWeakPtr이다.

TWeakPtr은 다음과 같은 규칙으로 순환참조를 끊는다.

  1. TWeakPtr은 TSharedPtr를 통해서만 복사 생성, 대입 연산이 가능하다.

  2. TWeakPtr은 TSharedPtr를 참조하되, 레퍼런스 카운트를 증가시키지 않는다.

  3. TWeakPtr을 통해 객체를 참조하려면 반드시 TSharedPtr로 변환하여 사용해야 한다.

TSharedRef

공유 포인터(TSharedPtr)와 유사하게 동작하지만, 감싸고 있는 객체가 null이 될 수 없다는 점에서 다르다.
null이 불가능하기 때문에 언제든 공유 포인터로 변환될 수 있으며, 이때 가리키고 있는 객체는 항상 유효한 상태이다.
객체의 소유권을 명확히하고 싶을 때나 객체가 null이 아님을 보장하고 싶을 때 사용된다.


회수되지 않는 UObject

그렇다면 언리얼 엔진은 어떤 형태로 UObject의 회수를 결정할까?

회수되지 않는 UObject는 다음과 같다.

  • 언리얼 엔진 방식으로 참조를 설정한 UObject

    • 오브젝트 포인터는 가급적 UPROPERTY로 선언 (UPROPERTY로 참조된 UObject)

    • AddReferencedObject 함수를 통해 참조를 설정한 UObject

  • Rootset 으로 지정된 UObject


Soft Referencing

에셋을 다룰 때 중요하다.

소프트 레퍼런싱과 하드 레퍼런싱이라는 용어가 있다.

우리가 일반적으로 액터의 멤버변수로 UObject를 선언할 때 TObjectPtr로 선언했다. 이렇게 선언한 UObject는 메모리에 액터가 로딩될 때 자동으로 같이 로딩된다. 이를 하드 레퍼런싱이라 한다.

게임 진행에 필수적인 오브젝트는 이렇게 선언해도 되나, 굉장히 많은 아이템 목록이 있다면..? 이를 다 로딩하는 건 메모리에 부담이 될 것이다.

그래서 필요한 데이터만 메모리에 로딩할 수 있도록 TSoftObjectPtr로 선언하면 애셋 대신에 애셋주소의 문자열이 지정되면서, 우리가 필요할 때 애셋을 로딩할 수 있다. (최적화)

정리해보면 아래와 같다.

하드 레퍼런싱 (한국어로 강한 참조)

  • TObejctPtr 처럼 바로 꼽아주는 것
  • 바로 그 녀석을 바인딩을 해버린다.

소프트 레퍼런싱 (약한 참조)

  • TSoftObjectPtr
  • 그녀석의 주소를 저장해뒀다가, 필요할 때 그 주소를 가서 애를 가서 데리고 옴
    • Game 폴더의 애셋 주소만 가능
  • 인스턴스를 가지고 있는게 아니라서 로드 과정이 필요하다. LoadSynchrounous() 로 메모리에 올려줘야 한다. 동기적으로. 메모리 주소가 아닌 디렉토리 주소로 가서 인스턴싱 할 때가 되었단다? 라고 알려주면~ 인스턴싱을 하게 된다.

참고

참고링크 1 : tistory
참고링크 2 : inflearn Q&A
참고링크 3 : velog


UObject 기반 스마트 포인터

TWeakObjectPtr

언리얼 C++은 언리얼 오브젝트를 약하게 참조하는 TWeakObjectPtr라는 별도의 라이브러리를 제공하고 있다.

TWeakPtr와 동일한 목적의 약한 포인터이다.
원본 UObject 인스턴스의 레퍼런스 카운트를 증가시키지 않으므로 유효성 검사가 필요하다. (IsValid())

원본 인스턴스가 더 이상 유효하지 않을 수 있기 때문이다.

UPROPERTY는 내부적으로는 강한 참조를 이용하기 때문에 과하게 사용하면 가비지 컬렉팅 비용이 커지는 문제가 생긴다.

  • 예를들면 UObjectA와 UObjectB가 정의되어 있다고 가정해보자. 이 두 객체가 멤버 변수로 서로에 대한 UPROPERTY 포인터를 갖고 있다면 어떻게 될까? UPROPERTY는 강하게 서로에 대해 참조하므로 Circular Reference 문제가 생기게 된다. 일반적인 C++ 프로그램에서는 메모리 누수로 이어질 Circular Reference 문제지만 언리얼에서는 자체 구현된 GC가 주기적으로 Collect를 통해 루트로부터 붙어있지 않은 UObject들을 마킹 후 수집하므로 누수 문제로는 이어지지 않는다. 하지만 이처럼 참조 그래프가 비대해지는 문제는 피할 수 없다. 이는 결국 GC의 컬렉팅 비용을 증가시킬 것이다. 이러한 문제를 최적화하기 위해 TWeakObjectPtr을 이용하는것이 좋다.
  • 위 UObjectA, UObjectB의 예제에서 한쪽이 UPROPERTY로 강하게 참조했다면 다른쪽에서는 TWeakObjectPtr로 약하게 참조하는 것이다. 이처럼 참조 순환을 끊어주는 습관은 프로그램이 큰 만큼 GC의 부담을 줄여줄 수 있을 것이다.
  • UPROPERTY Pointer의 경우 기본적인 용도로 봤을 때, 자신이 소유권을 갖고 있는 객체를 참조하기 위해 사용하는 것은 아무 문제가 없을 것이다.
    하지만, 소유권이 없는 다른 객체를 참조하기 위해 사용되는 경우, 조건을 줄줄이 달고 사용해야 파괴된 상태에서 호출되는 상황을 피할 수 있을 것이다.
    하지만 여전히, 과도한 레퍼런스 그래프를 만들어 GC가 힘들게 하기 때문에, 피할 수 있는 상황에서는 피하는 것이 좋을 것이다.
  • 그래서 TWeakObjectPtr의 경우 소유권이 없는 객체를 참조하기 위한 용도로 아주 적합하다. 레퍼런스 카운트를 증가시키지 않기 때문에, GC 레퍼런스 그래프에도 부담을 주지 않고, Destroy가 되자마자 바로 null 체크가 가능하다.

사용 예시 1)
UI Manager 같이 객체에 대한 소유권이 없고, 게임 중에 계속 살아있는 클래스는 가급적 약참조를 걸고 있는게 바람직하다.

사용 예시 2)
UI의 리스트박스에서 UObject의 목록을 보여주고 싶을 때,
TWeakObjectPtr을 사용해 UObject를 약참조(Weak Referencing)하는 것이 바람직하다.
만약 TWeakObjectPtr를 사용하지 않고 일반 참조를 걸게 되면 UI가 띄워져 있는 동안에는 UI에서 보여지는 모든 UObject의 레퍼런스 카운팅이 올라가게 되므로, UObject를 삭제해도 GC시스템에서 회수가 일어나지 않는다.

그렇다면 TStrongObjectPtr는 언제 어울릴까?

UObject 인스턴스의 레퍼런스 카운트를 올리기 위해서는 UPROPERTY() 마킹이 필요하다.

그렇다면 지역 변수의 가비지 컬렉션 수집을 막고 싶은 경우,
사용 중에 수집되어 오브젝트가 제거되기라도 하면 곤란한 상황이 될테니 말이다!

참고

참고링크 1 : What’s the difference between using TWeakObjectPtr or using UObject*
참고링크 2 : All about Soft and Weak pointers
참고링크 3 : tistory
참고링크 4 : inflearn Q&A
참고링크 5 : 자세한 티스토리

0개의 댓글