디자인패턴

싱글톤(Singleton)

SniKuz 2024. 6. 26. 15:50

목차

 

Singleton

게임개발을 하며 디자인 패턴을 생각하면 가장 처음 떠오르는, 아마 GoF의 디자인패턴 책에서 가장 유명하고 가장 무분별하게 쓰이는 패턴이 싱글톤 패턴일 것입니다.GoF를 포함한 모든 글에서 싱글톤을 남용하지 말라 강조하지만 잘 지켜지지 않습니다.
'게임프로그래밍 패턴 (Robert Nystrom)'에서도 '이번 장은 어떻게 하면 패턴을 안 쓸 수 있는지를 보여준다는 점에서 다른 장과는 정반대다.'라 말하는 것을 볼 때 "어떻게 하면 싱글톤을 안쓰고 문제를 해결할 수 있을까?"에 초점을 맞추는 것이 싱글톤을 이해하는 좋은 방향성이라 생각됩니다.

 

 

목적

싱글톤은 '오직 1개의 인스턴스만 갖도록 보장'한다는 점과 '전역 접근을 제공'한다는 점 총 2개의 문제를 한 번에 해결하는 디자인 패턴입니다.

● 오직 1개의 인스턴스만 갖도록 보장
인스턴스가 여러 개면 제대로 작동하지 않는 몇몇 상황이 있을 수 있습니다. 이런 경우 싱글톤을 사용하면 클래스가 인스턴스를 하나만 가지도록 컴파일 단계에서 강제할 수 있습니다.

●  전역 접근 제공
여러 클래스에서 싱글톤 클래스의 인스턴스를 따로 생성할 수 없기에 어떻게 접근할지에 대한 고민이 생깁니다. 이 때 싱글톤 패턴은 전역으로 접근할 수 있는 기능을 제공합니다.

이 덕분에 다음과 같은 장점도 가지고 있습니다.

  사용되기 전에는 인스턴스를 생성하지 않는다
싱글톤 클래스는 처음 사용될 때 인스턴스를 생성합니다. Unity Memory Profiler를 확인하면 최초 로드를 하면서 생성되는 것을 알 수 있습니다.

로드 전에는 없는 상태
로드 후에 확인 가능

  런타임에 초기화 됩니다
전역 접근 제공을 위해 정적 멤버 변수를 대안으로 쓰기도 합니다. 이 때 정적 멤버 변수는 자동 초기화되는 문제가 있다는데, 이는 컴파일러가 main함수 호출 전에 정적 변수를 초기화하기 때문에 프로그램 실행 이후에 알 수 있는 정보(파일 내용 등)은 쓸 수 없다는 문제점입니다. 또한 정적 변수 초기화 순서도 컴파일러가 보장하지 않기 때문에 한 정적 변수가 다른 정적 변수를 안전하게 의존할 수 없습니다.
이를 위해 싱글톤에서 *게으른 초기화 방법으로 초기화 순간을 뒤로 늦춰, 프로그램 실행 이후 필요한 정보를 준비하고 초기화할 수 있습니다. 또 순환 의존만 없다면 싱글톤간의 참조도 가능합니다.

* 게으른 초기화 : 객체 생성, 값 계산, 일부 기타 비용이 많이 드는 과정을 처음 필요한 시점까지 지연시키는 기법.

  싱글톤은 상속할 수 있습니다
파일 시스템 래퍼가 크로스 플랫폼을 지원한다면 추상 인터페이스 아래 플랫폼 별 구체 클래스를 만들고, 인터페이스에 인스턴스로 접근할 수 있습니다. 또한 단위 테스트용 *모의 객체(mock object)를 만들 때도 유용하다고 합니다.

* 주로 OOP에서 개발한 프로그램을 테스트할 경우 실제 모듈을 흉내내는 가짜 모듈을 작성하여 테스트의 효용성을 높이는데 사용하는 객체

 

 

문제점

● 전역변수
싱글톤을 전역 접근을 제공한다는 점에서 클래스로 캡슐화한 전역변수와 다를게 없습니다. 
전역변수는 접근이 쉬운 만큼 온갖 클래스에서 다 쓸 수 있습니다. 어느 클래스는 전역 변수를 이상하게 변경하고 있을 수 있고, 마치 거미줄처럼 얽히고 설켜 복잡한 코드와 커플링을 만들 수 있습니다.
또한, 전역 변수는 멀티스레딩과 같은 동시성 프로그래밍에 적합하지 않습니다.

● 언제나 2개의 문제를 한 번에 해결한다.
보통 싱글톤을 쓸 때 '단 하나의 인스턴스임을 보장'과 '전역 접근' 2가지 문제를 한 번에 해결합니다. 하지만 문제가 1개라면 2개의 문제를 해결하는게 오히려 독일 수 있습니다. 단 하나의 인스턴스임을 보장하고 싶지만 이 때 전역 접근 또한 제공한다면 다른 클래스에서 무분별하게 인스턴스를 훼손시킬 수도 있습니다.

▶️ 단 하나의 인스턴스 보장 
한 가지 방법으로 클래스 내에 private 전역 변수 1개를 만들어 생성자에서 *단언문을 사용해 디버깅 중 단 하나의 인스턴스만 가지도록 보장할 수 있습니다. 단 싱글톤은 컴파일 시간에 단일 인스턴스를 보장하지만, 이 방법은 런타임에 인스턴스 개수를 확인한다는 단점이 있습니다. 다른 방법을 고민해볼 수도 있습니다.

class FileSystem
{
    private static bool instantiated;
    FileSystem()
    {
    	Debug.Assert(!instantiated);
        instantiated = true;
    }
    ~FileSystem()
    {
    	instantiated = false;
    }
}

