Unity의 Fake null을 알고 있는가?

Fake null이란 Unity에서 같은 Object를 UnityObject로서 null 체크를 했을 때와

System.Object로서 null체크를 했을 때, 값이 다르게 나오는 현상을 말한다.


Fake Null이 정확히 어떤 것인지 파악하기 위해, 아래에 간단한 Unity 프로그래밍 퀴즈를 13개 준비했다.



🎓 간단한 Unity Quiz Time


아래 그림처럼, Scene에 TestGameObject라는 빈 GameObject를 만들고,

QuizClass라는 컴포넌트를 추가했다.

Quiz Class를 가지고 있는 GameObject

QuizClassQuizObject라는 public GameObject 변수를 가지고 있는데,

인스펙터에서 이 QuizObject에 값을 넣어주지 않았다고 하자.

이 상황을 머릿속에 기억해 두고, 다음 질문에 답하여라


Quiz 0️⃣0️⃣

만약 QuizClass의 내용이 다음과 같다면, 아래 코드를 실행했을 때,

에러 없이 정상적으로 실행되는가? (using문 생략)

public class QuizClass : MonoBehaviour
{
    public GameObject QuizObject;

    //=====================================================
    // Quiz 0 . 에러없이 정상적으로 실행될까? O / X
    //=====================================================
    private void Start()
    {
        if ( QuizObject == null )
        {
            QuizObject = this.gameObject;
        }

        Debug.Log(QuizObject.name);
    }
}
Quiz 0 정답보기 정답은 ... O


Quiz 0️⃣1️⃣

만약 QuizClass의 내용이 다음과 같다면, 아래 코드를 실행했을 때,

에러 없이 정상적으로 실행되는가? (using문 생략)

public class QuizClass : MonoBehaviour
{
    public GameObject QuizObject;

    //=====================================================
    // Quiz 1 . 에러없이 정상적으로 실행될까? O / X
    //=====================================================
    private void Start()
    {
        if ( QuizObject is null )
        {
            QuizObject = this.gameObject;
        }

        Debug.Log(QuizObject.name);
    }
}
Quiz 1 정답보기 정답은 ... X


Quiz 0️⃣2️⃣

만약 QuizClass의 내용이 다음과 같다면, 아래 코드를 실행했을 때,

에러 없이 정상적으로 실행되는가? (using문 생략)

public class QuizClass : MonoBehaviour
{
    public GameObject QuizObject;

    //=====================================================
    // Quiz 2 . 에러없이 정상적으로 실행될까? O / X
    //=====================================================
    private void Start()
    {
        if ( object.ReferenceEquals(QuizObject, null) )
        {
            QuizObject = this.gameObject;
        }

        Debug.Log(QuizObject.name);
    }
}
Quiz 2 정답보기 정답은 ... X


Quiz 0️⃣3️⃣

만약 QuizClass의 내용이 다음과 같다면, 아래 코드를 실행했을 때,

에러 없이 정상적으로 실행되는가? (using문 생략)

public class QuizClass : MonoBehaviour
{
    public GameObject QuizObject;

    //=====================================================
    // Quiz 3 . 에러없이 정상적으로 실행될까? O / X
    //=====================================================
    private void Start()
    {
        QuizObject = QuizObject ? QuizObject : this.gameObject;

        Debug.Log(QuizObject.name);
    }
}
Quiz 3 정답보기 정답은 ... O


Quiz 0️⃣4️⃣

만약 QuizClass의 내용이 다음과 같다면, 아래 코드를 실행했을 때,

에러 없이 정상적으로 실행되는가? (using문 생략)

public class QuizClass : MonoBehaviour
{
    public GameObject QuizObject;

    //=====================================================
    // Quiz 4 . 에러없이 정상적으로 실행될까? O / X
    //=====================================================
    private void Start()
    {
        QuizObject = QuizObject ?? this.gameObject;

        Debug.Log(QuizObject.name);
    }
}
Quiz 4 정답보기 정답은 ... X


Quiz 0️⃣5️⃣

만약 QuizClass의 내용이 다음과 같다면, 아래 코드를 실행했을 때,

에러 없이 정상적으로 실행되는가? (using문 생략)

public class QuizClass : MonoBehaviour
{
    public GameObject QuizObject;

    //=====================================================
    // Quiz 5 . 에러가 발생할까? O / X
    //=====================================================
    private void Start()
    {
        if(!QuizObject)
        {
            QuizObject = this.gameObject;
        }

        Debug.Log(QuizObject.name);
    }
}
Quiz 5 정답보기 정답은 ... O


Quiz 0️⃣6️⃣

