개요


삭제된 객체의 함수가 바인딩된 델리게이트를 실행했을 때, 크래쉬가 나는 경우가 있다. 이 문제를 해결하기 위해서는 어떻게 해야될까? 그리고 두 델리게이트 바인딩 방법의 차이점을 알아보자...

결론


동적 바인딩 Blueprint와 연동 크래쉬 위험 (IsBound 함수를 사용하지 않을 때) 리플렉션
BindUFunction 런타임에 함수 이름을 결정해야 할 경우 가능 있음 UFunction 이기 때문에 가능
CreateUObject 컴파일 타임에 함수가 결정되어야 함 불가능 없음 네이티브 C++ 함수이기 때문에 불가능
  • Blueprint/동적 바인딩: BindUFunction
  • 단순 네이티브 바인딩: CreateUObject
  • IsBound 는 꼭! 사용하자

BindUFunction 바인딩


void ADelegateTestActor::StartDangerousTest()
{
    UE_LOG(LogTemp, Warning, TEXT("=== 위험한 테스트 시작 (직접 바인딩) ==="));
    TargetObj = GetWorld()->SpawnActor<ATargetObject>();
    
    UE_LOG(LogTemp, Warning, TEXT("BindUFunction 으로 직접 바인딩 완료"));
    DirectBindDelegate.BindUFunction(TargetObj, FName("OnDelegateCallback"));
    
    UE_LOG(LogTemp, Warning, TEXT("타겟 객체 삭제 ..."));
    TargetObj->Destroy();

    UE_LOG(LogTemp, Warning, TEXT("DirectBindDelegate 델리게이트 호출 시도..."));
    if (!DirectBindDelegate.IsBound()) // IsBound 함수는 델리게이트 내부의 UserObjectPtr (Object 의 WeakPtr) 가 IsValid 한지 체크 한다.
    {
        UE_LOG(LogTemp, Warning, TEXT("DirectBindDelegate 의 UserObjectPtr is Not Valid"));
    }

    DirectBindDelegate.Execute(123);
    
    UE_LOG(LogTemp, Warning, TEXT("Execute 하면 Crash 발생"));
}
  • 객체 삭제 후, 델리게이트의 UserObjectPtr은 여전히 0x10ABC123 값을 가지고 있음 (포인터 값 자체는 변경되지 않음)
  • 0x10ABC123 주소의 상태: 이미 해제된 메모리
  • WeakPtr의 내부 상태: Invalid (참조 카운트로 객체 삭제 감지)
    RetValType Execute(ParamTypes... Params) const final
    {
        using FParmsWithPayload = TPayload<RetValType(typename TDecay<ParamTypes>::Type..., typename TDecay<VarTypes> ::Type...)>;

        checkSlow(IsSafeToExecute());

        TPlacementNewer<FParmsWithPayload> PayloadAndParams;
        this->Payload.ApplyAfter(PayloadAndParams, Forward<ParamTypes>(Params)...);
        UserObjectPtr->ProcessEvent(CachedFunction, &PayloadAndParams);
        return PayloadAndParams->GetResult();
    }
  • DirectBindDelegate 델리게이트는 위의 Execute 를 실행
    • 하지만 UserObjectPtr 은 이미 해제된 메모리에 접근하여 크래쉬 발생

❓ 왜 언리얼에서는 Crash가 날 수 있는 코드를 작성했을까?

  • 언리얼은 UFUNCTION 시스템을 통해 Blueprint나 C++ 모두에서 함수 호출을 가능하게 함.
  • 이를 실제로 호출하는 방식이 바로 UObject::ProcessEvent()
  • 이때 CreateUObjectExecute와 다르게 ProcessEvent 함수를 호출함.
    • TDelegate::CreateUObject는 일반적인 C++ 객체 함수 포인터를 안전하게 바인딩
    • 하지만 UFUNCTION 기반 Delegate는 ProcessEvent를 사용하여 호출
  • ProcessEvent 는 기본적으로 유효한 UObject 에서 호출 가능하기 때문에 당연히 유효성 검증은 프로그래머가 실시하도록 만들어진 함수
    • 왜 유효한 UObject 인지 직접 검사하지 않는 이유는 성능 상 문제일 가능성이 높음
    • UFUNCTION 은 (애니메이션, 타이머, 블프 이벤트 등등) 많이 호출되기 때문에 ProcessEvent가 있는 모든 곳에 유효성을 검사하는 로직을 추가하면 비용이 클 것으로 보임
    • 실제로 AnimNotify 도 캐릭터가 사라지면 Crash 가 가능함.

