Study/Unity

Unity에 싱글톤(Singleton) 패턴 적용하기

해달 2022. 7. 17. 14:55

개요

싱글톤 패턴은 단일의 인스턴스를 보장하고 이에 대한 전역적인 접근점을 제공하는 패턴이다. 이 글에서는 싱글톤 패턴을 Unity에 어떻게 적용시키는지 살펴본다. 싱글톤 패턴에 대한 상세한 정보가 필요하다면 여기를 참고하라. Unity에서 싱글톤을 적용하는 방법은 MonoBehaviour를 상속 받아서 구현하는 방법과 상속 받지 않고 구현하는 방법으로 나뉜다. 후자는 일반 C#에서의 방법과 크게 다르지 않으므로 여기서는 전자 부분만 살펴보려 한다.

구현

우선 싱글톤 추상 클래스를 만들어 원하는 컴포넌트는 해당 클래스를 상속 받아 싱글톤으로 만드는 구조로 갈 것이다. 그럼 아래와 같이 작성할 수 있다.

 

public class SingletonBehaviour<T> :
    // Unity의 제어를 받기 위해 MonoBehaviour를 상속한다.
    MonoBehaviour
    // 컴포넌트에 대해서만 동작하기 때문에 아래와 같은
    // where 제약을 작성한다.
    where T : MonoBehaviour
{
    // 정적 멤버로 인스턴스를 갖고 있는다.
    private static T _instance;


    // 전역적인 접근점을 제공한다.
    public static T Instance { get { return _instance }}
}

 

그럼 이제 남은 것은 인스턴스의 초기화 시점이다. 일반적으로는 타입 초기화를 사용하지만, Unity 컴포넌트의 경우는 생성자를 사용하여 초기화하면 안된다. 스크립트 객체의 생성은 아래의 내용처럼  Unity 엔진이 알아서 생성하고 관리하기 때문이다.

 

컴포넌트를 작성할 때 굳이 생성자를 만들어 줄 필요가 없다.(참고)

 

따라서 스크립트에 있는 멤버의 초기화는 Unity 이벤트 함수의 실행 순서에 따라 Awake, OnEnable, Start 중에서 하게 된다. 이 중 초기화 시점에 적절한 것은 Awake다.* Awake에서 아래와 같이 코드를 작성한다.

* 셋의 차이점에 대해서는 여기를 참고하라.

 

public class SingletonBehaviour<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T _instance;


    public static T Instance { get { return _instance; }}
   
    void Awake()
    {
        // 인스턴스가 할당된 경우라면 다른 게임 오브젝트가 있는 것이다.
        if (_instance != null)
        {
            // 따라서 씬에 단일에 게임 오브젝트만 남도록 삭제한다.
            Destroy(gameObject);


            return;
        }


        // 인스턴스를 할당한다.
        _instance = GetComponent<T>();
        // 씬이 전환될 때에도 파괴가 되지 않도록 한다.
        DontDestroyOnLoad(gameObject);
    }
}

 

또 하나 생각해볼 것이 있는데, 위의 코드가 정상적으로 동작하려면 SingletonBehaviour를 상속 받는 게임 오브젝트가 다른 모든 게임 오브젝트보다 먼저 Awake()를 처리해야한다는 점이다. 하지만 우리가 이 순서를 강제할 수 있는 코드를 작성할 수는 없다. 따라서 아래와 같이 코드를 수정한다.

 

public class SingletonBehaviour<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T _instance;
 
    public static T Instance
    {
        get
        {
            // SingletonBehaviour가 초기화 되기 전이라면
            if (_instance == null)
            {
                // 해당 오브젝트를 찾아 할당한다.
                _instance = FindObjectOfType<T>();
                DontDestroyOnLoad(_instance.gameObject);
            }
            return _instance;
        }
    }
   
    void Awake()
    {
        // 이제는 이 조건이 2가지를 시사하게 된다.
        if (_instance != null)
        {
            // (1) 다른 게임 오브젝트가 있다면
            if (_instance != this)
            {
                // 하나의 게임 오브젝트만 남도록 삭제한다.
                Destroy(gameObject);
            }


            // (2) Awake() 호출 전 할당된 인스턴스가 자기 자신이라면
            // 아무것도 하지 않는다.
           
           
            return;
        }
 
        // 이 아래의 경우는 SingletonBahaviour가 운이 좋게
        // Instance 참조 전 Awake()가 실행되는 경우이다.
        _instance = GetComponent<T>();
        DontDestroyOnLoad(gameObject);
    }
}
 

 

이제 SingletonBehaviour를 상속 받은 클래스는 싱글톤으로 동작한다.

 

public class GameManager : SingletonBehaviour<GameManager>
{
    public void Foo()
    {
        Debug.Log($"Foo");
    }
}


public class Temp : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.T))
        {
            // 어디서든 GameManager 클래스를 통해 인스턴스에 접근할 수 있다.
            GameManager.Instance.Foo();
        }
    }
}

 

다만 여기서 생각할 점이 있다. 만약 SingletonBehaviour를 상속 받은 스크립트가 Awake()를 정의한다면 SingletonBehaviour의 Awake()를 가리게 되어 의도치 않은 동작이 일어난다.

 

public class SingletonBehaviour<T> : MonoBehaviour where T : MonoBehaviour
{
    // 생략
   
    void Awake()
    {
        Debug.Log("SingletonBehaviour Awake")
       
        // 생략
    }
}




public class GameManager : SingletonBehaviour<GameManager>
{
    void Awake()
    {
        Debug.Log("GameManager Awake");
    }
}

 

위의 코드로 간단하게 테스트 하면 아래와 같은 결과를 볼 수 있다.

따라서 자식에서 Awake()를 호출할 수 있도록 접근 한정자를 바꿔주고, GameManager에서도 명시적으로 부모의 Awake()를 호출해야 한다.

 

public class SingletonBehaviour<T> : MonoBehaviour where T : MonoBehaviour
{
    // 생략
   
    protected void Awake()
    {
        Debug.Log("SingletonBehaviour Awake")
       
        // 생략
    }
}




public class GameManager : SingletonBehaviour<GameManager>
{
    void Awake()
    {
        base.Awake();
        Debug.Log("GameManager Awake");
    }
}

 

이제는 올바른 결과를 얻을 수 있다.

참고사항

다른 분들의 구현의 경우 씬 자체에 게임 오브젝트가 없을 때 동적으로 만들어 배치하는 경우도 있다. 본인의 경우에는 그러한 경우라면 터지는 게 맞다고 생각해 따로 작성하진 않았다.

'Study > Unity' 카테고리의 다른 글

Awake / OnEnable / Start의 차이  (0) 2022.07.17