궁극의 Unity 싱글톤 패턴 #2 - Instance 프로퍼티의 Lazy Initialization

이오싶·2024년 10월 2일


지난 포스트에서 기초적인 싱글톤 코드를 리뷰하고 각 코드의 의의에 대해 설명했습니다. 이번 포스트에서부터는 기존 싱글톤 코드의 문제점을 파악하고 보완하는 방법을 이야기해보도록 하겠습니다.

Awake가 일임하는 초기화의 문제점

한편 Awake에서 싱글톤의 초기화, 특히 instance를 할당하는 부분까지 일임하는 것이 안전할까요?

Awake는 Monobehaviour 인스턴스들의 초기화를 위해 제공되는 이벤트 함수로, 모든 이벤트 함수 중 가장 먼저 실행되도록 보장되어있기에 주로 인스턴스 필드의 초기화 로직이 많이 담기곤 합니다. 싱글톤의 instance가 초기화되기 전에 다른 컴포넌트에서 싱글톤 인스턴스에 접근하려 하면 null을 돌려받게 될 뿐이므로, 이를 방지하기 위해 가장 먼저 실행되는 메소드인 Awake에서 instance를 초기화하는 것은 적절하다 할 수 있겠습니다.

다른 컴포넌트의 Awake에서 싱글톤 인스턴스를 호출하지 않는다는 전제 하에서는 그렇습니다.

The order in which Unity calls each GameObject's Awake is not deterministic. Because of this, you should not rely on Awake being called on one GameObject before or after another. For example, you should not assume that a reference set up by one GameObject's Awake will be usable in another GameObject's Awake. Instead, you should use Awake to set up references between scripts, and use Start, which is called after all Awake calls are finished, to pass any information back and forth.
- Unity Scripting API

가장 큰 문제는 다른 컴포넌트도 Awake를 가지고 있으며, 컴포넌트 간의 Awake 호출 순서는 기준이 있거나 명시적으로 결정해줄 수 없다는 것입니다. 특히나 싱글톤 인스턴스는 게임 전체에 대한 데이터와 기능을 담당하는 용도로 주로 사용되므로 다른 컴포넌트의 초기화에도 많이 사용된다는 점이 큰 문제가 됩니다.

싱글톤의 Awake가 늦게 호출되었다면 당연히 다른 컴포넌트에서 Singleton.Instance를 통해 싱글톤의 멤버에 접근하려 할 때 NullReferenceException이 발생하는 것이 뻔하고, 만약 반대로 싱글톤의 Awake가 먼저 호출되었다면... 여러분은 높은 확률로 빌드 시 이벤트 함수의 실행 순서가 뒤바뀌면서 에디터에서 안 생기던 버그가 빌드에서 생기는 공포스러운 경험을 겪으시게 됩니다.

일반적으로 컴포넌트의 초기화는 AwakeStart라는 두 이벤트 함수에서 많이 구현이 되는데, 이렇게 해당 컴포넌트의 Awake가 호출되는 시점에서 싱글톤 instance가 초기화되었는지를 보장할 수 없으므로, 이로 인해 해당 싱글톤을 참조하는 모든 컴포넌트가 초기화를 Start에서만 진행해야 하는 큰 불편함이 발생합니다.

물론 Project Settings에서 Script Execution Order를 조정하는 방법도 있긴 하지만... 이 순서를 자주 건드리는 것은 여러가지 이유로 좋은 방법이 아닙니다. (호출 순서 의존성 발생, 메타 파일 영향 등...) 특히 싱글톤 오브젝트를 여러 개 추가할 작정이라면 특히 그렇습니다.

해결책: Lazy Initialization(지연 초기화)

위와 같은 문제를 해결하기 위해선 싱글톤 인스턴스가 언제 호출되던 instance가 먼저 초기화되어 있도록 보장해주어야 합니다. 가장 많이 사용하는 해결책 중 하나는 Lazy Initialization(지연 초기화)인데, 싱글톤 인스턴스의 유일한 접근 경로인 Instance가 호출되는 시점에 초기화를 함께 진행하는 방법입니다.

