Blog

ENGINEERING NOTE

[Unreal Engine] Validation, Assertions

Unreal Engine으로 개발하는 프로젝트는 대게 복잡하고 규모가 크다.실시간 렌더링, 블루프린트와 C++간의 상호작용, 멀티스레드와 네트워크 환경 등 다양한 요소가 얽혀 있어 예기치 못한 버그가 발생하기 쉽다.특히 게임 플레이 도중 예외적인 상황이 발생했을 때,

Unreal Engine

Unreal Engine에서의 Defensive Programming

Unreal Engine으로 개발하는 프로젝트는 대게 복잡하고 규모가 크다.

실시간 렌더링, 블루프린트와 C++간의 상호작용, 멀티스레드와 네트워크 환경 등 다양한 요소가 얽혀 있어 예기치 못한 버그가 발생하기 쉽다. 특히 게임 플레이 도중 예외적인 상황이 발생했을 때, 적절한 방어 로직이 없다면 크래시나 예측 불가능한 동작이 이어질 수 있다.

이러한 문제를 줄이기 위해 필요한 접근이 바로 Defensive Programming이다. 이는 코드가 잘못된 입력이나 예상 밖의 상황을 만났을 때, 버그를 조용히 넘기거나 명확하게 로그를 남기도록 작성하는 방식이다. 단순히 오류를 피하는 것을 넘어서 문제 발생 시 시스템의 안정성을 확보하는 목적도 있다.

Unreal Engine에서는 이를 위해 여러 도구나 함수를 지원하고 있다. 대표적으로 'IsValid, check, 'ensure 등이 있다.

Validation

Unreal Engine에서는 다양한 레벨에서 유효성 검사가 필요하다. 액터나 컴포넌트가 유효한지, 특정 데이터가 로드되었는지, 널 포인터 참조가 발생하지 않는지 등, 상황에 따라 반드시 선행 조건을 검증해야 하는 경우가 많다. 이러한 유효성 검사를 통해 코드의 안정성과 예측 가능성을 확보할 수 있다.

언리얼 엔진에서 흔히 사용되는 UObject의 경우, 가장 자주 사용되는 방법은 IsValid함수를 통한 유효성 검사이다.

text
if (IsValid(MyActor))
{
    MyActor->DoSomething();
}

해당 함수의 구현 부분을 살펴보면 다음과 같은 코드가 나타난다.

text
FORCEINLINE bool IsValid(const UObject *Test)
{
	return Test && FInternalUObjectBaseUtilityIsValidFlagsChecker::CheckObjectValidBasedOnItsFlags(Test);
}
text
struct FInternalUObjectBaseUtilityIsValidFlagsChecker
{
	FORCEINLINE static bool CheckObjectValidBasedOnItsFlags(const UObject* Test)
	{
		// Here we don't really check if the flags match but if the end result is the same
		PRAGMA_DISABLE_DEPRECATION_WARNINGS
		checkSlow(GUObjectArray.IndexToObject(Test->InternalIndex)->HasAnyFlags(EInternalObjectFlags::PendingKill | EInternalObjectFlags::Garbage) == Test->HasAnyFlags(RF_InternalPendingKill | RF_InternalGarbage));
		PRAGMA_ENABLE_DEPRECATION_WARNINGS
		return !Test->HasAnyFlags(RF_InternalPendingKill | RF_InternalGarbage);
	}
};

UObject 기반의 클래스에서 IsValid함수가 호출되면, 해당 함수는 오브젝트가 PendingKill/InternalGarbage 상태가 아닌 지를 확인해준다. 일반적인 C++의 널 포인터 검사와는 다르게, Unreal에서는 GC(가비지 컬렉션) 시스템이 동작하기 때문에 단순히 if(Ptr)로는 충분하지 않은 경우가 존재한다.

또한 IsValid의 경우, 게임 실행 도중 예상치 못하게 객체가 사라지는 상황에서도 안정적인 처리를 하도록 도와준다.


Assertion

게임 개발에서는 종종 *"절대 일어나지 말아야 하는 상황"*을 마주하게 된다.

로직상 특정 조건이 반드시 참이어야 하거나, 특정 포인터가 절대로 null이 되어선 안 되는 경우 등이다. 이러한 조건들을 코드에 명시해두면, 문제가 발생했을 때 즉시 감지하고 빠르게 원인을 추적할 수 있다.

이때 사용할 수 있는 방법이 바로 Assertion이다.

Unreal Engine에서는 이를 위해 Check, Verify, Ensure과 같은 매크로를 지원하고 있다.

Check

조건이 거짓일 경우, Crash를 발생시켜 콘솔에 오류 메세지를 출력하고, 게임을 중단하거나 디버거를 발생시키는 강력한 매크로다.

text
void MyFunction()
{
    APlayerCharacter* PC = Cast<APlayerController>(GetController());

    // If the condition returns false, the game will crash.
    // By default, C++ and Unreal has some overloads for pointers and classes, which allows them to return as boolean.
    check(PC);

    // But, if you wish to be explicit:
    check(PC != nullptr);
}
  • 용도 : 필수 조건 검사, 절대 null이 아니어야 할 포인터
  • 동작 : 실패 시 프로그램이 즉시 종료되며, 콜스택을 통해 원인을 추적 가능
  • 버전 : Debug Build에서만 활성화되며, Release Build에서는 영향을 끼치지 않음.

