프로그래밍/C#, .NET

[.NET] C#에서의 싱글톤(Singleton)

당근천국 2024. 5. 11. 15:30

싱글톤(Singleton)이란 개체를 처음 사용하는 타이밍에 생성하고 이후로 생성된 개체를 재활용하는 패턴을 말합니다.

여기서 중요한 건 ' 처음 사용하는 타이밍에 초기화'입니다.

 

대부분의 언어가 '싱글톤'하면 구현 원리는 비슷합니다.

1) 비어있는 정적 인스턴스를 선언해 두고

2) 사용하는 타이밍에 인스턴스를 생성하고 나서

3) 다음 사용부터는 생성된 인스턴스를 리턴합니다.

 

이 포스팅은 이 싱글톤 구현을 C#에 맞게 구현한 포스팅입니다.

 

 

1. 일반적인 구현 

일반적인 싱글톤 구현을 C#으로 구현하면 아래와 같습니다.

(소스 코드 : dang-gun/DotNetSamples/SingletonTest/Singletons/Gamma95.cs )

/// <summary>
/// 《디자인 패턴》[Gamma95]에서 제시된 싱글톤을 C#에 맞게 구현한 클래스
/// </summary>
public class Gamma95
{
    /// <summary>
    /// 실제 개체
    /// </summary>
    private static Gamma95? instance;
    private Gamma95() { }

    /// <summary>
    /// 개체 리턴
    /// </summary>
    public static Gamma95 Instance
    {
        get
        {
            if (instance == null)
            {//이미 생성된 개체가 없다.

                //새 개체를 생성한다.
                instance = new Gamma95();
            }
            return instance;
        }
    }
}

 

17번 줄 : 인스턴스에 접근하게 되면 'Getter/Setter' 패턴에 의해 기존 인스턴스가 생성되었는지 확인하고

없으면 생성, 있으면 기존 인스턴스를 리턴합니다.

 

 

이 방법은 스레드에 안전하지 않습니다.

스레드에 안전한 코드를 만들고 싶다면 'if (instance == null)'이 부분을 락(lock)으로 감싸야 합니다.

 

 

2. 정적 초기화(Static Initialization) 

'1. 일반적인 구현'에는 C++에서 정적변수 초기화 순서로 인한 오류 회피 코드가 포함되어 있습니다.

.NET에서는 이 문제가 없으므로 회피 코드를 제거하고 바로 정적 초기화를 해도 됩니다.

(소스 코드 : dang-gun/DotNetSamples/SingletonTest/Singletons/StaticInitialization.cs )

/// <summary>
/// 정적 초기화
/// <para>C++의 정적변수 초기화 순서 문제로인한 회피코드를 제거한 코드</para>
/// </summary>
public sealed class StaticInitialization
{
    /// <summary>
    /// 실제 개체
    /// </summary>
    private static readonly StaticInitialization instance = new StaticInitialization();

    private StaticInitialization() { }

    /// <summary>
    /// 개체 리턴
    /// </summary>
    public static StaticInitialization Instance
    {
        get
        {
            return instance;
        }
    }
}

 

10번 줄 : .NET 4 이후로는 정적 개체는 접근 시에 초기화가 일어납니다.(스레드에 안전함)

그러니 별도의 개체 생성 코드나 인스턴스 확인이 필요 없습니다.

 

 

이 방식은 '공용 언어 런타임(common language runtime)'에서 알아서 인스턴스를 필요한 타이밍에 초기화합니다.

초기화 중에는 다른 스레드에서 접근할 수 없으므로 자연적으로 인스턴스의 무결성이 보장됩니다.

 

 

3. 멀티 스레드 싱글톤(Multithreaded Singleton) 

정적 초기화를 쓸 수 없는 환경이라면 직접 멀티 스레드에서 안전한 인스턴스 생성을 해야 합니다.

(소스 코드 : dang-gun/DotNetSamples/SingletonTest/Singletons/MultithreadedSingleton.cs )

/// <summary>
/// 멀티스레드 상황에서 사용하는 싱글톤
/// <para>여러 스레드에서 한번에 개체에 접근할때도 1개의 개체를 보장하기위한 구현</para>
/// </summary>
public sealed class MultithreadedSingleton
{
    /// <summary>
    /// 실제 개체
    /// </summary>
    private static volatile MultithreadedSingleton? instance;
    /// <summary>
    /// 단일 스레드 잠금을 위한 개체
    /// </summary>
    private static object syncRoot = new Object();

    private MultithreadedSingleton() { }

    /// <summary>
    /// 개체 리턴
    /// </summary>
    public static MultithreadedSingleton Instance
    {
        get
        {
            if (instance == null)
            {
                lock (syncRoot)
                {//스레드를 잠금

                    //개체를 확인하고 생성하는 동안
                    //다른 스레드는 인스턴스 사용을 대기하게 된다.

                    if (instance == null)
                    {//이미 생성된 개체가 없다.

                        //새 개체를 생성한다.
                        instance = new MultithreadedSingleton();
                    }
                        
                }
            }

            return instance;
        }
    }
}

 

10번 줄 : 'volatile' 한정자는 변수 수정 시 원자성을 보장하기 위한 한정자입니다.