만약 QuizClass의 내용이 다음과 같다면, 아래 코드를 실행했을 때,

에러 없이 정상적으로 실행되는가? (using문 생략)

public class QuizClass : MonoBehaviour
{
    public GameObject QuizObject;

    //=====================================================
    // Quiz 6 . 에러가 발생할까? O / X
    //=====================================================
    private void Start()
    {
        QuizObject = new GameObject("GO.6");
        Destroy(QuizObject);

        Debug.Log(QuizObject.name);
    }
}
Quiz 6 정답보기 정답은 ... O


Quiz 0️⃣7️⃣

만약 QuizClass의 내용이 다음과 같다면, 아래 코드를 실행했을 때,

에러 없이 정상적으로 실행되는가? (using문 생략)

public class QuizClass : MonoBehaviour
{
    public GameObject QuizObject;

    //=====================================================
    // Quiz 7 . 에러가 발생할까? O / X
    //=====================================================
    private void Start()
    {
        QuizObject = new GameObject("GO.7");
        DestroyImmediate(QuizObject);

        Debug.Log(QuizObject.name);
    }
}
Quiz 7 정답보기 정답은 ... X


Quiz 0️⃣8️⃣

만약 QuizClass의 내용이 다음과 같다면, 아래 코드를 실행했을 때,

에러 없이 정상적으로 실행되는가? (using문 생략)

public class QuizClass : MonoBehaviour
{
    public GameObject QuizObject;

    //=====================================================
    // Quiz 8 . 에러가 발생할까? O / X
    //=====================================================
    private void Start()
    {
        QuizObject = new GameObject("GO.8");
        Destroy(this.gameObject);

        Debug.Log(QuizObject.name);
    }
}
Quiz 8 정답보기 정답은 ... O


Quiz 0️⃣9️⃣

만약 QuizClass의 내용이 다음과 같다면, 아래 코드를 실행했을 때,

에러 없이 정상적으로 실행되는가? (using문 생략)

public class QuizClass : MonoBehaviour
{
    public GameObject QuizObject;

    //=====================================================
    // Quiz 9 . 에러가 발생할까? O / X
    //=====================================================
    private void Start()
    {
        QuizObject = new GameObject("GO.9");
        DestroyImmediate(this.gameObject);

        Debug.Log(QuizObject.name);
    }
}
Quiz 9 정답보기 정답은 ... O


Quiz 1️⃣0️⃣

만약 QuizClass의 내용이 다음과 같다면, 아래 코드를 실행했을 때,

에러 없이 정상적으로 실행되는가? (using문 생략)

public class QuizClass : MonoBehaviour
{
    public GameObject QuizObject;

    //=====================================================
    // Quiz 10 . 에러가 발생할까? O / X
    //=====================================================
    private void Start()
    {
        QuizObject = new GameObject("GO.10");
        DestroyImmediate(QuizObject);
        
        if(QuizObject == null)
        {
            QuizObject = this.gameObject;
        }

        Debug.Log(QuizObject.name);
    }
}
Quiz 10 정답보기 정답은 ... O


Quiz 1️⃣1️⃣

만약 QuizClass의 내용이 다음과 같다면, 아래 코드를 실행했을 때,

에러 없이 정상적으로 실행되는가? (using문 생략)

public class QuizClass : MonoBehaviour
{
    public GameObject QuizObject;

    //=====================================================
    // Quiz 11 . 에러가 발생할까? O / X
    //=====================================================
    private void Start()
    {
        QuizObject = new GameObject("GO.11");
        DestroyImmediate(QuizObject);
        
        if(QuizObject is null)
        {
            QuizObject = this.gameObject;
        }

        Debug.Log(QuizObject.name);
    }
}
Quiz 11 정답보기 정답은 ... X


Quiz 1️⃣2️⃣

만약 QuizClass의 내용이 다음과 같다면, 아래 코드를 실행했을 때,

에러 없이 정상적으로 실행되는가? (using문 생략)

public class QuizClass : MonoBehaviour
{
    public GameObject QuizObject;

    //=====================================================
    // Quiz 12 . 에러가 발생할까? O / X
    //=====================================================
    private void Start()
    {
        QuizObject = new GameObject("GO.12");
        DestroyImmediate(QuizObject);
        
        if( object.ReferenceEquals(QuizObject, null) )
        {
            QuizObject = this.gameObject;
        }

        Debug.Log(QuizObject.name);
    }
}
Quiz 12 정답보기 정답은 ... X



👻 FAKE NULL

문제를 많이 맞추었는가? 위 문제를 해결하기 위해서는, Fake null이라는 현상을 이해해야 한다.

