콘텐츠로 이동

3-5. 리플렉션과 가비지 컬렉션

앞 챕터에서 UCLASS, UPROPERTY, UFUNCTION 매크로와 AActor를 이미 살펴봤습니다. 그때는 이 매크로들이 단순히 “언리얼에 클래스를 등록하는 표시” 정도로만 다뤘지만, 사실 이들은 언리얼 엔진의 두 가지 핵심 시스템을 떠받치는 기둥입니다. 바로 리플렉션(Reflection)과 가비지 컬렉션(Garbage Collection, GC)입니다. 이 페이지에서는 이 두 시스템이 무엇이고 어떻게 연결되어 있는지, 그리고 우리가 코드를 작성할 때 반드시 지켜야 하는 규칙이 무엇인지 정리합니다.

리플렉션은 프로그램이 실행 중(런타임)에 자기 자신의 타입 정보를 들여다보고 조작할 수 있는 능력을 말합니다. “이 클래스에는 어떤 프로퍼티가 있는가”, “이 멤버의 이름과 타입은 무엇인가”, “이 함수를 이름만 가지고 호출할 수 있는가” 같은 질문에 런타임에 답할 수 있다면 그 언어/프레임워크는 리플렉션을 지원하는 것입니다.

문제는 표준 C++에는 이런 리플렉션 기능이 거의 없다는 점입니다. C++가 제공하는 typeid나 RTTI는 타입 이름과 상속 관계 정도만 알려줄 뿐, 멤버 변수 목록을 순회하거나 이름으로 함수를 찾아 호출하는 일은 표준 기능만으로는 불가능합니다. 컴파일이 끝나면 변수 이름 같은 정보는 대부분 사라지기 때문입니다.

언리얼 엔진은 이 한계를 자체 시스템으로 메웁니다. 핵심은 매크로와 코드 생성 도구의 조합입니다.

리플렉션 매크로

  • UCLASS() - 클래스를 리플렉션 대상으로 등록
  • USTRUCT() - 구조체를 등록
  • UENUM() - 열거형을 등록
  • UPROPERTY() - 멤버 변수를 등록
  • UFUNCTION() - 멤버 함수를 등록

UnrealHeaderTool (UHT)

컴파일 직전에 헤더를 스캔해서 위 매크로들을 읽고, 각 타입의 메타데이터와 보조 코드를 자동으로 생성하는 도구입니다.

빌드 과정에서 UHT가 우리의 헤더 파일을 먼저 분석합니다. UCLASSUPROPERTY 같은 매크로를 발견하면, 해당 타입과 멤버의 이름·타입·플래그 등을 담은 메타데이터 테이블과 보조 코드를 별도의 파일로 만들어 둡니다. 이 생성된 코드 덕분에 엔진은 런타임에 “이 객체에는 어떤 프로퍼티가 있고 타입이 무엇인지”를 알 수 있게 됩니다.

UHT가 만든 코드를 우리 클래스에 실제로 연결하는 두 개의 접착점이 있습니다.

첫째는 클래스 본문 안에 넣는 GENERATED_BODY() 매크로입니다. 이 매크로는 UHT가 생성한 생성자 보조 코드, 타입 정보 함수 등을 클래스 안으로 펼쳐 넣는 자리입니다.

둘째는 헤더 파일 맨 위 include 영역의 가장 마지막 줄에 넣는 generated.h include입니다. 예를 들어 MyActor.h라면 마지막 include로 MyActor.generated.h를 넣습니다. 이 파일이 바로 UHT가 생성한 코드의 실체이며, 반드시 다른 모든 include 뒤, 즉 마지막에 와야 합니다.

#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyActor.generated.h" // 반드시 마지막 include
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY() // UHT 생성 코드가 펼쳐지는 자리
public:
UPROPERTY(EditAnywhere)
float Health = 100.0f;
};

리플렉션이 가능하게 하는 것들

섹션 제목: “리플렉션이 가능하게 하는 것들”

리플렉션은 그 자체가 목적이 아니라, 언리얼의 거의 모든 핵심 기능을 떠받치는 토대입니다. UPROPERTY 하나를 붙이는 순간 다음 기능들이 한꺼번에 열립니다.

Details 패널 노출

에디터가 프로퍼티 목록을 읽어 Details 패널에 자동으로 표시하고 편집 UI를 만들어 줍니다.

직렬화와 SaveGame

