Unity 엔진과 Null check에 대한 이야기

들어가며

최근 간단하게 유니티3D에서 재활치료를 하고 있었습니다. 앵그리 버드 클론을 만들면서, 목표 지점에 카메라를 부드럽게 이동시키고 싶었습니다. 그런데 목표 지점이 null인 경우를 체크하고 싶었습니다. 그래서…

public GameObject pointOfInterest;
if (defaultObject == null)
{
    pointOfInterest = GameObject.Find("Slingshot");
} else {
    pointOfInterest = defaultObject;
}

이런 간단한 코드를 이용해서 유니티 GameObject 클래스인 pointOfInterestnull인 경우에 대해 적당히 처리를 해주었습니다. 많이들 사용하는 코드죠.

그런데 문득 “C# Nullable”이 생각났습니다. C# 6.0에 추가된 nullable?? 연산자를 이용하면 조금 더 세련된 처리가 가능하겠다 싶었습니다. 그러면 이런 느낌이 되겠죠.

public GameObject? pointOfInterest;

pointOfInterest = defaultObject ?? GameObject.Find("Slingshot");

아름답지 않습니까. 여러 줄로 처리할 것을 3항 연산자마냥 간결하게 처리하다니. 그런데 ResharperIntellisense에서 경고를 띄워줍니다. Resharper에서 UnityObject는 null propagation을 쓰지 말라고 경고하는 모습 네. 유니티에서 만든 오브젝트에서는 ?? 연산자를 쓸 수 없다네요. 왜일까요?

유니티에서 null은 null이 아니다

일단 C#에서 null이 무엇인지 생각해봅시다. MSDN에 따르면, null 키워드는 어떤 오브젝트에도 레퍼런스되지 않는 레퍼런스, 아무것도 가리키지 않는 무언가를 말한다고 합니다. 클래스가 아닌 value는 null이 될 수 없지만, Nullable을 이용하면 가능하다고 합니다. 우리의 관심사는 Unity에서 만들어 사용하는 GameObject와 같은 클래스들입니다.

이런 오브젝트들은 사실 진짜 C# 클래스가 아닙니다. 유니티가 내부적으로 C++로 돌아간다는 사실을 알고 있나요? 이런 C# 오브젝트는 C++ 오브젝트의 래퍼(Wrapper)입니다. Destroy()등을 이용하면 네이티브 오브젝트는 사라지지만 래퍼 오브젝트는 가비지 컬렉션이 되기 전까지 남아 있게 됩니다. 유니티에서는 이런 상태를 Fake null이라고 부르고 있습니다.

더 상세한 설명을 원한다면 여기를 참조해주세요.

우리가 여기서 알아야 할 점은 크게 3가지입니다.

  1. 유니티 엔진에서는 그냥 일반 C# 오브젝트처럼 null 체크를 해 버리면, C++ 객체는 없지만 래퍼만 남아 있는 상황이 있을 수 있습니다. 이를 위해 UnityEngine.Object에서는 ==, !=를 오버로딩해서 네이티브 객체의 존재 유무까지 확인하게 됩니다.
  2. 그런데 이렇게 되면 null을 등호, 부등호로 체크할 때마다 네이티브 객체의 존재 유무까지 체크해야 하니, 비용이 많이 들게 됩니다.
  3. C# Nullable과 ??이 들어가면 문제가 더 커집니다. C# Nullable과 ??는 UnityEngine.Object에서 기껏 만들어 둔 오버로딩을 이용하지 않고, 직접 null check를 하게 됩니다. 이 때 Destroy되었지만 가비지 컬렉션이 이루어지지 않았다면 우리의 충실한 런타임은 “null이 아니다”라는 결과를 낼테고, 정작 내부에 접근을 하려고 하면 불가능할 것입니다. 이미 Destroy되었으니까요.

그런 이유로 Resharper에서는 ??을 못 쓰게 막아둔 것입니다. 예전에 “유니티는 C++을 쓴대!”라고 할 때는 감흥이 없었지만, 이게 이런 식으로 돌아오다니 좀 놀랍군요.

그러면 어떻게 해야 하는가?

우리는 null이 진짜 null이 아닐 수 있고, Destroy()를 한다고 오브젝트가 바로 null이 되지 않을 수 있음을 알았습니다.

일단 null check에 대해서, 제가 참조한 블로그에서는 다음과 같은 방식을 제안합니다.

  1. 유니티 오브젝트는 암시적으로 bool로 변환되는 것을 이용합니다.
     if (gameObject)
    
  2. Destroy()한 뒤에는 null을 대입해줍니다. 정리하면 아래와 같이 됩니다.
    if (customMonoBehavior) {
     customMonoBehavior.enabled = false;
     Destroy(customMonoBehavior);
     customMonoBehavior = null;
    }
    

    사실 gameObject == null과 구현상 차이는 없습니다만, 위 블로그에서는 다음과 같은 이유로 이 방법을 추천하고 있습니다. 일반적인 오브젝트에 대해서는 null을 일반적인 방법으로 체크해도 되지만, implicit bool conversion을 하지 않기 때문에 컴파일 단계에서 오류가 납니다. 유니티 오브젝트는 bool으로 implicit conversion이 되지만, null 병합 연산자를 사용할 수 없습니다. 둘의 차이를 확실히 해 두라는 의도로 보입니다. 일단 취미 개발 수준에서는 이 정도면 적당한 것 같습니다.

마지막으로 하나만 더

위에서 참조했던 블로그의 2번째 글에서는 다음과 같은 방법도 제안합니다. 통상적인 경우(파괴하지 않는 경우, 대입이 되었는지를 확인하고 싶을 때)에는 Object.ReferenceEquals()을 이용해 equality check를 하면 속도를 올릴 수 있다고 합니다.

중요한 것은 (추측컨대) null 병합 연산자(??) 등을 UnityObject에서 쓰지 않는 것을 권장하는 이유입니다. 바로 인스펙터에 노출되었지만 대입되지 않은 유니티 오브젝트는 위에서 말한 fake null 상태로 시작한다는 것입니다. 많은 경우에 오브젝트를 public으로 선언한 뒤, 코드를 이용해 Start()에서 “null이면 무언가를 찾아 대입하라!” 같은 로직을 많이 보았을 것입니다. 위 블로그 글에 따르면, 정확히 그런 경우에 문제가 생긴다고 합니다. 네.

결국 결론은 유니티가 잘못했고, 우리들은 이런 한계점을 잘 이해해서 각 경우에 무슨 일이 일어나고, 어떻게 우회해야 하는지 잘 공부해야겠습니다.

*****
긍정적인 영향을 주는 사람이 됩시다
Served using home raspberry pi 3 B+
☕ Pudhina theme by Knhash 🛠️