Unity에서 만든 객체는 UnityEngine.Object를 상속 받고 있으며,

모든 Unity 객체들의 부모인 UnityEngine.Object 또한 C# 클래스 이므로, System.object를 상속받고 있다.


왜 Unity에서는 이렇게 2가지 object를 허용하도록 했는가?

유니티 엔진은 C++로 만들어져있기 때문이다.

다만 유니티에서 .NET API를 외부로 노출해주었기 때문에, 개발자는 C++대신 C#을 이용해 개발을 할 수 있다.

참고 자료 : Is Unity Engine written in Mono/C#? or C++

즉, 개발자가 만든 실제 객체는 C++로 구현되어있지만, C#에서도 접근이 가능해야 하기 때문에,

이 과정을 처리하려는 목적으로 UnityEngine.Object를 만든것이다.


여기서 살펴볼 점은, C++ 객체를 래핑한 C# 객체인 UnityEngine.Object

null check를 할 때, 직접 실제 C# 객체 뿐 아니라 native c++ object또한 null인지 아닌지 판단하도록

== 연산자와 != 연산자를 정의하고 있다는 점이다.


아래는 UnityTechnologies/UnityCsRefercne에서 확인한 operator를 정의한 부분이다.

public static bool operator==(Object x, Object y) { return CompareBaseObjects(x, y); }
public static bool operator!=(Object x, Object y) { return !CompareBaseObjects(x, y); }

==연산자와 !=연산자를 호출했을 때, CompareBaseObjects 메서드를 호출하고 있는 모습을 볼 수 있다.

아래는 같은 스크립트에서 확인 가능한 CompareBaseObjects 메서드 전문이다.

static bool CompareBaseObjects(UnityEngine.Object lhs, UnityEngine.Object rhs)
{
    bool lhsNull = ((object)lhs) == null;
    bool rhsNull = ((object)rhs) == null;

    if (rhsNull && lhsNull) return true;

    if (rhsNull) return !IsNativeObjectAlive(lhs);
    if (lhsNull) return !IsNativeObjectAlive(rhs);

    return lhs.m_InstanceID == rhs.m_InstanceID;
}

코드를 자세히 살펴보면 C# 에서 null을 체크하는 부분도 있고, C++에서 null을 체크하는 부분 (IsNativeObjectAlive)도 있는 모습을 볼 수 있다.


Fake null은 위에서 기술한 UnityEngine.ObjectC++ 객체를 래핑한 C# 객체라는 특징때문에 발생하는데,

UnityEngine.Object의 C++ 객체는 메모리에서 해제되었으나 C# 객체는 아직 메모리에서 해제 되지 않은 상황을 말한다.

대표적으로는 다음 경우들이 있다.

🔸 변수의 접근 한정자를 Public으로 설정하거나, [SerializeField]로 선언하고, 값을 초기화해주지 않은 경우

🔸 Destroy() 또는 DestroyImmediate() 메서드를 호출해서 객체를 제거하는 경우


예를 들어, Destroy() 메서드로 객체를 삭제한다고 하면, 아래 처럼 처리 된다.

오브젝트 설명
Native C++ Object 메모리 해제. 바로 삭제 됨.
UnityEngine.Object 메모리 해제 되지 않음. 나중에 가비지 콜렉터가 해제 함.

C# 에서 객체를 메모리에서 해제하는 주체는 GC밖에 없기 때문에, C++ 객체는 즉시 해제가 가능해도 C# 객체는 남아있게 된다.


이런 상태를 Fake null 상태라고 하며, 객체가 이 상태에 있을땐 조심해서 Null 체크를 진행해야 한다.



🔎 UNITY NULL CHECK

gameObject 라는 객체가 Fake null인 상태에서, gameObject == null 이라는 코드로 검사를 한다고 하자.

이 경우에는 UnityEngine.Object== 연산자를 구현 내용에 따라

C# 객체가 비어있는지 비어있는지 확인하고, C++객체가 비어있는지를 모두 확인하게 된다.

처음에는 C# 객체를 서로 비교한다.

좀 더 자세히 설명하면, 처음에는 C# 객체로써 gameObjectnull을 비교하기 시작한다.

비교 연산자의 우측항인 null은 당연히 비어있고, 좌측 항인 gameObject(Fake null 상태라)  비어있지 않으므로

비어있지 않은 gameObject에 대해 C++ 객체가 비어있는지 추가로 확인하게 된다.

C++ 객체가 비어있는지 확인한다.

확인 결과, C++ 객체가 비어있다는 사실을 파악하게 되었으므로,

