팀 프로젝트 미니맵 UI 제작 관련 중 발생한 문제입니다.

※ 미니맵 관련 포스팅 - https://mynameiskgws.tistory.com/50

미니맵을 초기에 설계했을 때, 각 액터 컴포넌트에 타임 핸들러를 사용하여 일정 시간마다 위치값을 전송하려고 했습니다.

이 때 각 액터 컴포넌트들이 타임핸들러를 가지고 있을 경우, 미니맵에 100개의 몬스터가 렌더링 된다 하면 타이머도 100개를 소지하게 되며 메모리 낭비 및 오버헤드 발생 우려가 있었습니다.

이를 해결하기 위해 액터의 시간을 전송하는 싱글톤패턴 기반의 Minimap Manager를 설계하게 되었습니다.

 

UObject 기반의 Manager 클래스

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnTargetUpdated, const TArray<FMinimapTargetLocation>&, UpdateLocations);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnTargetRemoved, AActor*, RemovedTarget);

UCLASS()
class GUNFIREPARAGON_API UMinimapManager : public UObject
{
	GENERATED_BODY()
	
public:
	void Initialize();
	void RegisterTarget(AActor* Target);
	void UnRegisterTarget(AActor* Target);
	void RecvLocationToBroadCast();

public:
	UPROPERTY(BlueprintAssignable, Category = "Minimap")
	FOnTargetUpdated OnAllLocationsUpdated;

	UPROPERTY(BlueprintAssignable, Category = "Minimap")
	FOnTargetRemoved OnTargetRemoved;

private:
	TMap<AActor*,FVector> TargetLocations;
	FTimerHandle UpdateTimer;

	void UpdateTargetsLocation();
};

void UMinimapManager::Initialize()
{
	TargetLocations.Empty();
}

void UMinimapManager::RegisterTarget(AActor* Target)
{
	if(!GetWorld())
	{
		UE_LOG(LogTemp, Display, TEXT("World Is Null"));
		return;
	}
	if (IsValid(Target) && !TargetLocations.Contains(Target))
	{
		TargetLocations.Add(Target, Target->GetActorLocation());

		if (!UpdateTimer.IsValid())
		{
			UpdateTargetsLocation();
		}
		UE_LOG(LogTemp, Display, TEXT("Register Test : %s"), *Target->GetName());
	}
}

void UMinimapManager::UnRegisterTarget(AActor* Target)
{
	if (TargetLocations.Contains(Target))
	{
		TargetLocations.Remove(Target);
		OnTargetRemoved.Broadcast(Target);
	}

	if (TargetLocations.Num() == 0)
	{
		if (GetWorld())
		{
			GetWorld()->GetTimerManager().ClearTimer(UpdateTimer);
		}
	}
}

void UMinimapManager::RecvLocationToBroadCast()
{
	TArray<FMinimapTargetLocation> TargetData;

	for (const auto& Target : TargetLocations)
	{
		if (Target.Key)
		{
			FMinimapTargetLocation Data;
			Data.TargetActor = Target.Key;
			Data.Location = Target.Key->GetActorLocation();
			TargetData.Add(Data);
		}
		else
		{
			UnRegisterTarget(Target.Key);
		}
	}

	OnAllLocationsUpdated.Broadcast(TargetData);
}

void UMinimapManager::UpdateTargetsLocation()
{
	float UpdateCycleTime = 0.1f;
	if (GetWorld())
	{
		GetWorld()->GetTimerManager().SetTimer(UpdateTimer, this, &UMinimapManager::RecvLocationToBroadCast, UpdateCycleTime, true);
	}
}

 

해당 클래스를 기존에는 static Get을 통한 싱글톤 패턴의 기반으로 하였으나,

미니맵에 해당 액터클래스를 등록하는 과정에서 GetWorld()가 Null이 되는 로그가 출력되었습니다.

이를 해결하기 위해, GameInstance에서 등록을 하였지만, 마찬가지로 GetWorld()가 Null이 출력되며, 크래시되는 현상이 발생하였습니다.

해당 과정은 UObject 기반의 클래스를 정적 으로 사용시에 발생한 문제입니다. 

원인 분석

  • 게임 컨셉이 레벨과 레벨을 이동하는 과정이 발생한다.
  • 해당 과정에서 레벨이 다시 실행 될 경우, 액터 라이프사이클과 같이 새 월드를 가져온다.
  • 해당 과정에서 전역 클래스인 MinimapManager를 재 호출하게되며, 순서를 보장하지 않은 상태.
  • 이로인해 MinimapManager를 다시 불러오며 동시에 새 월드가 생성되기 전 호출되어 Null반환을 하게된다.
    • 순서가 보장이 되지않는다 -> 매 실행마다 출력되는 로그가 다름. Null일경우, 아닐경우 존재를 통해 검증.
  • --- 다른 원인 ---
  • AActor에서 파생된 모든 클래스는 배치 할 수 있는 클래스. 즉, 자신이 어느 World인지 인지 할 수 있다.
  • UObject는 GetWorld()를 호출 할 수 있으나, 배치 되지 않은 경우 의미가 없음.

 

