C++의 성능과 블루프린트의 생산성을 함께 취하려다 잦은 크래시와 메모리 누수에 직면한 경험이 있으신가요? UE5 환경에서 UObject의 라이프사이클을 완벽히 이해하고, GC 관련 크래시를 원천 차단하는 고급 메모리 관리 기법을 탐구합니다.
도입 및 개요
언리얼 엔진으로 게임을 개발할 때 가장 권장되는 접근 방식은 '핵심 시스템과 무거운 연산은 C++로 구현하고, 시각적 효과 및 기획적인 로직 조립은 블루프린트에 위임하는' 혼합(Hybrid) 워크플로우입니다. 이 방식은 프로그래머와 기획/아티스트 직군 간의 협업 효율을 극대화해 줍니다. 그러나 두 언어적 환경이 교차하는 지점에서는 엔진의 가비지 컬렉션(Garbage Collection, GC) 메커니즘에 대한 깊은 이해가 없다면 예측하기 어려운 크래시나 심각한 메모리 누수(Memory Leak)가 발생하기 쉽습니다. 인디 게임을 출시한 직후, 특정 씬을 반복해서 로딩할 때마다 RAM 사용량이 기하급수적으로 증가하다가 결국 'Out of Video Memory'나 'Access Violation' 오류를 뿜으며 게임이 튕기는 현상을 마주했다면, 십중팔구 C++와 블루프린트 간의 잘못된 객체 참조가 원인입니다.
UE5의 가비지 컬렉터는 기본적으로 객체의 참조(Reference) 개수를 추적하는 방식이 아니라, 루트(Root) 객체 집합으로부터 도달 가능한(Reachable) 객체들의 그래프를 탐색하는 마크 앤 스윕(Mark and Sweep) 알고리즘을 사용합니다. 블루프린트 환경에서는 엔진이 모든 변수와 참조를 안전하게 관리해주기 때문에 개발자가 메모리 해제를 크게 신경 쓰지 않아도 됩니다. 하지만 C++ 코드에서 UObject 파생 클래스의 포인터를 멤버 변수로 캐싱할 때 UPROPERTY() 매크로를 누락한다면 어떻게 될까요? 가비지 컬렉터는 해당 객체가 C++ 코드에서 여전히 사용 중이라는 사실을 알 수 없게 됩니다. 다음 GC 주기(기본적으로 약 1분 주기)가 도래하면, 엔진은 이 객체를 쓸모없는 쓰레기로 판단하여 강제로 메모리에서 소멸시켜 버립니다. 이후 C++ 코드에서 UPROPERTY()로 보호되지 않은(그리고 이미 파괴된) 그 객체 포인터(Dangling Pointer)에 접근하는 순간 게임은 끔찍한 크래시를 일으키며 종료됩니다.
반대의 사례도 있습니다. 너무 강력한 참조를 유지하여 객체가 절대 메모리에서 해제되지 않는 '메모리 누수' 문제도 흔히 발생합니다. 대표적인 사례가 순환 참조(Cyclic Reference)와 델리게이트(Delegate) 해제 누락입니다. A라는 위젯(UI)이 캐릭터에 대한 참조를 들고 있고, 캐릭터가 이 위젯에 대한 참조를 동시에 들고 있을 경우, 서로가 서로를 물고 놓아주지 않는 상태가 될 수 있습니다. 또한, C++에서 싱글톤 객체나 게임 인스턴스에 캐릭터의 이벤트를 바인딩(Bind)해두고, 캐릭터가 파괴(Destroy)될 때 바인딩을 해제(Unbind)하지 않으면, 싱글톤 객체가 델리게이트를 통해 캐릭터를 계속 가리키게 되어 메모리에서 사라지지 않는 일명 좀비 객체가 양산됩니다. 한 인디 어드벤처 게임에서는 플레이어가 상호작용하는 모든 NPC의 정보 위젯을 AddToRoot()를 통해 강제로 유지시키다가, 스테이지를 전환해도 이전 스테이지의 UI 데이터가 수백 메가바이트씩 쌓여 결국 크래시가 발생하는 치명적 버그가 있었습니다.
이러한 문제를 해결하기 위한 완벽한 메모리 관리 기법은 용도에 맞는 포인터 래퍼(Wrapper) 클래스를 명확하게 사용하는 것입니다. 첫째, 강력하게 소유하고 생명 주기를 함께할 객체라면 반드시 UPROPERTY() 매크로를 붙이거나 TStrongObjectPtr를 사용하십시오. 둘째, 내가 객체를 소유하지는 않지만 참조만 하고 싶고, 객체가 파괴되었을 때 안전하게 nullptr로 평가되길 원한다면 TWeakObjectPtr을 사용해야 합니다. 델리게이트 안에서 객체를 참조할 때도 안전성을 위해 BindUObject 대신 BindWeakLambda 등을 활용하는 것이 좋습니다. 셋째, 블루프린트나 몬스터 스폰 데이터 등 에셋 그 자체를 참조할 때는 강한 포인터로 들고 있으면 메모리에 모든 에셋이 미리 강제 로드되어 초기 로딩 시간과 메모리 폭발을 유발하므로, TSoftObjectPtr이나 TSoftClassPtr을 사용하여 에셋 경로만 기억해 두었다가 필요한 시점에 Asset Manager를 통해 비동기 로딩(Async Loading)을 수행하는 패턴을 정착시켜야 합니다. 이 세 가지 원칙만 엄격하게 지켜도 혼합 프로젝트에서 발생하는 메모리 관련 크래시의 95% 이상을 예방할 수 있습니다.
UCLASS()
class AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// 1. 강한 참조: GC로부터 보호되며, 에셋이 즉시 로드됩니다.
UPROPERTY(EditDefaultsOnly, Category = "Weapon")
UStaticMesh* EquippedWeaponMesh;
// 2. 부드러운 참조: 메모리에 올리지 않고 경로만 가리킵니다. 비동기 로드 필요.
UPROPERTY(EditDefaultsOnly, Category = "Weapon")
TSoftObjectPtr HitSoundAsset;
// 3. 약한 참조 포인터 (UPROPERTY 불가): 객체의 소유권은 없으나 생존 여부를 안전하게 검사할 수 있습니다.
TWeakObjectPtr LastTargetedEnemy;
void InteractWithTarget(AActor* Target)
{
LastTargetedEnemy = Target;
}
void Attack()
{
// 타겟이 GC에 의해 삭제되었는지(유효한지) 안전하게 확인
if (LastTargetedEnemy.IsValid())
{
// 타겟이 살아있다면 데미지 처리
}
}
};