gameObject == null의 결과는 true라고 리턴하게 된다.


그럼, gameObject 라는 객체가 Fake null인 상태에서,

gameObject is null 이라는 코드나 object.ReferenceEquals(gameOjbect,null)이라는 코드로 검사를 한다면?

UnityEngine.Object에는 is 연산자가 별도로 정의되어 있지 않았기때문에, C# 객체만을 비교하게 된다.

object.ReferenceEquals(gameOjbect,null) 또한 별도로 정의되지 않았기에 주어진 두 객체를 C# 객체로 간주하고 비교를 한다.

C++ 객체가 비어있는지 확인한다.

따라서, Fake null 상태에선 C# 객체간 비교로는 두 객체가 같지 않다고 판단하게 된다.

gameObject is nullobject.ReferenceEquals(gameOjbect,null)의 결과는 false가 된다.



이런 특징들에서 알 수 있듯이, is  연산자나 ReferenceEquals  메서드는 UnityEngine.Object  의 Null Check에는 적합하지 않다.

오히려 == 이나 != 을 사용하는것이 바람직하다는 것을 알 수 있다.


그래서 실제로 프로그래밍을 하는 중, UnityEngine.Object들에 대해 해당 연산자 / 메서드를 사용하면, IDE가 알려주고 있는 모습을 볼 수 있다.

Null check시 pattern matching(is 연산자)을 사용하지 말라는 경고

좀 더 명확한 설명은 다음과 같다.

UNT0029에 대한 상세한 설명

Unity overrides the null comparison operator for Unity objects, which is incompatible with pattern matching with null

Unity는 Unity Object에 대한 null 비교 연산자를 재정의합니다. 이는 null과의 pattern matching에는 호환되지 않습니다.


UnityEngine.Object에는 이런식으로 override된 연산자들이 더 존재한다.

전부 찾아본 것은 아니지만, 구분하자면 다음과 같다.

⭕ override 된 연산자 목록 ❌ override 되지 않은 연산자 / 메서드 목록
==   !=   ?   묵시적 변환 (bool) is   object.ReferenceEquals   ??   Equals
🔍 묵시적 변환 (bool) 이란...?
정확한 명칭은 아닐 수 있는데, 다음과 같은 코드를 지칭한다.

// gameObject가 null이 아닐 때...
if( gameObject )
{
    // Do Something
}


따라서, Unity에서 Null Check를 할 땐 override 된 연산자만 사용하도록 해야 할 것이다!




⏰ PERFORMANCE

라고 할줄을 아셧습니까?

그러나, 꼭 그렇지만은 않다.

왜냐하면, 이렇게 override된 연산자들은 너무 느리다는 단점이 있기 때문이다.

그도 그럴것이, override 된 메서드들은 기본적으로 C# 객체C++ 객체를 모두 비교하기 때문에,

성능적으로 차이가 날 수 밖에 없는 것이다.


얼마나 차이가 나는지 직접 확인해보기 위해, 테스트 코드를 작성하여 돌려보았다.

각 Null Check 방식을 1억번 연산했을 때, 시간이 얼마나 소비되었는지 ms 단위로 확인해보았다.

