Unity의 Fake Null에 대해서
쓰여진:
고쳐진:
문제
Unity에서 개발을 하다 보면 오브젝트가 파괴되었는 지를 확인하기 위해서 null과 비교를 하는 경우가 있다.
Destroy(myObject);
//
if (myObject == null)
Debug.Log("Destroyed");
이건 예상대로 동작한다. 문제는 C#에서 지원하는 null을 체크하는 다른 방법들을 사용했을 때 발생한다.
DestroyImmediate(myObject);
Debug.Log(myObject == null); //True
Debug.Log((myObject as UnityEngine.Object) == null); //True
Debug.Log((myObject as System.Object) == null); //False
Debug.Log(myObject?.name); //MissingReferenceException
Debug.Log((myObject ?? yourObject) == myObject ? false : true); //False
Debug.Log(myObject is null); //False
같은 객체에 대한 null 체크인데 방법에 따라 결과가 다르다. 유니티 오브젝트와의 == null은 파괴된 걸 감지하지만, 나머지는 전부 아직 살아있다고 판단한다. null이면서 null이 아닌 것이다.
이것이 Unity의 Fake Null이다.
원인
이걸 이해하려면 Unity가 C# 코드를 어떻게 실행하는지부터 알아야 한다.
Unity에서 게임 로직은 C#으로 작성하지만, 엔진의 핵심은 C++로 되어 있다.
이 구조 때문에 GameObject, Transform 같은 것들은 실제 데이터는 C++ 네이티브 메모리에 존재하고, C# 쪽에는 그 네이티브 객체를 가리키는 래퍼 객체가 있는 구조가 된다.
[C# 래퍼 객체] -> [C++ 네이티브 객체]
Destroy()를 호출하면 C++ 쪽 네이티브 객체는 파괴되지만, C# 쪽 래퍼 객체는 가비지 컬렉터가 수거할 때까지 메모리에 그대로 남아 있다. C# 관점에서 그 변수는 여전히 유효한 참조를 들고 있는 상태가 된다.
C# 기준으로는 살아있지만 C++에서는 이미 죽은 객체. 이것이 Fake Null이다.
해결책
Unity는 이 문제를 처리하기 위해 UnityEngine.Object에서 == 연산자를 오버로드했다.
public static bool operator ==(Object x, Object y)
{
return CompareBaseObjects(x, y);
}
private static bool CompareBaseObjects(Object lhs, Object rhs)
{
bool flag = (object)lhs == null;
bool flag2 = (object)rhs == null;
if (flag2 && flag)
{
return true;
}
if (flag2)
{
return !IsNativeObjectAlive(lhs);
}
if (flag)
{
return !IsNativeObjectAlive(rhs);
}
return lhs.m_InstanceID == rhs.m_InstanceID;
}
주소가 비어있는지만 보는 게 아니라 래퍼 객체 내부의 네이티브 포인터가 유효한지를 확인해서, 파괴된 객체에 대해 == null이 true를 반환하도록 만든 것이다.
!=과 !도 오버로드 되어 있고 덕분에 != null이나 !myObject 같은 코드도 직관적으로 동작한다.
한계
?., ??, is null은 오버로드할 수 있는 연산자가 아니기 때문에 포인터가 유효한지에 관계없이 주소가 비어있는지만 확인하고, Destroy()가 호출된 이후에도 객체를 null로 인식하지 못한다.
System.Object로의 형변환 이후의 == 연산자를 이용한 비교 역시 유니티가 오버로드한 연산자를 거치지 않기 때문에 마찬가지이다.
대안?
여기까지 글을 읽었으면 일견 Destroy 시점에 래퍼 객체를 가리키는 변수에 null을 넣어주면 모든 문제가 해결되는 게 아닌가 하는 생각이 들 수 있다. 하지만 이것도 문제가 있다.
GameObject myObject = new GameObject();
GameObject yourObject = myObject;
이런 코드를 실행한 후의 참조 관계를 간단한 도식으로 표현하면 이렇게 된다.
[MyObject: 0xABCD] -> [GameObject (0xABCD)] <- [YourObject: 0xABCD]
이런 상태에서 myObject를 Destroy하면서 null로 값을 바꿔주어도 메모리에 있는 값은 그대로 남아있고 그 메모리를 참조하는 다른 변수도 그대로 남아있다.
C# 런타임에는 “이 객체를 참조하는 모든 변수를 찾아라” 같은 기능이 없기 때문에, 흩어져 있는 모든 변수를 null로 바꿔줄 방법이 없다.
그래서 Unity는 어쩔 수 없이 Destroy()를 호출할 때 메모리의 네이티브 객체 주소만 null로 설정하고 연산자를 오버로드해서 주소까지 확인하는 방식을 채택한 것이다.
[MyObject: 0xABCD] -> [GameObject (0xABCD) nativePtr: 0x0000] <- [YourObject: 0xABCD]
이 과정에서 네이티브 객체의 파괴 여부와 상관없이 변수의 값만 확인하는 ?., ??, is null에서는 Fake Null 문제가 발생하게 된다.
결론
//안전한 방법
if (myComponent == null)
if (myComponent != null)
if (!myComponent)
//위험한 방법
myComponent?.DoSomething();
var ourComponent = myComponent ?? yourComponent;
if (myComponent is null)
Unity 오브젝트의 null 여부를 확인할 때는 ==이나 !=, !를 사용하고, ?., ??, is null은 사용하지 말자.
Comments