공용 스크립트 제작기🎈 - 싱글톤 편

공용 스크립트 제작기🎈 - 싱글톤 편

Unity 프로젝트의 공용으로 사용할 수 있는 스크립트를 제작하면서 어떻게 만들었는지, 어떤 점에 신경썼는지 설명하는 글입니다.

Singleton 클래스를 상속받아서 Manager 클래스를 싱글톤으로 만드는 건 이미 흔한 기법이다. 이를 매번 새로 만들어쓰자니 역시 귀찮아서, 이 참에 단단하게 한 번 다지고 지나가보려 한다. 이 글에서 따로 싱글톤 패턴이 어떠니 저쩌니 떠들지는 않겠다. 간단하게 왜 이렇게 만들었는지를 정리해놓으려 한다.

분류하기

크게 두 종류를 개발하려 한다.

  1. 일반 C# Singleton
  2. MonoBehaviour를 상속받는 Singleton
C# Singleton 특징
  • Unity의 생명주기와 독립적으로 동작
  • 데이터를 관리할 때 혹은 순수 로직만 담당하는 시스템에 적합
MonoBehaviour Singleton 특징
  • GameObject에 컴포넌트로 부착됨
  • Unity의 생명주기 메서드 사용가능
  • Scene관리가 필요한 시스템 혹은 Unity의 시스템이 필요한 경우에 사용

그리고 각각은 또 EagerSingleton과 LazySingleton 두 가지로 나뉜다.

EagerSingleton 특징
  • 타입 로드 시에 인스턴스 즉시 생성
  • lock을 사용하지 않아도 되어 성능이 우수함
  • 게임 전체에서 사용되는 핵심 매니저에 적합
LazySingleton 특징
  • 실제로 필요할 때까지 인스턴스 생성을 미룸
  • 메모리 효율적
  • 게임 전체에서 사용되지 않을 수도 있는 시스템 및 초기화 비용이 클 때 적합

분류 이유

성능의 최적화를 위해서 이렇게 세분화 하였다. 순수 C# 싱글톤은 MonoBehaviour의 오버헤드 없이 가볍게 동작할 수 있고, 메모리를 최적화 하기 위해 Lazy라는 선택지를 두었다. 또한 이렇게 함으로써 명확하게 책임 분리가 된다.

주의사항

  • MonoBehaviour를 상속받는 개체는 DontDestroyOnLoad가 없으면 씬이 전환될 때 파괴된다. 때문에 IsPersistence 등의 bool값을 사용하여 씬 전환 시 파괴가 되지 않는 옵션도 제공할 예정이다
  • 반면 순수 C# Singleton은 씬과 무관하게 계속 메모리에 존재한다. 따라서 메모리 관리에 주의가 필요하다
  • EagerMonoSingleton는 씬에 반드시 미리 배치되어야 한다 (런타임에 자동 생성 불가)
  • LazyMonoSingleton은 씬에 배치되지 않았다면 자동으로 생성한다

실제 구현

C# Singleton - Eager
public abstract class EagerSingleton<T> where T : class, new()
{
    private static readonly T _instance;

    /// <summary>
    /// 정적 생성자는 CLR이 타입 최초 접근 시 단 한 번만 실행함을 보장 (내부적으로 lock 사용)
    /// = 멀티스레드 환경에서도 lock 없이 안전
    /// </summary>
    static EagerSingleton()
    {
        _instance = new T();
        (_instance as EagerSingleton<T>)?.Initialize();
    }

    public static T Instance => _instance;

    /// <summary>
    /// 싱글톤 인스턴스가 생성되어 있는지 확인
    /// Eager 싱글톤은 타입 로드 시 즉시 생성되므로 항상 true
    /// </summary>
    /// <returns>항상 true</returns>
    public static bool IsAlive() => _instance != null;

    /// <summary>
    /// 파생 클래스에서 직접 생성하지 못하도록 protected
    /// </summary>
    protected EagerSingleton()
    {
    }

    /// <summary>
    /// 싱글톤 인스턴스 초기화
    /// 파생 클래스에서 오버라이드하여 초기화 로직 구현
    /// </summary>
    protected virtual void Initialize()
    {
    }

    // Eager 싱글톤은 한번 생성되면 해제할 수 없음
}
C# Singleton - Lazy
public abstract class LazySingleton<T> where T : class, new()
{
    private static T _instance;
    private static readonly object _lock = new object();

    public static T Instance
    {
        get
        {
            // lock 건 이후에도 _instance가 생성되었을 수도 있으니
            // Double-checked locking 패턴으로 스레드 세이프 보장
            if (_instance == null)
            {
                lock (_lock)
                {
                    // 초기화 메서드 호출
                    if (_instance == null)
                    {
                        _instance = new T();
                        (_instance as LazySingleton<T>)?.Initialize();
                    }
                }
            }

            return _instance;
        }
    }

    /// <summary>
    /// 싱글톤 인스턴스가 생성되어 있는지 확인
    /// </summary>
    /// <returns>인스턴스가 존재하면 true, 아니면 false</returns>
    public static bool IsAlive() => _instance != null;

    /// <summary>
    /// 파생 클래스에서 직접 생성하지 못하도록 protected
    /// </summary>
    protected LazySingleton()
    {
    }

    /// <summary>
    /// 싱글톤 인스턴스 초기화
    /// 파생 클래스에서 오버라이드하여 초기화 로직 구현
    /// </summary>
    protected virtual void Initialize()
    {
    }