Verify

check와 비슷하게 조건을 검사하지만, 주로 함수 호출의 결과를 검사할 때 사용한다.

text
void MyFunction()
{
    APlayerCharacter* PC = Cast<APlayerController>(GetController());

    // Same as 'check' assertion.
    // However, this assertion will include in all builds (including shipping builds).
    verify(PC);
}

Ensure

check보다 유연한 방식이다. 조건이 거짓이라면 로그에 경고 메시지를 남기지만, 프로그램을 종료시키지는 않는다. 디버깅 단계에서 유용하게 사용할 수 있다.

text
void MyFunction()
{
    APlayerCharacter* PC = Cast<APlayerController>(GetController());

    // Non-critical check (assertion only during development and editor builds).
    // This will only call once and will not retrigger for each play.
    ensure(PC);

    // To have this assertion retriggerable, then use this macro instead:
    ensureAlways(PC);
}
  • 용도 : 조건이 보장되지 않지만, 문제가 생기면 로그를 통해 추적하고 싶은 경우
  • 동작 : 로그 출력 + 한 번만 경고 (에디터에서는 메시지 창도 표시됨)
  • 버전 : Release Build시에도 코드는 남아있음.

Alternatives Assertions

Unreal Engine에서는 기본적인 check, verify, ensure 외에도 문자열 메시지를 출력할 수 있는 확장 매크로들을 제공하고 있다. 이는 디버깅 시 어떤 조건이 실패했는지 명확하게 로그로 남길 수 있어 유용하다.

text
void MyFunction()
{
    APlayerController* PC = Cast<APlayerController>(GetController());
    checkf(PC, TEXT("PlayerController는 null일 수 없습니다!"));

    ULocalPlayer* LP = PC->GetLocalPlayer();
    verifyf(LP, TEXT("Shipping 빌드에서는 LocalPlayer가 null이면 안 됩니다!"));

    bool bIsDead = false;
    ensureMsgf(bIsDead, TEXT("플레이어는 죽은 상태가 아니어야 합니다!"));
}

기타 Assertion 매크로

unimplemented()

구현되지 않은 함수임을 명시할 때 사용한다. 함수의 뼈대는 만들어두었지만 아직 구현할 내용이 없는 경우 유용하다.

text
void DoSomething()
{
    unimplemented(); // 나중에 구현 예정
}

checkCode(Expression)

코드 블록 내부에서 특정 조건을 검증할 수 있게 하는 매크로다.

내부적으로 do { ... } while(false) 구조로 감싸져 있으며, break, continue 키워드를 사용할 수 있다. 메모리 정리나 빠져나가기 전 검사에 유용하다.

text
checkCode(
    if (ObjectItem->IsPendingKill())
    {
        UE_LOG(LogGarbage, Fatal, TEXT("객체 %s는 루트 셋에 포함되어 있지만 RF_PendingKill 상태입니다!"), *Object->GetFullName());
    }
);

checkNoEntry()

절대 실행되어서는 안 되는 코드에 사용한다. 이 지점에 도달했다는 것은 논리적인 문제가 발생한 것이다.

text
if (IsValid(Player))
{
    switch (MyEnum)
	{
		case MyEnumValue:
			break;
		default:
			checkNoEntry(); // 이 지점에 오면 안 됨
			break;
	}
}

checkNoReentry()

함수 또는 코드 블록이 두 번 이상 실행되어선 안 되는 상황에서 사용한다. 한 번 호출이 완료되기 전까지 다시 호출해서는 안될 함수에 사용하세요.

text
void NoReentry()
	{
		checkNoReentry();
	}

checkNoRecursion()

재귀 호출이 절대 발생해서는 안 되는 경우에 사용한다.

text
int32 Recurse(int32 A, int32 B)
	{
		checkNoRecursion();
		return Recurse(A - 1, B - 1);
	}

Summary

✅ IsValid() vs 🛑 Assertion (check, ensure, ...)

구분IsValid()check, ensure, verify, ... (Assertion)
주 용도UObject의 유효성 확인코드 논리 검증, 치명적 조건 확인
대상UObject*에만 사용 가능모든 표현식(포인터, bool, 수식 등)
실행 시점런타임에서 조건 분기디버깅 시점에서 조건 단언(assert)
동작 방식false면 if 조건 분기로 우회false면 로그 출력 또는 크래시 발생
Shipping 빌드 반영포함됨일부 매크로 (check)는 제거됨
예시if (IsValid(Actor)) { ... }check(Actor != nullptr);
사용 목적객체가 GC에 의해 파괴되었는지 확인절대 만족되어야 하는 조건 보장

IsValid()의 특징

  • UObject 기반 객체의 유효성을 확인하는 함수
  • nullptr 여부 + PendingKill 여부까지 확인

→ 단순한 if (Ptr)로는 Unreal의 GC(Garbage Collection)를 반영하지 못함

Assertion (check, ensure, 등)의 특징

  • 주로 디버깅 단계에서 코드 논리 검증을 위해 사용
  • 조건이 false일 경우 강제로 크래시를 발생시키거나 로그 경고를 출력