[Unity/C#] 싱글톤 패턴(Singleton Pattern)에 대해서

신지한·2024년 6월 11일
0

면접준비

목록 보기
9/9
post-thumbnail

📢 공부내용에 앞서서

본 게시글은 면접준비 및 자기계발을 목적으로 작성된 게시글입니다
공부한 내용을 토대로 남들에게 설명할 수 있도록 이해하는 과정에 작성한 게시글이니 참고바랍니다

📖 싱글톤 패턴(Singleton Pattern)

📌 오브젝트 풀링에 대해선 위 자료를 참고하여 게시글을 작성 및 실습 하였습니다


📌 싱글톤 패턴이란?

소프트웨어 디자인 패턴에서 싱글턴 패턴(Singleton pattern)을 따르는 클래스는, 생성자가 여러 차례 호출되더라도 실제로 생성되는 객체는 하나이고 최초 생성 이후에 호출된 생성자는 최초의 생성자가 생성한 객체를 리턴한다. 이와 같은 디자인 유형을 싱글톤 패턴이라고 한다. 주로 공통된 객체를 여러개 생성해서 사용하는 DBCP(DataBase Connection Pool)와 같은 상황에서 많이 사용된다.ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ- 위키백과

원조 GoF에 따르면 싱글톤 패턴은 다음을 수행합니다

  • 클래스가 자체의 인스턴스 하나만을 인스턴스화하도록 보장
  • 해당하는 하나의 인스턴스에 대한 손쉬운 글로벌 액세스 제공

전체 씬에서 행동을 조정하는 오브젝트가 정확히 하나만 필요할 때 유용합니다
예를 들면 씬에 메인 게임 루프를 총괄하는 게임 관리자가 딱 하나만 필요할 수 있습니다
한 번에 하나의 파일 관리자만 파일 시스템에 작성하기를 원할 수도 있습니다

이러한 관리자 레벨의 오브젝트는 대체로 싱글톤 패턴을 적용하기에 좋은 대상입니다


📌 싱글톤 패턴 구현 방법

👉 단순한 싱글톤

using UnityEngine;

public class SimpleSingleton : MonoBehaviour
{
	public static SimpleSingleton Instance;
    
    private void Awake()
    {
    	if (Instance == null)
        {
        	Instance = this;
        }
        else
        {
        	Destroy(gameObject);
        }
    }
}

공용 정적 Instance는 씬에서 Singleton의 인스턴스 하나를 갖게 됩니다

Awake 메서드에서 인스턴스가 이미 설정되어 있는지 확인하고 Instance가 현재 null이면 인스턴스는 이 특정 오브젝트로 설정됩니다 (이 오브젝트는 씬에서 가장 첫 번째 싱글톤이어야 합니다)
그렇지 않으면 이 인스턴스는 복제본이어야 하며, 싱글톤이 씬에서 그러한 컴포넌트를 오직 하나만 갖게 하도록 Destroy(gameObject)를 호출합니다

런타임에 계층 구조에서 둘 이상의 게임 오브젝트에 스크립트를 연결하는 경우, Awake의 로직은 첫 오브젝트만 유지하고 나머지는 폐기합니다

🤔 단순한 싱글톤(Simple Singleton)의 문제점 - 지속성 및 지연 인스턴스화

Simple Singleton은 정상적으로 작동하지만 다음과 같은 문제가 있음

  • 새로운 씬을 로드하면 게임 오브젝트가 파괴됩니다
  • 사용하기 전에 계층 구조에서 싱글톤을 설정해야 합니다

싱글톤은 보편적인 관리자 스크립트 역할을 하는 경우가 많으므로 DontDestroyOnLoad를 사용하여 지속성을 갖게 해서 이점을 활용할 수 있습니다

나아가 지역 인스턴스화를 사용하면 싱글톤이 처음으로 필요할 때 자동으로 만들어지도록 할 수 있습니다. 게임 오브젝트를 만들고 적절한 싱글톤 컴포넌트를 추가하기 위한 로직만 있으면 됩니다

👉 개선된 싱글톤

public class Singleton : MonoBehaviour
{
	private static Singleton instance;
    public static Singleton Instance
    {
    	get
        {
        	if(instance == null)
            {
            	SetupInstance();
            }
            return instance;
        }
    }
    
    private void Awake()
    {
    	if(instance == null)	
        {
        	instance = this;
            DontDestroyOnLoad(this.gameOjbect);
        }
        else
        {
        	Destroy(gameObject);
        }
    }
    
    private static void SetupInstance()
    {
    	instance = FindObjectOfType<Singleton>();
        
        if(instance == null)
        {
        	GameObject gameObj = new GameObject();
            gameObj.name = "Singleton";
            instance = gameObj.AddComponent<Singleton>();
            DontDestroyOnLoad(gameObj);
        }
    }
}

Instance는 이제 프라이빗 instance 지원 필드의 공용 프로퍼티입니다. 싱글톤을 처음 참조할 때는 가져오려는 인스턴스가 있는지 확인하고 인스턴스가 존재하지 않으면, SetupInstance 메서드가 적절한 컴포넌트를 갖는 게임 오브젝트를 만듭니다

DontDestroyOnLoad(gameObject)는 씬 로드로 인해 계층 구조에서 싱글톤이 지워지지 않게 합니다

싱글톤 인스턴스는 지속적이며, 게임에서 씬을 변경해도 액티브 상태를 유지합니다

👉 제네릭 사용

어떤 버전의 스크립트도 같은 씬 내에서 여러 싱글톤을 만드는 방법을 다루지 않습니다

예를 들어 AudioManager로서 작동하는 싱글톤과 GameManager로 작동하는 다른 싱글톤이 필요한 경우, 이 두 싱글톤은 현재 공존할 수 없습니다

관련 코드를 복제하고 로직을 각 클래스로 붙여 넣어야하는데 대신 다음과 같이 스크립트의 제네릭 버전을 만듭니다

public class Singleton<T> : MonoBehaviour where T : Component
{
	private static T instance;
    public static T instance
    {
    	get
        {
        	if(instance == null)
            {
            	instance = (T)FindObjectOfType(typeof(T));
                if(instance == null)
                {
                	SetupInstance();
                }
            }
            return instance;
        }
    }
    
    public virtual void Awake()
    {
    	RemoveDuplicates();
    }
    
    private static void SetupInstance()
    {
    	instance = (T)FindOjbectOfType(typeof(T));
        
        if(instance == null)
        {
        	GameOjbect gameObj = new GameObject();
            gameObj.name = typeof(T).Name;
            instance = gameObj.AddComponent<T>();
            DontDestroyOnLoad(gameObj);
        }
    }
    
    private void RemoveDuplicates()
    {
    	if(instance == null)
        {
        	instance = this as T;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
        	Destroy(gameObject);
        }
    }
}

이렇게 하면 어떤 클래스나 싱글톤으로 변환할 수 있습니다. 클래스를 선언하면 간단히 제네릭 싱글톤에서 상속합니다. 예를 들어 GameManager라는 MonoBehaviour를 다음과 같이 선언하여 싱글톤으로 만들 수 있습니다

public class GameManager : Singleton<GameManager>
{
	// ...
}

이제 필요할 때마다 언제나 공용 정적 GameManager.Instance를 참조할 수 있습니다


📌 싱글톤 패턴 장점과 단점

싱글톤은 여러 측면에서 SOLID 원칙을 위반한다는 점에서 다른 패턴과 다르고, 여러 이유로 많은 개발자들이 싱글톤에 호의적이지 않습니다

👎 싱글톤 패턴 단점

  • 싱글톤에는 글로벌 액세스가 필요합니다
    : 싱글톤은 글로벌 인스턴스를 사용하므로 많은 종속성을 숨길 수 있으며, 이에 따라 훨씬 해결하기 어려운 버그를 유발합니다
  • 싱글톤은 테스트를 어렵게 만듭니다
    : 유닛 테스트는 반드시 서로 독립적으로 수행해야합니다
    싱글톤은 씬 전반에서 많은 게임 오브젝트의 상태를 변경할 수 있으므로 테스팅에 방해가 될 수 있습니다
  • 싱글톤은 결합도가 높아지게 합니다
    : 다른 디자인 패턴은 대부분 종속성 분리를 시도하지만, 싱글톤은 이와 반대입니다
    결합도가 높으면 리팩터링하기가 어렵고 컴포넌트 하나를 변경하면 연결된 컴포넌트에 영향을 줄 수 있으므로 코드가 지저분해질 수 있습니다
  • 멀티 스레드 환경에서의 Race Condition 문제 발생 가능성이 증가합니다
    : 이를 막기 위해 싱글톤은 mutex lock, unlock을 반복적으로 걸기 때문에 코드의 성능이 떨어지게 됩니다

    📌 Race Condition(경쟁 상태) : 둘 이상의 입력 또는 조작의 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태

싱글톤에 대한 반감은 상당한 편입니다
수년 동안 유지 관리하려는 엔터프라이즈 수준의 게임을 제작할 예정이라면 싱글톤은 적절한 선택이 아닙니다

하지만 엔터프라이즈 수준의 애플리케이션이 아닌 게임도 많으며, 그러한 게임은 비즈니스 소프트웨어에 준하는 방식으로 계속 확장할 필요가 없습니다

사실 싱글톤에는 확장성이 필요하지 않은 소규모 게임 개발에 유용할 수 있는 장점이 있습니다

👍 싱글톤 패턴 장점

  • 싱글톤은 비교적 빨리 배울 수 있습니다
    : 코어 패턴 자체가 다소 직관적입니다
  • 싱글톤은 사용자 친화적입니다
    : 다른 컴포넌트에서 싱글톤을 사용하려 할 때, 공용 및 정적 인스턴스만 참조하면 됩니다
    싱글톤 인스턴스는 필요할 때 씬의 어떤 오브젝트에서도 항상 사용할 수 있습니다
  • 싱글톤은 성능을 보장합니다
    : 정적 싱글톤 인스턴스에 항상 글로벌 액세스가 가능하므로, 속도가 느린 경향이 있는 GetComponent 또는 Find 연산의 결과를 캐시하지 않아도 됩니다

이러한 방식으로 씬의 다른 모든 게임 오브젝트에서 항상 액세스할 수 있는 관리자 오브젝트(게임 플로 관리자 또는 오디오 관리자 등)를 만들 수 있습니다
또한 오브젝트 풀을 구현한 경우, 풀링 시스템을 싱글톤으로 디자인하면 풀링된 오브젝트를 더 쉽게 가져올 수 있습니다

⭐ 싱글톤 패턴 사용시 유의사항

  • 프로젝트에서 싱글톤을 사용하기로 했으면 최소한으로 유지하고 무분별하게 사용하지 않아야 합니다
  • 싱글톤은 글로벌 액세스의 장점을 활용할 수 있는 소수의 스크립트에만 사용해야 합니다

📌 Singleton VS Static Class


ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤSingletonㅤㅤㅤㅤㅤㅤㅤㅤStatic Classㅤㅤㅤㅤ
원리사용자 요청시 하나의 인스턴스를 생성해 재사용인스턴스를 만들 수 없고, 생성자도 갖지 않는다
인터페이스 구현가능불가능
follow OOPOX, 절차적 디자인으로 '함수'에 가깝다
override가능불가능
load필요할 때 Lazy Load 가능Static Binding in Compile-Time
performance상대적으로 느림빠름 (by static binding)
testEasierHard to mock and test
저장 위치HeapStack

👉 Singleton을 쓰는 이유

  • 고정된 메모리 영역을 가지고 하나의 인스턴스만 사용하기 때문에 메모리 낭비 방지
  • 싱글톤 클래스의 인스턴스는 전역이기 때문에 다른 클래스의 인스턴스들이 데이터를 공유하기 쉬움
  • DBCP(Database Connection Pool)처럼 공통된 객체를 여러 개 생성해야 하는 상황에 많이 사용

👉 Static Class를 쓰는 이유

  • 상태를 가지고 있지 않고 Global Access를 제공할 때 유용
  • Static은 컴파일 할 때 Static Binding으로 싱글톤보다 좀 더 빠름
  • 클래스 자체에 static을 붙여 사용할 수 없음 (inner class일 때만 가능)

📌 Singleton VS Static Class

  • OOP와 다형성의 이점 + 구현시 더 편리함에 있어 일반적으로 Singleton 선호
    • 싱글톤 패턴은 상태를 가질 수 있고 생성과 파괴 시기를 선택할 수 있기 때문에 테스트하기 쉽다
    • 싱글톤 패턴은 상속할 수 있기 때문에 다형성과 확장성을 구현할 수 있다
    • 인터페이스 구현, 유연한 코딩 가능
  • Static Class가 선호되는 경우
    • Singleton이 상태를 갖지 않고 Global Access를 제공한다면 Static Class를 고려한다
    • 정적 클래스는 싱글톤보다 컴파일 속도가 빠르기 때문에 속도가 중요한 경우 정적 클래스 사용을 고려한다

📚 참고출처

  • Level Up Your Code With Game Programming Patterns - 2021 LTS Edition Unity
  • Elenaljh.log : velog.io/@hye9807/싱글톤과-정적static-클래스-차이
profile
게임 개발자

4개의 댓글

comment-user-thumbnail
2024년 6월 13일

안녕하세요 개발자 지한님 벨로그 잘 보고 있습니다~!
제네릭 싱글톤 클래스 관련해서 추가 내용 전달해보고자 합니다.

  1. 중복 인스턴스 처리: 싱글톤은 하나의 인스턴스에 대한 권한을 줘야하기 때문에
    하나(FindObjectOfType) 가 아닌 (FindObjectsOfType) 중복된 인스턴스를
    안정적으로 처리하는 게 더 좋아보입니다.
  2. 유연한 초기화: 'dontDestroyOnLoad' 플래그 추가를 통해서 인스턴스가 삭제되지 않도록
    하는 기능을 제공하면 좋을 것 같습니다.

제가 사용하고 있는 제네릭 싱글톤 코드도 공유해드립니다!

abstract public class MonoSingleton<T> : MonoBehaviour where T : MonoBehaviour
{
    public bool dontDestroyOnLoad = false;

    static readonly string objName = $"_{typeof(T).ToString()}";

    protected void CheckDuplicate()
    {
        T[] objs = FindObjectsOfType<T>();
        if (objs.Length <= 1)
            return;

        for (int i = 0; i < objs.Length; ++i)
        {
            if (objs[i].gameObject.name != objName)
                Destroy(objs[i].gameObject);
        }
    }

    protected virtual void Start()
    {
        CheckDuplicate();

        this.gameObject.name = objName;

        if (dontDestroyOnLoad)
            DontDestroyOnLoad(this.gameObject);
    }

    private static T m_instance;
    public static T Instance
    {
        get
        {
            if (m_instance == null)
            {
                T[] objs = FindObjectsOfType<T>();

                if (objs.Length > 1)
                {
                    for (int i = 0; i < objs.Length; ++i)
                    {
                        if (objs[i].gameObject.name != objName)
                            Destroy(objs[i].gameObject);
                        else
                            m_instance = objs[i];
                    }
                }
                else if (objs.Length == 1)
                {
                    m_instance = objs[0];
                }

                if (m_instance == null)
                {
                    Debug.LogWarning($"Cannot find {typeof(T)}, instantiating new object");
                    m_instance = new GameObject(objName).AddComponent<T>();
                }
            }

            return m_instance;
        }
    }
}
2개의 답글