엔진이 어떤 멤버가 있는지 알기에 객체를 디스크에 저장하고 다시 불러오는 직렬화가 가능합니다.

블루프린트 노출

프로퍼티와 함수를 블루프린트에서 읽고 쓰거나 노드로 호출할 수 있습니다.

네트워크 리플리케이션

서버와 클라이언트 사이에서 어떤 값을 동기화할지 엔진이 추적할 수 있습니다.

가비지 컬렉션 추적

객체 사이의 참조 관계를 엔진이 파악해 수명을 안전하게 관리합니다.

이 중 마지막 항목, 즉 가비지 컬렉션 추적이 이번 페이지의 나머지 절반의 주제입니다.

언리얼에서 UObject를 상속한 모든 객체(우리가 다뤄온 AActorUObject 계열입니다)는 수명을 가비지 컬렉터가 관리합니다. 일반 C++에서는 new로 만든 것을 delete로 직접 지워야 하지만, UObject 계열은 다릅니다.

대신 다음과 같은 전용 팩토리로 생성합니다.

NewObject()

일반 UObject 인스턴스를 만들 때 사용합니다.

SpawnActor()

월드에 배치되는 AActor를 생성할 때 UWorld를 통해 사용합니다.

이렇게 만든 객체는 우리가 직접 지우지 않습니다. 가비지 컬렉터가 주기적으로 동작하면서 다음 두 단계를 거칩니다.

  1. Mark(표시): 루트 집합(root set)에서 출발해 참조를 따라가며 도달 가능한(reachable) 모든 객체를 살아 있다고 표시합니다.
  2. Sweep(수거): 어디에서도 도달할 수 없는(unreachable) 객체를 더 이상 쓰이지 않는다고 판단해 메모리에서 수거합니다.

핵심은 “도달 가능성”입니다. GC가 어떤 객체에 도달하려면, 그 객체를 가리키는 참조를 엔진이 인식할 수 있어야 합니다. 그리고 엔진이 참조를 인식하는 방법이 바로 리플렉션, 구체적으로는 UPROPERTY입니다. 여기서 리플렉션과 GC가 직접 연결됩니다.

가장 중요한 규칙: 포인터 멤버는 UPROPERTY로 표시한다

섹션 제목: “가장 중요한 규칙: 포인터 멤버는 UPROPERTY로 표시한다”

다른 UObject를 가리키는 멤버 포인터는 반드시 UPROPERTY()로 표시해야 합니다. 이것이 이 페이지에서 가장 중요한 한 줄입니다.

UPROPERTY()로 표시하면 두 가지 보호를 받습니다.

  • GC가 그 참조를 인식합니다. 따라서 내가 어떤 객체를 가리키고 있는 동안에는 GC가 그 객체를 도달 가능하다고 보고 수거하지 않습니다(강한 참조).
  • 가리키던 대상이 파괴되면 엔진이 내 포인터를 자동으로 nullptr로 바꿔 줍니다. 덕분에 이미 사라진 메모리를 가리키는 댕글링 포인터(dangling pointer)가 생기지 않습니다.

반대로 UPROPERTY 없이 raw 포인터로 UObject를 들고 있으면, GC는 그 참조의 존재 자체를 모릅니다. 그 결과 (a) 다른 곳에서 참조가 끊기면 대상이 수거되어 버리고, (b) 내 포인터는 자동으로 정리되지 않으므로 이미 해제된 메모리를 가리키는 댕글링 포인터가 되어 접근 시 크래시가 납니다.

UCLASS()
class AInventory : public AActor
{
GENERATED_BODY()
// 안전: GC가 추적하고, 대상이 파괴되면 자동으로 null 처리
UPROPERTY()
UItem* EquippedItem;
// 위험: GC가 모름. 대상이 수거되면 댕글링 포인터가 되어 크래시 위험
UItem* CachedItem;
};

두 멤버는 타입이 똑같지만, UPROPERTY() 한 줄의 유무가 안전과 크래시를 가릅니다.

UObject를 참조하는 방법은 한 가지가 아닙니다. 의도(강한 참조인지, 약한 참조인지, 지연 로딩인지)에 따라 알맞은 타입을 고릅니다. 아래 제네릭 표기는 모두 인라인 코드로 적었습니다.

TObjectPtr

UE5에서 권장하는 강한 참조입니다. UPROPERTY()와 함께 멤버로 선언하며, 대상이 살아 있도록 유지합니다. 과거의 raw 포인터 멤버를 대체합니다.