해결 과정

  • 게임 시작부터 유지되는 GEngine()->GetWorld... 를 사용한 호출
    • 수명동안 다른 세계를 로드할 수 있으므로 특정 세계를 반환할 수 없음. 실패.
  • 게임 시작부터 끝까지 데이터를 가지고 있는 Instance에서 생성, 초기화에서 호출
    • 동일 현상, 원인 파악을 못했음. 실패.
    • 기본 자료형 또는 함수는 사용 할 수 있으나, 월드간 변화시 동일 월드가 아니라서 발생한것같다.
  • Actor기반의 클래스 내 호출하여 사용하기
    • 해당 액터의 GetWorld()를 가져오기 때문에 정상적으로 작동.
    • 하지만, BeginPlay()후 GetWorld()를 호출하여 사용하면 초기 설계시 고려한 문제가 발생한다. 실패.
  • Timer를 사용하지 않고, Delegate를 사용한 이벤트 기반으로 리팩토링
    • 특정 조건에 바인딩하여 이벤트 기반 데이터 전송. 구현 성공

 

정리

  • UObject에는 GetWorld() 함수가 존재하기는 하나, 실제로는 null을 반환 할 수 있다.
  • GEngine()->GetWorld()... 를 사용한 호출은 서로 다른 세계를 가져올 수 있어 적합하지 않다.
  • Instance 내 GetWorld()를 사용할 순 있지만, 정적 클래스에는 부적합 (추정)
  • Timer 사용을 하지 않은 Delegate 이벤트 바인딩, 전송으로 인한 우회, 구현 성공

 


참고내역

https://forums.unrealengine.com/t/gengine-getworld-return-null/377571

https://husk321.tistory.com/426

https://www.inflearn.com/community/questions/989392/getworld-%EA%B0%80-nullptr?srsltid=AfmBOookfoyJK0gL44HvTFW_aOKR6EPWRV0Fc8a7JPtwvrUCHOoRy5xI

 

'Unreal Engine > Debugging' 카테고리의 다른 글

[UE5] - 유효성 검사  (0) 2025.01.27

언리얼 엔진을 사용하여 학습하던 중, nullptr 또는 캐스팅된 클래스가 개발자가 의도한 클래스인지 확인하기위해 

객체 또는 유효성 검사를 하였습니다.

유효성 검사중 Succeeded, Assert, IsValid, ==nullptr 등 다양한 함수를 사용하게 되었으며,

각각 어떠한 상황에 써야지 알맞는지 학습해보았습니다.

이번 포스팅에서는 상황에 맞는 유효성 검사를 수행하는 방법에 대해 알아보겠습니다. 

 

유효성 검사

  • 객체, 클래스, 상태 등이 올바르고 기대된 조건을 충족하는지 확인하는 방법
  • 입력 데이터, 객체 상태, 연산 결과 등이 유효한지 검증한다.
  • 디버깅, 런타임등의 다양한 환경에서 프로그램의 예기치 않은 동작 혹은 오류를 방지 할 수 있다.
  • 오류 발생 위치를 명확하게 알 수 있으므로, 디버깅 과정에서 시간을 절약 할 수 있다.
  • 코드의 신뢰성 및 유지보수성을 향상시킨다.
  • 객체가 삭제되거나, 메모리에서 해제된 상태에서도 참조하는 경우(Dangling Pointer)문제 방지 가능

 

== nullptr

  • 단순히 포인터가 nullptr인지 확인한다.
  • UE엔진의 Class, Actor, Object들은 모두 포인터 방식.
    • 객체 유효성 검사시 가장 쉬운 방법
  • GC객체를 감지하지 못해 IsValid보다는 안전하지 않다
  • GC가 필요하지 않은 경우에는 적합하다.
    • 삭제된 경우, !Actor은 유효할 수 있다고 판단 할 수 있다.
    • 일반 자료형(int32*, float*), 스마트포인터, UClass 리플렉션이 없는 클래스 등

 