Instance가 호출되었을 때 instance가 초기화되지 않았다면 초기화 로직을 실행함으로써, 다른 컴포넌트들이 Instance를 호출했을 때 항상 초기화된 싱글톤 인스턴스를 반환해주도록 보장할 수 있습니다. 싱글톤 컴포넌트에 접근하기 위해서는 항상 먼저 Instance 프로퍼티를 거쳐야 하는다는 점을 이용한 트릭이죠.

public static Singleton Instance
{
	get
	{
		if (instance == null) InitSingleton();
		return instance;
    }
}
private static void InitSingleton()
{
	if (instance != null) return;
	instance = FindObjectOfType<Singleton>();
	if (instance == null)
    {
    	var managerObject = GameObject.Find("Singleton");
        if (managerObject == null)
        {
        	managerObject = new GameObject
            {
            	name = "Singleton"
            };
        }
		instance = managerObject.AddComponent<Singleton>();
		DontDestroyOnLoad(managerObject);
    }
    else DontDestroyOnLoad(instance.gameObject);
}
        
private void Awake()
{
    if (Instance != this)
    {
        if (Instance.gameObject != gameObject) Destroy(gameObject);
        else Destroy(this);
        return;
    }
}

위 코드 스니펫에서 instance가 초기화되지 않았을 경우(null인 경우) InitSingleton()을 호출하여 instance를 우선 초기화한 뒤 반환하는 것을 확인할 수 있습니다.

InitSingleton()의 역할

싱글턴의 초기화 코드가 기존 코드에 비해 많이 복잡해진 것을 확인할 수 있는데, 이는 InstanceInstance에서 호출해야 하는 InitSingleton() 메소드가 모두 static이기 때문입니다.
특정 인스턴스에서 호출되는 메소드가 아니기에 자기 자신을 instance에 할당하거나 할 수 있는 피호출 인스턴스가 존재하지 않습니다. 따라서 씬 내에서 Singleton 타입을 가진 컴포넌트를 탐색하여 할당하는 과정이 필요합니다.

private static void InitSingleton()
{
	if (instance != null) return;
    
	instance = FindObjectOfType<Singleton>();
	if (instance == null)
    {
        var managerObject = GameObject.Find("Singleton");
    	if (managerObject == null)
    	{
        	managerObject = new GameObject
        	{
            	name = "Singleton"
        	};
    	}
    	instance = managerObject.AddComponent<Singleton>();
    }
	DontDestroyOnLoad(instance.gameObject);
}

InitSingleton()을 자세히 보면 씬에서 Singleton 타입의 컴포넌트를 탐색하고 할당하는 과정에서 꽤나 다양한 케이스를 고려하고 있다는 것을 알 수 있습니다.

1. instance가 할당되었는지 확인

if (instance != null) return;

instance가 이미 할당되었다면 Instance가 이를 그대로 반환하면 그만입니다. return을 호출합니다. 아니라면 다음으로 넘어갑니다.

2. 씬 안에서 Singleton 타입의 컴포넌트가 있는지 탐색

instance = FindObjectOfType<Singleton>();
if (instance != null)
{
	DontDestroyOnLoad(instance.gameObject);
	return;
}

FindObjectOfType<Singleton>이 씬 내의 Singleton 타입의 컴포넌트를 탐색합니다. 해당 컴포넌트를 발견했다면 instance에 대입, 게임오브젝트를 DontDestroyOnLoad를 적용하고 반환합니다.

3. 게임오브젝트를 탐색/생성 후 컴포넌트를 추가

일반적으로 2번에서 instance 할당이 실패한 경우 바로 게임오브젝트를 새로 생성하여 Singleton 컴포넌트를 붙인 후 instance에 할당합니다. (심지어 근성없게도 1번 실패 후 바로 생성하는 경우도 있습니다)

싱글톤 컴포넌트와 해당 컴포넌트를 추가할 게임오브젝트를 딱 하나씩만 사용할 작정이라면 큰 문제가 없지만, 하나의 게임오브젝트가 여러 싱글톤 컴포넌트를 함께 달고 있는 경우 멀쩡한 다른 싱글톤 컴포넌트를 같이 파괴하는 문제를 발생시킬 수 있습니다. (중복된 싱글톤 인스턴스의 파괴는 Awake에서 일임하고 있으므로 후술합니다)