📊 성능 측정 테스트 코드 확인하기
using System.Diagnostics;
using UnityEngine;
public class PerformanceTester : MonoBehaviour { public GameObject QuizObject; // Null Check를 위해 사용될 GameObject
private readonly int count = 100000000; // 퍼포먼스 측정을 위해 연산을 반복할 횟수
//===================================================================================== // == 연산자를 이용해서 Null Check 하는 메서드F //===================================================================================== private void NullCheck_EqualOperation_UnityEngineObject( UnityEngine.Object obj ) { for ( int i = 0 ; i < count ; i++ ) { if ( obj == null ) { } } }
//===================================================================================== // == 연산자를 이용해서 Null Check 하는 메서드 //===================================================================================== private void NullCheck_EqualOperation_SystemObject( object obj ) { for ( int i = 0 ; i < count ; i++ ) { if ( obj == null ) { } } }
//===================================================================================== // if문 안에 직접 넣어서 Null Check 하는 메서드 //===================================================================================== private void NullCheck_JustIf_UnityEngineObject( UnityEngine.Object obj ) { for ( int i = 0 ; i < count ; i++ ) { if ( obj ) { } } }
//===================================================================================== // is 연산자를 이용해서 Null Check 하는 메서드 //===================================================================================== private void NullCheck_Is_UnityEngineObject( UnityEngine.Object obj ) { for ( int i = 0 ; i < count ; i++ ) { if ( obj is null ) { } } }
//===================================================================================== // is 연산자를 이용해서 Null Check 하는 메서드 //===================================================================================== private void NullCheck_Is_SystemObject( object obj ) { for ( int i = 0 ; i < count ; i++ ) { if ( obj is null ) { } } }
//===================================================================================== // object.ReferenceEquals를 이용해서 Null Check 하는 메서드 //===================================================================================== private void NullCheck_ReferenceEquals_SystemObject( object obj ) { for ( int i = 0 ; i < count ; i++ ) { if ( object.ReferenceEquals(obj, null) ) { } } }
//===================================================================================== // Stopwatch를 이용해서, 총 연산에 들어간 milli second 측정 //===================================================================================== void Start() { var stopwatch = new Stopwatch();
// == 비교 연산자의 성능 측정 stopwatch.Start(); NullCheck_EqualOperation_UnityEngineObject(QuizObject); stopwatch.Stop(); print("== 연산자 <color=cyan>(UnityEngine.Object)</color> : " + $"{stopwatch.ElapsedMilliseconds}ms");
// == 비교 연산자의 성능 측정 stopwatch.Start(); NullCheck_EqualOperation_SystemObject(QuizObject); stopwatch.Stop(); print("== 연산자 <color=yellow>(System.Object)</color> : " + $"{stopwatch.ElapsedMilliseconds}ms");
stopwatch.Reset();
// NullCheck_Is stopwatch.Start(); NullCheck_Is_UnityEngineObject(QuizObject); stopwatch.Stop(); print("is 연산자 <color=cyan>(UnityEngine.Object)</color> : " + $"{stopwatch.ElapsedMilliseconds}ms");
// NullCheck_Is stopwatch.Start(); NullCheck_Is_SystemObject(QuizObject); stopwatch.Stop(); print("is 연산자 <color=yellow>(System.object)</color> : " + $"{stopwatch.ElapsedMilliseconds}ms");
stopwatch.Reset();
// NullCheck_JustIf stopwatch.Start(); NullCheck_JustIf_UnityEngineObject(QuizObject); stopwatch.Stop(); print("just if 메서드 <color=cyan>(UnityEngine.Object)</color> : " + $"{stopwatch.ElapsedMilliseconds}ms");
stopwatch.Reset();
// NullCheck_object.ReferenceEquals stopwatch.Start(); NullCheck_ReferenceEquals_SystemObject(QuizObject); stopwatch.Stop(); print("object.ReferenceEquals 메서드 <color=yellow>(System.Object)</color> : " + $"{stopwatch.ElapsedMilliseconds}ms"); } }


아래는 측정한 결과이다.

각 Null Check 방식별 퍼포먼스 차이

좀 더 명확하게 보기 위해 그래프로 표현하면, 아래와 같다.

연산자 / 메서드들 간 연산 속도 비교

한 눈에 보기에도 큰 차이가 나는 것을 볼 수 있다.


따라서, Singleton과 같이 한 번 생성되면, 파괴되지 않는 객체의 null 검사를 할 때에는

isobject.ReferenceEquals, Equals, 또는 ?? 연산자를 사용하는 것이 성능상 바람직하다.

앞서 설명했듯이 Fake null이란, 객체가 파괴되는 행위등에 의해 C++ 객체는 사라졌으나 C# 객체는 남아있는 상황을 말하는데,

이 상황에서 개발자가 의도하지 않은 동작을 피하기 위해 UnityEngine.Object 정의한 연산자를 사용해야 하는 것이다.

이는 다시 말하면, 그런 상황을 피할 수 있다는 것을 보장 할 수 있을땐

사실상 isobject.ReferenceEquals, Equals, 또는 ?? 연산자를 사용하는 것이 더 이득이라는 것이다.



따라서 Null Check 방식 역시, 잘 알아두고 상황에 맞게 적절한 방법을 사용하는 것이 중요하다는 것을 알 수 있다.




📚 REFERENCE

Blog / Site Post
Unity Blog Custom == operator, should we keep it?
진우의 혼잣말 하는 블로그 Unity null 확인 방법(약간의 최적화편)
EveryDay.DevUp [ Unity ] Fake Null
0시0분 [ Unity ] 유니티 Fake Null
평생 공부 블로그 : Today I Learned Unity C# > 유니티 오브젝트의 fake null
더블즈비의 기묘한 공방 Unity Null Check할 때 성능을 개선할 수 있는 방법
WHY’S BLOG [ C# ] null을 비교하기위한 여정2, is 와 FakeNull
Unity Discussions Is Unity Engine written in Mono/C#? or C++