(참고 : MS Learn - volatile(C# 참조),  Volatile 클래스, 15.5.4 Volatile fields )

변수를 읽고/쓰는 과정이 코드로 보면 한 줄이지만 실제 동작은 캐싱 등의 여러 단계로 쪼개져 있습니다.

 

멀티 스레드 환경에서는 이 단계가 진행되는 중에 다른 스레드에서도 같은 동작이 일어날 수 있습니다.

이렇게 되면 캐싱 된 데이터로 인해 의도하지 않은 값이 저장되는 오류가 발생할 수 있습니다.

(DB로 치면 동시성 문제로 인해 무결성이 깨지는 현상입니다.)

이것은 코드 실행을 최적화 하기 위해 여러 가지 방법으로 물리적인 분산(CPU코어나 CPU스레드)을 하는데 이때 메모리를 재정렬하므로 발생합니다.

 

'volatile' 한정자는 이 메모리 재정렬을 제한하여 동작 순서를 보장하게 됩니다.

 

 

14번 줄 : 스레드 잠금을 위한 오브젝트(Object)입니다.

'private static'로 선언하면 해당 개체에 접근할 때 개체가 생성됩니다.

이 개체는 프로그램에서 한 개만 존재하므로 이 개체를 통해 락을 걸면 다른 스레드에서는 락이 풀릴때까지 대기하게 됩니다.

 

25번 줄 : 스레드 락(lock)을 걸어둔 상태로 인스턴스를 확인하면 인스턴스를 확인하는 동안, 이 인스턴스에 접근하는 다른 스레드가 대기하는 비효율이 발생합니다.

그래서 락 없이 먼저 확인합니다.

다만 이것으로 인해 얻을 수 있는 이득은 그렇게 많지 않다고 합니다.

 

27번 줄 : 인스턴스를 생성하기 전에 락을 걸어 다른 스레드에서 접근하지 못하도록 합니다.

 

33번 줄 : 여기서 인스턴스를 다시 확인하는 것은 위에서 검사한 인스턴스는 멀티 스레드에 안전하지 않은 검사였기 때문입니다.

여기서 멀티 스레드에 안전한 상태로 인스턴스를 다시 확인하여 무결성을 확보한 상태로 인스턴스를 생성합니다.

 

 

4. 'Lazy<T>'를 이용한 방법

.NET 4 이상부터는 'Lazy<T>'를 사용할 수 있습니다.

(참고 : MS Learn - Lazy<T> 클래스)

(소스 코드 : dang-gun/DotNetSamples/SingletonTest/Singletons/LazyT.cs)

/// <summary>
/// Lazy<T>를 이용한 싱글톤 구현
/// <para>.NET4 이상을 사용하는 경우 Lazy<T>를 사용할 수 있다.</para>
/// </summary>
public sealed class LazyT
{
    /// <summary>
    /// 실제 개체
    /// </summary>
    private static readonly Lazy<LazyT> lazy 
        = new Lazy<LazyT>(() => new LazyT());

    private LazyT() { }

    /// <summary>
    /// 개체 리턴
    /// </summary>
    public static LazyT Instance 
    { 
        get 
        { 
            return lazy.Value; 
        } 
    }
}

 

 

'Lazy<T>'를 사용하면 '3. 멀티 스레드 싱글톤(Multithreaded Singleton)'에서의 구현과 동일하게 동작합니다.

'Lazy<T>'는 스레드의 안전한 초기화를 보장하기 때문입니다.

 

 

마무리 

참고 :

MS Learn - Implementing Singleton in C#, Singleton

csharpindepth - Implementing the Singleton Pattern in C#

 

샘플 : github - dang-gun/DotNetSamples/SingletonTest

 

샘플을 보면 'GlobalStatic.LogTime()'를 호출하는 순간 'StaticNoSingleton'가 초기화되는 것을 알 수 있다.

 

이것으로 정적 클래스는 생성하는 순간이 아닌 호출하는 순간 생성된다는 것을 알 수 있습니다.

 

일반적인 정적 변수는 '프로그램 전역 변수'라는 의미도 가지므로

프로로그램이 실행되면 초기화 된다는 생각을 할 수 있는데 .NET 4 이후로는 처음 접근했을 때 생성됩니다.

그래서 .NET에서는 싱글톤을 구현할 때 정적 초기화를 해도 되는 것입니다.

 

 

대부분의 경우 '2. 정적 초기화'를 사용하면 됩니다.(이 대부분에 속하지 않는 경우가 얼마나 될지 모르겠지만....)

만약 정적 초기화를 믿을 수 없는 상황인데 .NET 4이상의 환경이면 '4. 'Lazy<T>'를 이용한 방법'를 쓰는 것이 좋습니다.

 

 

사실 싱글톤 패턴 쓸 일이 많지 않습니다.

특히 전역 변수가 필요한 곳에 싱글톤을 난발하는 경우가 많은데......

원래 프로그램을 설계할 때 전역 변수조차 안 쓰는 걸 권장하니 싱글톤이 나설 일이 더욱 없죠.

 

특히 .NET 4의 정적변수(혹은 클래스)는 접근할 때 초기화가 되고 심지어 생성 시 스레드에 대한 안전을 보장하므로 싱글톤을 사용할 일이 더욱 없습니다.

그럼에도 .NET 4에서 싱글톤을 사용하는 건 대부분은 관습이고, 가끔 하위 호환이 필요한 경우입니다.

(.NET의 낮은 버전에서도 호환시키려는 목적)

 

저의 경우 코드의 명확성을 높이려는 목적으로 사용합니다.

일반 정적 변수는 프로그램이 초기화될 때 같이 초기화된다고 생각하고 사용하고,

싱글톤은 접근 시에 초기화된다고 생각하도록 유도하는 목적으로 사용하곤 합니다.

 

싱글톤 패턴은 리소스에 대한 액세스 제어(IO나 네트워크)에 사용할 수 있다고 설명하는 문서가 있는데....

어차피 멀티 스레드 관련 작업을 따로 해야 합니다.

싱글톤이 있다고 멀티 스레드 작업이 뿅하고 되는 것은 아닙니다.

별다른 작업이 없다면 싱글톤도 일반 개체와 다를 게 없습니다.