*단언문 : 코드에 제약을 넣을 때 쓰며 값이 거짓이면 중지합니다.

▶️ 전역 접근
정적 멤버 변수 혹은 전역 클래스를 만들어 접근점을 제공할 수 있습니다. 혹은 의존성 주입과 같이 객체를 필요로 하는 함수에 인수로 제공할 수 있습니다. 옵저버 패턴을 통해 전달할 수도 있습니다.
가장 좋은 해결법은 충분한 추상화와 구조를 설계해 패턴을 굳이 쓸 상황을 만들지 않도록 노력할 수 있습니다.

 

 게으른 초기화는 제어할 수 없습니다.
가상 메모리도 사용할 수 있고 성능 요구도 심하지 않은 데스크톱 PC에서는 게으른 초기화가 괜찮은 기법이라 합니다.
하지만 게임에 경우는 상황이 다른데, 시스템을 초기화할 때  시간이 꽤 걸리기에 처음 소리를 재생할 때 게으른 초기화를 하게 만들면 전투 도중에 초기화가 시작되어 프레임 드랍이 생길 수 있습니다. 또한 메모리 단편화를 막기 위해 힙에 메모리 할당을 여러 기준을 두는데 위 시스템을 초기화할 때 적절한 위치와 시점도 찾아야 합니다.

 

 

Unity Singleton

Unity에서 싱글톤을 만드는 방법은 크게 2가지로 나뉩니다. Unity API인 MonoBehaviour를 상속받아 만들거나, 기본 C# 파일로 만드는 방법이 있습니다.

MonoBehaviour를 상속받는 경우 Unity API가 메인 스레드에서만 관리를 받기에 싱글 스레드로 작동합니다. 그래서 스레드에 관한 고민을 안해도 됩니다. 또한 Unity API를 사용할 수 있습니다. 대신 GameObject로 존재하니 씬 이동시 이미 해당 씬에 있다면 어떤을 사용할지, 중복 생성, 파괴 시 고려할 점들이 있습니다.

더보기
public abstract class MonoBehaviourSingleton<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T instance;
    public static T Instance
    {
        get
        {
            if(instance == null)
            {
                instance = FindFirstObjectByType<T>();

                if(instance == null)
                {
                    SetupInstance();
                }
            }
            return instance;
        }
    }

    private static void SetupInstance()
    {
        var singletonObject = new GameObject
        {
            name = typeof(T).Name
        };
        instance = singletonObject.AddComponent<T>();
        DontDestroyOnLoad(singletonObject);
    }

    private void Awake()
    {
        RemoveDuplitcates();
    }

    private void RemoveDuplitcates()
    {
        if(instance == null)
        {
            instance = this as T;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
        }
    }
}

/*
    How to use
    public class TestSingleton : MonoBehaviourSingleton<TestSingleton>
    {
    	// ....
    }
*/

다른 예시로 Unity VisualScripting의 Singleton<T>도 확인하면 좋을 것 같습니다.

 

기본 C# 클래스로 만들 경우 씬 이동 등을 고려하지 않아도 됩니다. 대신 멀티 스레드에서 접근하는 경우가 있다면 이에 대한 대비가 필요합니다. 

다음 예시는 volatile 구문을 사용해 CPU 내 캐시와 메모리를 동기화하고(캐시 최적화 제외) lock을 사용함으로써 경쟁 상태를 해결합니다.
*C#에서는 volatile 구문이 캐시 최적화 제외, 코드 최적화 제외 등등 여러가지 일을 하는 형태로 작동하기에 해당 구문 사용을 추천하지 않는다고 합니다.

더보기
public class LazyInitSingleton
    {
        private static volatile LazyInitSingleton instance;
        private static readonly object padlock = new object();

        private LazyInitSingleton() {}
        public static LazyInitSingleton Instance
        {
            get
            {
                if (instance == null) //여기서는 null
                {
                    lock(padlock)
                    {
                        if(instance == null) //여기서는 null이 아닐 수 있음
                        {
                            instance = new LazyInitSingleton(); //1cycle clock이 아닐 수 있음
                        }
                       
                    }
                }
                return instance;    
            }
        }
    }

 

static, readonly, 즉시초기화를 쓰면 immutable하게 생성을 즉시하기에 스레드 세이프하게 사용할 수 있습니다.

더보기
public class Singleton
    {
        private static readonly Singleton instnace = new Singleton();
        private Singleton() { }
        public static Singleton Instance
        {
            get
            {
                return instnace;
            }
        }
        ~Singleton() { }    
    }

 

 

 

정리

만능 마술봉처럼 매니저에 다 넣어버리는고 쉽게 쓰다보니 여러 글에서 오남용하지 말라는 내용이 많은 것 같습니다. 특히 디자인패턴을 보면서 싱글톤만큼 '망치를 든 사람에겐 모든 문제가 못으로 보인다'가 어울리는 패턴이 없는 것 같습니다.

장기적으로 볼 때는 싱글톤 사용 자체를 자제하는 것이 공부하는 것에는 더 도움이 되는 방향이라 생각됩니다.
게임에서는 게임 매니저와 같이 매니저라는 이름을 가지고 다른 객체를 관리하는 용도로 존재하는 경우가 많은 것 같은데 이때 관리하는 싱글톤 클래스가 진짜로 필요한 것인지 고민하고 장단점을 고려하며 써야할 것 같습니다.

Singleton이 글에서 대부분 '싱글톤'으로 번역하는데, 외국 글을 번역한 글에서는 대부분 '싱글턴'으로 쓰고 있고 실제 발음은 '톤'보다는 '턴'에 가깝다는 것 같습니다.