    /// <summary>
    /// 싱글톤 인스턴스 해제
    /// 주로 테스트나 씬 전환 시 사용
    /// </summary>
    public static void DestroyInstance()
    {
        lock (_lock)
        {
            _instance = null;
        }
    }
}
MonoBehaviour Singleton - Eager
public abstract class EagerMonoSingleton<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T _instance;
    private static bool isInitialized = false;
    private static bool isApplicationQuitting = false;

    public static T Instance
    {
        get
        {
            // 애플리케이션 종료 중에는 null 반환
            if (isApplicationQuitting)
            {
                return null;
            }

            // Awake 전 접근 시에만 한 번 찾기
            if (_instance == null && !isInitialized)
            {
                _instance = FindFirstObjectByType<T>();
            }

            // 여전히 null이면 경고
            if (_instance == null)
            {
                Debug.LogError($"[EagerMonoSingleton] {typeof(T).Name}이(가) 씬에 존재하지 않습니다. " +
                               $"씬에 GameObject를 배치하고 {typeof(T).Name} 컴포넌트를 추가해주세요.");
            }

            return _instance;
        }
    }

    /// <summary>
    /// 싱글톤 인스턴스가 생성되어 있고 파괴되지 않았는지 확인
    /// </summary>
    /// <returns>인스턴스가 존재하고 살아있으면 true, 아니면 false</returns>
    public static bool IsAlive() => _instance != null && !isApplicationQuitting;

    /// <summary>
    /// DontDestroyOnLoad 적용 여부 (기본값: true)
    /// 파생 클래스에서 오버라이드하여 변경 가능
    /// </summary>
    protected virtual bool IsPersistent => true;

    protected virtual void Awake()
    {
        // 이미 인스턴스가 존재하는 경우
        if (_instance != null && _instance != this)
        {
            Debug.LogWarning($"[EagerMonoSingleton] {typeof(T).Name}이(가) 씬에 중복으로 존재합니다. " +
                           $"중복 인스턴스를 제거합니다.");
            Destroy(gameObject);
            return;
        }

        _instance = this as T;
        isInitialized = true;

        // DontDestroyOnLoad 설정
        if (IsPersistent)
        {
            DontDestroyOnLoad(gameObject);
        }

        // 초기화
        Initialize();
    }

    /// <summary>
    /// 초기화 메서드 파생 클래스에서 오버라이드하여 변경 가능
    /// </summary>
    protected virtual void Initialize()
    {
    }


    protected virtual void OnApplicationQuit()
    {
        isApplicationQuitting = true;
    }

    protected virtual void OnDestroy()
    {
        // 자신이 싱글톤 인스턴스인 경우에만 해제
        if (_instance == this)
        {
            isApplicationQuitting = true;
            isInitialized = false;
        }
    }
}
MonoBehaviour Singleton - Lazy
public abstract class LazyMonoSingleton<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T _instance;
    private static readonly object _lock = new object();
    private static bool isApplicationQuitting = false;

    public static T Instance
    {
        get
        {
            // 애플리케이션 종료 중에는 null 반환 (워닝 방지)
            if (isApplicationQuitting)
            {
                return null;
            }

            lock (_lock)
            {
                // 기존 인스턴스가 없으면 씬에서 찾기
                _instance ??= FindFirstObjectByType<T>();

                // 씬에도 없으면 새로 생성
                if (_instance == null)
                {
                    GameObject singletonObject = new GameObject($"[Singleton] {typeof(T).Name}");
                    _instance = singletonObject.AddComponent<T>();
                }

                return _instance;
            }
        }
    }

    /// <summary>
    /// 싱글톤 인스턴스가 생성되어 있고 파괴되지 않았는지 확인
    /// </summary>
    /// <returns>인스턴스가 존재하고 살아있으면 true, 아니면 false</returns>
    public static bool IsAlive() => _instance != null && !isApplicationQuitting;

    /// <summary>
    /// DontDestroyOnLoad 적용 여부 (기본값: true)
    /// 파생 클래스에서 오버라이드하여 변경 가능
    /// </summary>
    protected virtual bool IsPersistent => true;

    protected virtual void Awake()
    {
        // 이미 인스턴스가 존재하는 경우
        if (_instance != null && _instance != this)
        {
            // 중복 인스턴스 제거
            Destroy(gameObject);
            return;
        }

        _instance = this as T;

        // DontDestroyOnLoad 설정
        if (IsPersistent)
        {
            DontDestroyOnLoad(gameObject);
        }

        // 초기화
        Initialize();
    }

    /// <summary>
    /// 초기화 메서드 파생 클래스에서 오버라이드하여 변경 가능
    /// </summary>
    protected virtual void Initialize()
    {
    }

    protected virtual void OnApplicationQuit()
    {
        isApplicationQuitting = true;
    }

    protected virtual void OnDestroy()
    {
        // 자신이 싱글톤 인스턴스인 경우에만 해제
        if (_instance == this)
        {
            isApplicationQuitting = true;
        }
    }
}

결론

사실 별거 없긴하다. 요샌 AI가 잘 만들어주기도 하고, 많이 보편화 되어있기도 하고. 그래도 이렇게 하나쯤은 계속 쓰는거 가지고 있어야지 싶어서 정리해봤다. 누가볼까 싶지만 혹시 틀린 부분이나 더 개선할 부분이 있다면 댓글로 알려주시길.


© 2022. All rights reserved.