개념공부/자투리 지식

유니티에서의 null ( feat. fake null )

Cadi 2025. 7. 8. 02:33

 

목차

     

     

    유니티에서의 null 실험

     

    유니티에서는 일반적인 null 개념과는 다른 동작이 적용된다. 이를 흔히 fake null이라고 부른다.

    * 비공식 용어이다.

     

    using UnityEngine;
    
    public class FakeNullTest : MonoBehaviour
    {
        void Start()
        {
            System.Object systemObj = new System.Object();
            Debug.Log($"systemObject: {systemObj}");
            UnityEngine.Object unityObject = new UnityEngine.Object();
            Debug.Log($"unityObject : {unityObject}");
        }
    }
    

     

    위와 같이 코드를 쳐서 확인해 보면 아래와 같은 결과가 나온다.

     

    이는 유니티에서의 null과 System에서의 null의 차이에서 기인한다. 

     

    System.Object 

    • C#의 모든 클래스가 상속받는 가장 기본적인 타입
    • int, string, List<T> 와 같은 일반 C# 타입은 System.Object를 간접적으로 상속

    UnityEngine.Object 

    • Unity 엔진에서 사용되는 모든 객체의 기본 클래스로  GameObject, Component, ScriptableObject , Material, Texture 등 핵심 요소들이 상속받음
    • 씬에 존재하거나, Project  Asset으로 관리되는 모든 Unity 특화 객체들이 상속받아 에셋 및 Scene 객체 관리에 필요한 기능 포함
    • C++ 네이티브 객체와 , C# 관리 객체를 동시에 관리함 

     

    만약 우리가 UnityEngine.Object를 상속받은 오브젝트를 Destroy하면, Unity의 C++ 네이티브 객체는 (DestroyImmediate가 아닌 한) 프레임이 끝날 때 실제로 메모리에서 제거된다.
    그러나 C# 관리 객체는 여전히 힙에 남아 있으며, GC가 모든 참조를 잃은 후에야 수거된다.

     

    using UnityEngine;
    
    public class FakeNullTest : MonoBehaviour
    {
        void Start()
        {
            System.Object systemObj = new System.Object();
            Debug.Log($"systemObject: {systemObj}");
            UnityEngine.Object unityObject = new UnityEngine.Object();
            Debug.Log($"unityObject : {unityObject}");
            Destroy(unityObject);
           Debug.Log($" unityObject is null ?  : {ReferenceEquals(unityObject, null)}");
        }
    }
    

     

     

     

    그렇다면 GC가 할당을 해제한 이후에는 null이라고 뜰까 ?  

     

     

    using System.Collections;
    using UnityEngine;
    
    public class FakeNullTest : MonoBehaviour
    {
        void Start()
        {
            StartCoroutine(TestCoroutine());
        }
    
        IEnumerator TestCoroutine()
        {
            System.Object systemObj = new System.Object();
            Debug.Log($"systemObject: {systemObj}");
    
            UnityEngine.Object unityObject = new UnityEngine.Object();
            Debug.Log($"unityObject: {unityObject}");
    
            Destroy(unityObject);
            Debug.Log($"After Destroy: unityObject == null? {unityObject == null}");
            Debug.Log($"After Destroy: ReferenceEquals(unityObject, null)? {ReferenceEquals(unityObject, null)}");
    
            //unityObject = null;
    
            // GC 강제 호출
            System.GC.Collect();
            System.GC.WaitForPendingFinalizers();
    
            yield return new WaitForSeconds(1f);
    
            Debug.Log($"After GC Collect");
            Debug.Log($"ReferenceEquals(unityObject, null)? {ReferenceEquals(unityObject, null)}");
        }
    }

     

     

    그래도 null이라고 나오지 않는다. 아직 unityObject를 사용하는 참조가 남아 있기 때문에 GC에서 할당을 해제하지 않기 때문이다.

    따라서 null이라는 결과값을 얻기 위해서는 null을 직접 대입해 주는 수밖에 없다. 

     

    //unityObject = null;

    위 주석을 해제해 다시 결과를 보면 당연하게도 true로 나온다.

     

    Unity에서 null을 사용하는 방법 

     

    실험 결과를 통해 유니티의 null은 일반적인 null과 다르다는 것을 알았다. 

     

     

    public static implicit operator bool(Object exists)
    {
      return !Object.CompareBaseObjects(exists, (Object) null);
    }

     

    private static bool CompareBaseObjects(Object lhs, Object rhs)
    {
      bool flag1 = (object) lhs == null;
      bool flag2 = (object) rhs == null;
      if (flag2 & flag1)
        return true;
      if (flag2)
        return !Object.IsNativeObjectAlive(lhs);
      return flag1 ? !Object.IsNativeObjectAlive(rhs) : lhs.m_InstanceID == rhs.m_InstanceID;
    }

     

    UnityEngine.Object는 위와 같은 방식으로 null인지 체크한다. 

    1. 들어온 오브젝트들을 System.Object로 형변환한 후 null인지 체크한다. 

    2. 그리고 둘 다 null이라면 같은 것이므로 true를 반환한다.

    3. 만일 lhs가 null이 아니고 rhs가 null이라면, 네이티브 오브젝트가 살아 있는지를 검사한다.
    네이티브 객체가 살아 있으면 null이 아니므로 CompareBaseObjects는 false를 반환한다.
    반대로 네이티브 객체가 이미 파괴되어 살아있지 않다면, null 취급되어 true를 반환한다.

    4. 3번의 반대이다. 

    5. 마지막으로 lhs와 rhs가 둘 다 UnityEngine.Object 인스턴스고 null이 아니라면 고유 ID를 비교해 반환한다. 

     

     

    이처럼 문제가 발생할 소지가 다분하기 때문에 유니티에서는 == 연산자를 오버라이드해서 Fake null을 잡는다.

            public static bool operator ==(InstanceID left, InstanceID right) => left.Equals(right);

     

       public override bool Equals(object other)
        {
          Object rhs = other as Object;
          return (!(rhs == (Object) null) || other == null || other is Object) && Object.CompareBaseObjects(this, rhs);
        }

     

     

    https://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/UnityEngineObject.bindings.cs

     

    UnityCsReference/Runtime/Export/Scripting/UnityEngineObject.bindings.cs at master · Unity-Technologies/UnityCsReference

    Unity C# reference source code. Contribute to Unity-Technologies/UnityCsReference development by creating an account on GitHub.

    github.com

    * 참고 : ReferenceEquals System.Object 레벨 비교이므로 fake null을 감지하지 못한다. 
                Destroy된 Unity 오브젝트도 여전히 C# 객체가 살아 있으면 ReferenceEquals로는 null이 아니다. 

     

     

     

    버그가  발생하는 경우들

     

    1. Destroy 이후에도 여전히 참조가 남아있고, UnityEngine.Object 타입이 아닌 다른 타입으로 캐스팅되거나 object로 박싱되면 의도와 다르게 비교가 동작할 수 있다

     

    UnityEngine.Object obj = new GameObject();
    Object.Destroy(obj);
    
    // 아래처럼 캐스팅하면 비교가 꼬임
    object boxed = obj;
    
    if (boxed == null)
    {
        Debug.Log("null이다!");
    }
    else
    {
        Debug.Log("null 아니다!");
    }

     

    2.  == 연산자는 오버라이드되어 fake null을 감지할 수 있지만, object로 박싱되거나 null 병합 연산자(??)를 사용하면 System.Object 기준 비교가 일어나기 때문에 주의해야 한다.
    특히 is null 패턴은 Unity의 == 연산자를 호출하므로 비교적 안전하지만, 여전히 박싱된 상태에서는 의도와 다른 결과가 나올 수 있다. 

     

     

    문제점

     

    위와 같은 문제가 발생하면 두 가지 문제점이 있다.

     

    1. 성능상 문제

     

    오버로딩된 '==' 연산자는 C++ 네이티브 객체의 상태를 검사하기 위해 매번 네이티브 함수를 호출할 수 있기 때문에, 수많은 비교가 반복될 때 성능 저하가 발생할 수 있다.

     

    2. 메모리 누수 

     

    C# 객체는 단순히 네이티브 객체의 래퍼이기 때문에, 겉으로 보기엔 메모리 누수 위험이 없어 보일 수 있다.
    그러나 C# 객체가 큰 데이터를 참조하고 있고, static 변수나 다른 오브젝트에 연결되어 있다면, Destroy 이후에도 GC가 수거하지 않아 메모리 누수가 발생할 수 있다.

     

     

    해결 방안

     

    가장 좋은 방법은 Destroy 한 후, null을 직접적으로 할당해 주는 것이다.

    Destroy(myObject);
    myObject = null;

    그리고 GC가 메모리 할당을 자연스럽게 해제하게 된다면 위와 같은 메모리 누수 문제는 발생하지 않는다. 

     

    비교를 할 때에는 오버라이딩되어 있는 == 으로 비교하거나, bool 캐스팅으로 해야 한다.

    if (myObject == null)
    {
        // Destroy되었거나, 실제 null
    }

     

    if (myObject)
    {
        // 살아있음
    }

     

     


     

    참고 : 

    https://velog.io/@zer0vin/Unity-Fake-Null

     

    [Unity] 유니티 Fake Null

    타 프로젝트를 받아서 코드 최적화를 진행한 적이 있는데해당 프로젝트에서는 모든 오브젝트의 null 검사를 IsNull() 이라는 함수로 하고 있었다.그런데 이상한 점이 있었다.두 값이 다르게 나오는

    velog.io

    https://tearsinrain.tistory.com/8

     

    Unity의 fake null 문제

    유니티 엔진의 구조 UnityEngine.Object를 == null로 비교해서 true가 떨어졌다고 진짜 null인 것은 아니다. 이 문제는 유니티 개발자들에게는 잘 알려져 있는 문제지만, 워낙 직관적이지 않은 문제이기

    tearsinrain.tistory.com