TWeakObjectPtr

약한 참조입니다. 대상의 수명에 영향을 주지 않으며, 대상이 사라졌는지 IsValid()로 확인한 뒤 사용합니다. 소유하지 않고 참조만 하고 싶을 때 적합합니다.

TSoftObjectPtr / TSoftClassPtr

소프트 참조입니다. 에셋 경로만 들고 있다가 필요할 때 비동기로 로드하는 지연 로딩에 사용합니다. 항상 메모리에 둘 필요가 없는 대상에 알맞습니다.

UPROPERTY로 표시된 raw 포인터

UPROPERTY() UItem* Ptr; 형태의 전통적 강한 참조입니다. 여전히 유효하며 GC 추적과 자동 null 처리를 받지만, 신규 코드는 TObjectPtr<T>를 권장합니다.

드물게, 어떤 UObject를 어떤 멤버도 참조하지 않는데도 GC가 수거하지 못하게 강제로 살려 두고 싶을 때가 있습니다. 이때 AddToRoot()로 객체를 루트 집합에 직접 추가하면, 다른 참조가 전혀 없어도 도달 가능한 것으로 취급되어 수거되지 않습니다. 더 이상 필요 없어지면 RemoveFromRoot()로 풀어 줘야 하며, 풀어 주는 것을 잊으면 메모리가 영영 회수되지 않습니다.

다만 이런 수동 루팅이 필요한 경우는 흔치 않습니다. 일반적인 액터와 컴포넌트는 월드와의 관계, 그리고 소유(owner) 관계를 통해 자동으로 도달 가능 상태가 유지되기 때문입니다. 컴포넌트는 자신을 소유한 액터가, 액터는 자신이 속한 월드가 참조 사슬을 이어 줍니다. 따라서 대부분의 경우 우리는 UPROPERTY 규칙만 잘 지키면 되고, AddToRoot/RemoveFromRoot는 특수한 상황에서만 사용합니다.

가장 흔한 실수는 단연 UPROPERTY 누락입니다. 증상은 두 가지 형태로 나타납니다.

  • 분명히 만들어 둔 객체가 시간이 조금 지나면 갑자기 사라져 있다(GC가 수거해 버림).
  • 멀쩡히 동작하다가 어느 순간 그 포인터에 접근하는 코드에서 크래시가 난다(댕글링 포인터 접근).

특히 까다로운 점은, raw 포인터로 들고 있어도 GC가 한동안 돌지 않으면 우연히 멀쩡하게 동작하는 것처럼 보인다는 것입니다. 그래서 개발 중에는 멀쩡하다가 출시 빌드나 장시간 플레이에서 터지는 식으로 뒤늦게 드러나곤 합니다.

이런 의심이 들 때는 가비지 컬렉션을 강제로 한 번 돌려서 재현해 볼 수 있습니다. 콘솔 명령으로 강제 GC를 호출하면, UPROPERTY가 누락된 참조 대상이 즉시 수거되면서 문제가 바로 드러납니다.

// 코드에서 강제로 GC 수행 (재현/디버깅용)
GEngine->ForceGarbageCollection(true);

에디터 콘솔에서는 obj gc 또는 GC.CollectGarbageEveryFrame 1 같은 명령으로 자주 수거를 유도해 누락을 빠르게 노출시킬 수 있습니다.

  • 표준 C++에는 리플렉션이 거의 없지만, 언리얼은 매크로(UCLASS, USTRUCT, UENUM, UPROPERTY, UFUNCTION)와 UHT로 메타데이터를 생성해 리플렉션을 구현합니다.
  • GENERATED_BODY()와 마지막 include인 generated.h가 그 생성 코드를 클래스에 연결합니다.
  • 리플렉션은 Details 패널, 직렬화/SaveGame, 블루프린트, 네트워크 리플리케이션, 그리고 GC 추적을 가능하게 합니다.
  • UObject 계열은 직접 delete하지 않고 NewObject<>()/SpawnActor<>()로 만들며, GC가 mark & sweep로 수명을 관리합니다.
  • 다른 UObject를 가리키는 멤버 포인터는 반드시 UPROPERTY()로 표시해야 GC 추적과 자동 null 처리를 받습니다.

리플렉션 코드가 실제로 빌드 단계에서 어떻게 생성되는지, 그 전체 과정은 3-7 빌드 파이프라인을 참고하세요.