IsValid

  • 동적으로 생성된 객체의 유효성 검사에 특화된 함수
  • UObject를 상속한 객체의 포인터에 대한 유효성 검사
  • GC에 의해 삭제되었는지 확인 가능
  • nullptr 과 GC를 IsValid를 사용하여 확인 할 수 있다.
  • nullptr만 확인하기에는 오버헤드가 발생할 우려가 있다.
    • 단순한 nullptr 체크는 == nullptr를 사용하는것이 더 효율적이다.
  • AActor, UActorComponent 객체가 동적으로 생성된 경우 상태를 확인하기 위해 사용한다.
    • 파괴된 액터에 접근하여 크래시를 방지한다.
    • 삭제 중(IsPendingKill()인 경우, IsValid를 사용하여 삭제를 취소 할 수있다.

 

IsPendingKill

  • GC에 의해 삭제 예정 상태, 삭제 중인지 확인하고 싶을 경우 사용한다.
  • 객체가 삭제 중이지만 메모리가 여전히 존재한 경우에 사용한다.
  • nullptr의 상황은 확인하지 않는다.
  • 삭제중인 액터에 접근시, 크래시 발생 우려가 있으므로 IsPendingKill를 사용하여 확인한다.
  • 타이머/델리게이트에서 삭제된 객체를 확인한다.

 

Check

  • UE에서 디버깅중 유효성 검사를 위해 사용한다.
  • 코드에서 특성 조건이 항상 참이어야 하는 경우에 사용한다.
  • 조건이 실패시에 에디터 크래시, Assert를 출력한다.
  • 디버거에서 해당 지점을 중단점으로 잡아 잘못된 코드경로를 빠르게 확인 할  수 있다.
  • 논리적으로 실행이 안되는 코드가 실행되었을 때 크래시를 발생시키는 경우
  • Shipping 빌드시에는 사용하지 않는다.
    • Shipping 빌드시에 런타임 유효성 검사는 Ensure가 더 적합하다.
  • 개발자의 실수로 인한 경우에도 크래시를 유발 할 수 있으므로, Ensure 또는 if문을 사용한 검사가 더 효율적일 수 있다.

Ensure

  • Check와 비슷하지만, 크래시가 아닌 경고만 출력한다.
  • 조건 실패시에도 계속 실행 될 수 있다.
  • 사용자 입력의 경우 실수 가능성이 있어 Ensure를 사용하여 조건이 실패해도 계속 실행해야 하는 경우 사용한다.

Succeeded

  • 함수 실행 결과가 성공했는지에 대해 확인한다.
  • FOptionalResult, FHitResult, ConstructorHelpers같은 함수 호출 결과를 검사하는 경우 사용한다.

 

Assert

  • 조건이 실패하면 디버거 브레이크 포인트를 트리거한다.
  • Shipping 빌드시 비활성화 된다.

 

Verify

  • Shipping 빌드에서도 조건 검사를 수행한다.
  • 디버깅 및 릴리즈 환경 모두에서 조건 검증이 필요한 경우에 적합하다.
  • 실패시에 Check와 같은 크래시가 발생한다.
    • Shipping빌드 환경에서는 크래시 대신 무시된다.
    • 실패해도 크래시 대신 검증 로그를 남기고 실행을 계속해야 할 때 사용한다.
  • 특정 조건이 항상 참이여야 하는 경우에 사용하여, 런타임 환경에서도 코드 안정성을 보장한다.

 

IsTemplate

  • 객체가 CDO인지 확인하고 싶을 때
  • 클래스의 기본 값을 설정하거나, 특정 작업을 CDO에만 적용해야 하는 경우에 사용한다.
  • 게임 플레이 도중 특정 로직이 CDO에서 실행하지 않도록 제한한다.

 

GEngine→AddOnScreenDebugMessage

  • 디버깅시 메시지를 출력하고 싶을 경우 사용한다.
  • 디버깅 중 상태를 확인하기 쉽다.
  • 런타임 디버깅용으로만 사용된다.

 

 

객체 클래스 검사 및 캐스팅시의 차이점  (IsA VS Cast<T>())

 

bool UObject::IsA(const UClass* Class) const;

  • UClass타입을 확인하고자 할 때 사용한다.
  • 객체가 해당 클래스이거나, 그 클래스에서 파생된 클래스인 경우 true 반환
  • 객체 타입을 확인할 때 사용한다.
  • 런타임시 타입 정보를 확인 할 수 있다.
  • 실제 클래스 타입을 확인할 때 사용한다.

 

Cast<T>(Obj)

  • 객체를 지정된 클래스 타입으로 캐스팅 한다.
  • 타입으로 캐스팅 된 객체 포인터를 반환한다.
  • 캐스팅이 실패한 경우 nullptr를 반환한다.
  • 객체를 특정 타입으로 변환하여 작업을 수행하고자 할 때 사용한다.

 

 

 

정리

  • 디버깅 : Check, Ensure, Assert
  • 런타임 조건 검증 및 경고 : Ensure, EnsureMsgf, EnsureAlways
  • UObject 포인터 유효성 확인 : IsValid, IsPendingKill, IsValidLowLevel
  • Shipping 빌드 : Verify, Succeeded
  • 디버깅 메시지 출력 : GEngine→AddOnScreenDebugMessage

'Unreal Engine > Debugging' 카테고리의 다른 글

[UE5] UObject 클래스 내 GetWorld Null 반환  (0) 2025.02.26

+ Recent posts