따라서 싱글톤 오브젝트를 추가할 게임오브젝트의 이름을 우선 정해놓고, 해당 게임오브젝트를 씬에서 우선 탐색한 후 발견하지 못한 경우에만 새로 생성하도록 하는 것이 바람직합니다.

var managerObject = GameObject.Find("Singleton");
if (managerObject == null)
{
	managerObject = new GameObject
	{
		name = "Singleton"
	};
}
instance = managerObject.AddComponent<Singleton>();
DontDestroyOnLoad(managerObject);

필요하다면 해당 오브젝트의 이름을 정적 필드로 빼내 사용할 수도 있겠습니다.

위와 같이 없으면 만들어라 정신으로 instance를 찾아 할당하니 정말 극단적인 경우가 아니고서야 instance가 초기화되지 않기가 어렵습니다.

Awake()의 역할

InitSingleton()instance를 초기화하는 역할을 넘겨받았지만, Awake()는 여전히 instance가 아닌 중복 인스턴스를 제거하는 역할을 해줘야 합니다. instance가 이미 할당된 상태에서 어쩌다 생성된 Singleton의 인스턴스는 Awake()에서 자신이 적자가 아님을 확인하고 장엄하게 자결합니다.

private void Awake()
{
    if (Instance != this)
    {
        if (Instance.gameObject != gameObject) Destroy(gameObject);
        else Destroy(this);
        return;
    }
}

하늘은 어찌 instance를 낳고...
이때도 마찬가지로 혹여나 원본 또는 다른 싱글톤 타입들이 부착된 게임오브젝트를 파괴하지 않도록 만전을 기하여야 합니다. 자신의 게임오브젝트가 원본 싱글톤 인스턴스가 부착된 게임오브젝트라면, 조용히 자기 자신만을 제거하도록 합니다.

이때 한가지 주목해야 할 점은, Awake()에서도 마찬가지로 프로퍼티 Instance를 호출하고 있다는 점입니다. 싱글톤 인스턴스의 Awake()가 호출되기 전까지 instance가 초기화되지 않았다면, Awake()에서 무조건 싱글톤을 초기화함으로써 지연 초기화의 호출 시점에 초기화의 오버헤드를 떠넘긴다는 단점을 완화할 수 있습니다. 게임이 실행되는 도중 보다는 시작 시점에 오버헤드를 몰아주는 편이 더 좋은 플레이 경험을 제공하기 마련이니까요. 이러한 점 덕에 순수한 지연 초기화를 구현했다고 보기는 어려울 수도 있겠습니다.

인스턴스 자체 초기화

싱글톤 클래스가 instance만 필드로 가지고 있을리가 없으니, 다른 필드를 초기화해주는 로직도 필요한 경우가 많습니다.
이때도 마찬가지로 Instance가 호출되는 시점에 초기화가 완료되어야 하므로 다음과 같이 인스턴스 초기화 메소드 Init()를 집어넣어 주는 방법으로 초기화를 구현할 수 있겠습니다.

private static void InitSingleton()
{
	if (instance != null) return;
    
	instance = FindObjectOfType<Singleton>();
	if (instance == null)
    {
      var managerObject = GameObject.Find("Singleton");
      if (managerObject == null)
      {
          managerObject = new GameObject
          {
              name = "Singleton"
          };
      }
      instance = managerObject.AddComponent<Singleton>();
    }
	DontDestroyOnLoad(managerObject);
    instance.Init();
}

private void Init()
{
	// 인스턴스 초기화
}

네이티브 클래스라면?

Monobehaviour가 아닌 네이티브 환경에서의 싱글톤 클래스라면 이런 걱정을 할 필요가 없습니다. 싱글톤 인스턴스 여럿이 비동기적으로 생성되는 괴랄한 환경이 아니고서야, 생성자에 이 모든 과정을 일임하고 싱글톤 인스턴스를 가장 먼저 생성하면 자연스럽게 해결되는 문제이기 때문입니다.
단 유니티의 gameObject들은 기본적으로 씬의 하이어라키 상에 생성된 상태에서 프로그램이 시작되고, 각각의 동등한 이벤트 함수들이 명시적인 순서 없이 실행되므로 이런 귀찮은 과정을 거쳐야 한다고 볼 수 있겠습니다.

profile
Iosif2510

0개의 댓글