다음과 같은 제네릭 싱글톤 베이스가 있다.
using UnityEngine;
using Sirenix.OdinInspector;
public class Singleton<T> : SerializedMonoBehaviour where T : SerializedMonoBehaviour
{
private static T instance;
public static T Instance
{
get
{
if (isQuitting) return null;
if (instance == null)
{
instance = FindFirstObjectByType<T>();
if(instance == null)
{
instance = new GameObject(typeof(T).Name).AddComponent<T>();
}
}
return instance;
}
protected set { instance = value; }
}
[SerializeField] private bool isDontDestory = true;
protected static bool isQuitting = false;
protected virtual void Awake()
{
if (instance == null)
{
instance = this as T;
if (isDontDestory)
{
DontDestroyOnLoad(gameObject);
}
}
else
{
Debug.LogWarning($"{typeof(T)} 중복");
Destroy(gameObject);
}
}
public virtual void Init() { }
protected virtual void OnApplicationQuit()
{
isQuitting = true;
}
}
이 클래스를 상속받은 어떤 매니저가 Awake()에서 base.Awake()를 호출해 instance를 세팅한다.
그런데 다른 오브젝트가 OnEnable()에서 해당 매니저 이벤트에 등록하려고 했더니 null이 발생했다.
로그를 찍어보니 다음처럼 보였다.
다른 오브젝트 OnEnable() 로그가 먼저 찍힘
싱글톤 쪽 base.Awake() 아래 로그가 나중에 찍힘
그래서 의문이 생겼다.
“Unity는 모든 오브젝트의
Awake를 먼저 다 돌리고, 그다음OnEnable을 돌리는 거 아니었나?”
그렇게 전역 순서로 보장되지 않는다.
공식 문서 기준으로 보장되는 것은 다음에 가깝다.
Awake와 OnEnable은 모두 Start보다 먼저 호출된다.
OnEnable은 같은 컴포넌트 기준으로는 Awake 다음, Start 이전이다.
하지만 여러 오브젝트 전체를 대상으로 “모든 Awake가 끝난 뒤에 모든 OnEnable이 돈다”는 보장은 문서에 없다.
매뉴얼은 Awake와 OnEnable이 모두 Start 이전 구간에 속한다고 설명할 뿐, 그 둘 사이를 전역 배리어처럼 설명하지 않는다.
null이 생겼나?시나리오를 순서로 풀면 이렇다.
base.Awake() 안에서 세팅된다protected virtual void Awake()
{
if (instance == null)
{
instance = this as T;
...
}
}
즉, 이 오브젝트의 Awake가 실제로 실행되기 전까지는 instance가 비어 있을 수 있다.
OnEnable()에서 이벤트를 구독한다.private void OnEnable()
{
MyManager.Instance.SomeEvent += HandleSomething;
}
이 시점에 MyManager 쪽 base.Awake()가 아직 안 끝났다면 Instance가 비어 있거나, 기대한 씬 오브젝트가 아닌 다른 경로로 평가될 수 있다.
OnEnable()에서 null이 나올 수 있다이건 Awake -> OnEnable -> Start 규칙을 어긴 게 아니라, 오브젝트 간 실행 순서에 전역 동기화가 없기 때문이다. 공식 문서가 강하게 보장하는 전역 배리어는 Start 쪽이다. Start는 씬의 모든 오브젝트에서 Awake가 호출되기 전에는 호출되지 않는다.
유니티 입문 6년만에 알게 되었다..
unchecked 예외인 NullPointerException를 만나셨나 보네요? ㅋ