CreateUObject 바인딩


void ADelegateTestActor::StartSafeTest()
{
    UE_LOG(LogTemp, Warning, TEXT("=== 안전한 테스트 시작 (CreateUObject) ==="));
    TargetObj = GetWorld()->SpawnActor<ATargetObject>();

    UE_LOG(LogTemp, Warning, TEXT("CreateUObject 바인딩 완료"));
    CreateUObjectDelegate = FTestDelegateCreateUObject::CreateUObject(
        TargetObj, &ATargetObject::OnDelegateCallback);
    
    UE_LOG(LogTemp, Warning, TEXT("타겟 객체 삭제 ..."));
    TargetObj->Destroy();

    UE_LOG(LogTemp, Warning, TEXT("CreateUObject 델리게이트 호출 시도..."));
    if (!CreateUObjectDelegate.IsBound()) // IsBound 함수는 델리게이트 내부의 UserObjectPtr (Object 의 WeakPtr) 가 IsValid 한지 체크 한다.
    {
        UE_LOG(LogTemp, Warning, TEXT("CreateUObjectDelegate 의 UserObjectPtr is Not Valid"));
    }
    
    CreateUObjectDelegate.Execute(456);
    UE_LOG(LogTemp, Warning, TEXT("하지만 Execute 해도 Crash 나지 않는다."));
}
  • 객체 삭제 후, 델리게이트의 UserObjectPtr은 여전히 0x10ABC123 값을 가지고 있음 (포인터 값 자체는 변경되지 않음)
  • 0x10ABC123 주소의 상태: 이미 해제된 메모리
  • WeakPtr의 내부 상태: Invalid (참조 카운트로 객체 삭제 감지)
RetValType Execute(ParamTypes... Params) const final
{
    using MutableUserClass = std::remove_const_t<UserClass>;

    // Verify that the user object is still valid.  We only have a weak reference to it.
    checkSlow(UserObject.IsValid());

    // Safely remove const to work around a compiler issue with instantiating template permutations for 
    // overloaded functions that take a function pointer typedef as a member of a templated class.  In
    // all cases where this code is actually invoked, the UserClass will already be a const pointer.
    MutableUserClass* MutableUserObject = const_cast<MutableUserClass*>(UserObject.Get());

    checkSlow(MethodPtr != nullptr);

    return this->Payload.ApplyAfter(MethodPtr, MutableUserObject, Forward<ParamTypes>(Params)...);
}
  • CreateUObjectDelegate 은 위의 Execute 를 실행
    • 여기서 UserObject가 유효한지 체크
    • 만약 객체가 삭제되면 UserObject.Get() 에서 Null 반환
      • 내부적으로 WeakPtr 의 Get 을 호출
      • 계속 들어가보면... 유효한 오브젝트 아니면 nullptr 반환
[[nodiscard]] FORCEINLINE T* Get(bool bEvenIfPendingKill) const
{
    return (T*)WeakPtr.Get(bEvenIfPendingKill);
}

// 계속 들어가보면... 유효한 오브젝트 아니면 nullptr 반환
FORCEINLINE_DEBUGGABLE UObject* Internal_Get(bool bEvenIfGarbage) const
{
    FUObjectItem* const ObjectItem = Internal_GetObjectItem();
    return ((ObjectItem != nullptr) && GUObjectArray.IsValid(ObjectItem, bEvenIfGarbage)) ? (UObject*)ObjectItem->GetObject() : nullptr;
}
  • 따라서 직접적인 댕글링 포인터를 사용할 일이 없음.