싱글톤(Singleton)이란 개체를 처음 사용하는 타이밍에 생성하고 이후로 생성된 개체를 재활용하는 패턴을 말합니다.
여기서 중요한 건 ' 처음 사용하는 타이밍에 초기화'입니다.
대부분의 언어가 '싱글톤'하면 구현 원리는 비슷합니다.
1) 비어있는 정적 인스턴스를 선언해 두고
2) 사용하는 타이밍에 인스턴스를 생성하고 나서
3) 다음 사용부터는 생성된 인스턴스를 리턴합니다.
이 포스팅은 이 싱글톤 구현을 C#에 맞게 구현한 포스팅입니다.
일반적인 싱글톤 구현을 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)으로 감싸야 합니다.
'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)'에서 알아서 인스턴스를 필요한 타이밍에 초기화합니다.
초기화 중에는 다른 스레드에서 접근할 수 없으므로 자연적으로 인스턴스의 무결성이 보장됩니다.
정적 초기화를 쓸 수 없는 환경이라면 직접 멀티 스레드에서 안전한 인스턴스 생성을 해야 합니다.
(소스 코드 : 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번 줄 : 여기서 인스턴스를 다시 확인하는 것은 위에서 검사한 인스턴스는 멀티 스레드에 안전하지 않은 검사였기 때문입니다.
여기서 멀티 스레드에 안전한 상태로 인스턴스를 다시 확인하여 무결성을 확보한 상태로 인스턴스를 생성합니다.
.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나 네트워크)에 사용할 수 있다고 설명하는 문서가 있는데....
어차피 멀티 스레드 관련 작업을 따로 해야 합니다.
싱글톤이 있다고 멀티 스레드 작업이 뿅하고 되는 것은 아닙니다.
별다른 작업이 없다면 싱글톤도 일반 개체와 다를 게 없습니다.