Unity.Object의 null 체크에 대해서 흔히들 겪는 딜레마인데,


일단 기본적으로 네이티브 C# 클래스가 아닌 Unity.Object를 상속하는 클래스를 작성할 경우


== operator가 유니티 엔진에서 오버라이드한 오퍼레이터를 사용하는걸 알고 있을겄이다.


그리고 대부분의 IDE에서 Unity.Object의 null 체크에 대해서 비용이 큰 작업이라고 경고를 띄운다.


그 유명한 Unity 의 fake null과 관련된 내용인데, 검색하면 자세하게 나오므로 여기에선 적지 않는다.


하여튼 우리가 UnityEngine.Object에 대해서 접근할때 NullReference 오류를 띄우지 않기 위해선


접근하기 전에 == 를 이용한 null 체크를 해야한다.


다만, 앞서 말했다시피 UnityEngine.Object의 == operator를 이용한 null 체크는 고비용의 작업이다.


특정 트리거에 의해서 간헐적으로 실행하는 경우에는 큰 문제가 없지만


Update와 같이 지속적으로 체크해야하고, 해당 작업을 해야하는 인스턴스가 많을수록 신경쓰이는 부분이기도 하다.




그렇다면 그런 상황에서 Null 체크를 피하는 방법은 뭐가 있을까?


대표적인 한가지는 필요한 데이터"만" 공유하는 식이다.


예를 들어 한 Transform의 위치를 추적하는 Transform이 있다고 치자


이 경우 사실 추적하는 Transform은 추적할 Transform의 컴포넌트는 필요가 없다.


다만 추적할 위치 데이터가 필요할 뿐이니


추적당하는 대상이 지속적으로 Position을 어딘가에 공유하면


추적자는 그 공유된 Position만 따라서 이동시키면 된다.


다만 이 경우 공유해야할 데이터가 늘어날수록 구현이 번거롭고


공유할 데이터를 관리할 매니징 클래스가 필요하거나, static한 필드를 둬야한다던가 하는 문제등으로


코드가 지저분해질 가능성이 크다.


무엇보다 데이터의 공유가 아니라, 컴포넌트간에 직접적인 참조를 해야하는 경우엔 쓸 수가 없다.



다른 하나는, 명시적으로 객체의 파괴 상태를 전달하는 방법이다.


다행히 유니티의 경우 OnDestory에서 Destory시 호출되는 이벤트가 있다.


A라는 컴포넌트의 OnDestory에서 A를 참조하고 있는 대상에게 알림을 주면 그만이다.


참조 대상을 별도의 콜렉션에 등록/해제해서 관리하도록 만들어도 좋고, 이벤트를 구독하도록 만들어도 좋다.


위의 데이터만 공유하는 방법과 마찬가지로 별도의 구현은 필요하지만, 객체를 직접 참조할 수 있기 때문에


참조시 구독/해제만 제대로 구현한다면 참조할 필드나 메서드가 늘어나도 추가적인 작업없이 유연하게 쓸 수 있다는게 장점이다.


다만 이 경우에도 함정이 있는데, OnDestory의 호출을 100% 보장하지 않는다는 점이다.


UnityEngine.Object의 경우 Awake를 호출하지 않은 인스턴스의 경우 OnDestory도 마찬가지로 호출하지 않는다.



그래서 오늘 참고해 볼것은 UniTask 의 GetCancellationTokenOnDestroy 이다.


UniTask의 GetCancellationTokenOnDestroy 은 GameObject의 확장 메서드인데,


어떻게 작동하는지를 자세히 살펴보면 방금 막 했던 방식과 매우 흡사하다.


호출한 GameObject에 인스펙터에 표시되지 않는  AsyncDestroyTrigger라는 컴포넌트를 붙이고


해당 컴포넌트의 OnDestory 에서 CancellationTokenSource의 Cancel을 호출하는 식으로 작동한다.


그리고 이 컴포넌트를 자세히 뜯어보면 어떤식으로 OnDestory의 호출을 100% 보장하는지 알 수 있다..


bool awakeCalled = false;
bool called = false;
CancellationTokenSource cancellationTokenSource;

public CancellationToken CancellationToken
{
get
{
if (cancellationTokenSource == null)
{
cancellationTokenSource = new CancellationTokenSource();
if (!awakeCalled)
{
PlayerLoopHelper.AddAction(PlayerLoopTiming.Update, new AwakeMonitor(this));
}
}
return cancellationTokenSource.Token;
}
}

void Awake()
{
awakeCalled = true;
}

void OnDestroy()
{
called = true;

cancellationTokenSource?.Cancel();
cancellationTokenSource?.Dispose();
}



사실 원리 자체는 간단한데, Awake시 true로 변하는 Flag와 OnDestroy시 true로 변하는 Flag를 하나씩 세워두는 것이다.


그리고 해당 클래스의 Flag를 감시하는 Monitor 클래스를 만들고, 업데이트에서 지속적으로 해당 플래그를 체크하는 PlayerLoop에 등록한다.


PlayerLoopHelper는 유니티의 Update 타이밍에 등록된 Monitor를 순회하면서


해당 클래스의 Awake나 OnDestory가 호출되었는지 확인하고, 만약 이미 호출되었다면 Monitor를 등록 해제한다.


만약 아직 호출되지 않았다면, Monitor가 감시중인 컴포넌트를 == 체크를 통해 파괴되었는지 확인한다.


== 체크를 통해 파괴되었다면, 해당 컴포넌트는 단 한번도 활성화하지 않고 파괴되었다는 뜻이니


PlayerLoopHelper가 대신해서 해당 컴포넌트의 OnDestory를 호출하고 Monitor를 등록 해제한다.


물론 이 방법도 완전히 고비용 작업인 == 비교를 아예 없애진 않는다.


다만 좀 더 단순한 작업인 플래그 체크로 필터링 함으로써


고비용 작업을 최소화 했다고 볼 수 있다.



사실 이 모든건 UnityEngine이 OnDestory 호출을 보장하지 않기 때문인데,


null 체크도 고비용으로 만들어놓고 대신 OnDestory를 사용할걸 권장하면서


100% 호출을 보장하지 않는 이유는 나도 잘 모르겠다.


하여간 엔진이 문제임