Singleton

Singleton 패턴이란?

Singleton 패턴은 특정 클래스의 인스턴스를 하나만 생성하여 전역적으로 접근할 수 있도록 보장하는 디자인 패턴입니다. 시스템 내에서 공통적으로 사용되는 리소스나 객체를 관리하는 데 유용하며, 데이터베이스 연결, 설정 관리, 로깅 시스템 등과 같은 자원을 전역적으로 접근할 필요가 있을 때 사용됩니다.

Singleton 패턴 구조

D2 Diagram

  • Client → Singleton: GetInstance()
    • 클라이언트는 Singleton 클래스의 GetInstance() 메서드를 호출하여 싱글턴 객체를 요청합니다.
  • Singleton → Singleton: Create Instance (If Not Exists)
    • 싱글턴 클래스는 내부적으로 Static Instance를 확인하고, 존재하지 않을 경우 새로운 인스턴스를 생성합니다.
    • 이미 존재하면 기존 인스턴스를 반환합니다.
  • Client → Singleton: Access Instance Methods or Properties
    • 클라이언트는 반환된 싱글턴 인스턴스를 사용하여 메서드 호출이나 속성 접근을 수행합니다.
  • Singleton: Private Constructor
    • 생성자가 Private으로 설정되어 있어 외부에서 객체를 직접 생성할 수 없습니다.

Singleton 패턴 적용

Singleton 패턴의 필요성

도서 관리 시스템에서 데이터베이스 연결이나 설정 파일 등, 시스템 전반에 걸쳐 하나의 인스턴스로 관리해야 하는 객체들이 존재할 수 있습니다. 이러한 객체는 여러 번 생성할 필요가 없으며, 자원을 절약하고 일관성을 유지하기 위해 단일 인스턴스만을 유지하는 것이 중요합니다.

잘못된 처리

하나의 객체를 여러 번 생성하여 사용하는 방식은 자원 낭비와 일관성 문제를 야기할 수 있습니다. 예를 들어, 데이터베이스 연결 객체를 매번 생성한다면 자원이 낭비되고, 여러 연결이 충돌할 수 있습니다.

public class DatabaseConnection
{
    public DatabaseConnection()
    {
        // 데이터베이스 연결 생성
        Console.WriteLine("Database connection created.");
    }
}
  • 인스턴스 관리의 어려움 : 객체가 여러 번 생성되면 자원 관리가 어려워지고, 메모리 낭비가 발생할 수 있습니다.
  • 일관성 문제 : 여러 인스턴스가 서로 다른 상태를 가질 수 있으므로, 동일한 자원에 대해 여러 개의 연결이 생겨 일관성 문제가 발생할 수 있습니다.

Singleton 패턴 적용 예시

Singleton 패턴을 사용하면 클래스의 인스턴스가 하나만 생성되고, 전역적으로 접근할 수 있게 됩니다.

public class DatabaseConnection
{
    private static DatabaseConnection _instance;
    private static readonly object _lock = new object();
    // 생성자를 private로 설정하여 외부에서 인스턴스 생성 금지
    private DatabaseConnection()
    {
        Console.WriteLine("Database connection created.");
    }
    // 전역적으로 접근할 수 있는 인스턴스 반환 메서드
    public static DatabaseConnection Instance
    {
        get
        {
        // 멀티스레드 환경에서 안전하게 인스턴스를 생성하기 위해 Lock 사용
	        lock (_lock)
	        {
	            if (_instance == null)
	            {
	                _instance = new DatabaseManager();
	            }
	            return _instance;
			}
        }    
    }
    public void Connect()
    {
        Console.WriteLine("Connected to the database.");
    }
}
// 클라이언트 코드
public class Program
{
    public static void Main(string[] args)
    {
        DatabaseConnection conn1 = DatabaseConnection.Instance;
        conn1.Connect();
        DatabaseConnection conn2 = DatabaseConnection.Instance;
        conn2.Connect();
        // 동일한 인스턴스임을 확인
        Console.WriteLine(object.ReferenceEquals(conn1, conn2)); // True
    }
}
  • Singleton 인스턴스는 처음 요청될 때까지 생성되지 않습니다. 이를 통해 자원을 절약하고, 불필요한 인스턴스 생성을 방지할 수 있습니다.
  • lock 키워드를 사용하여 멀티스레드 환경에서 인스턴스가 여러 번 생성되는 문제를 방지합니다. 이를 통해 다중 스레드가 동시에 Singleton 인스턴스에 접근하더라도 안전하게 하나의 인스턴스만 유지할 수 있습니다.

Singleton 패턴 구성 요소

Singleton 클래스

인스턴스를 하나만 생성하고, 전역적으로 접근할 수 있게 관리하는 클래스입니다. 예제에서는 DatabaseConnection 클래스가 Singleton으로 사용됩니다.

Private 생성자

외부에서 객체를 생성하지 못하도록 생성자를 private으로 제한하여 클래스 외부에서 직접 인스턴스를 생성하지 못하게 합니다.

Static Instance

Instance라는 속성은 Singleton 클래스의 인스턴스를 반환하는 메서드입니다. 이 메서드는 클래스의 인스턴스가 존재하지 않을 때는 새로 생성하고, 이미 존재하는 경우에는 기존 인스턴스를 반환합니다.

적용 시 유의 사항

전역 상태 관리의 위험성

Singleton 패턴을 사용하면 전역적으로 상태를 관리할 수 있지만, 전역 상태가 많아지면 디버깅이나 유지보수가 어려워질 수 있습니다. 특히 객체의 상태가 예상치 못하게 변경될 수 있어, 의도하지 않은 동작을 일으킬 위험이 있습니다. 전역 상태는 필요할 때만 최소한으로 사용하고, 필요 이상으로 의존하지 않도록 설계하는 것이 중요합니다. Singleton 클래스는 가능하면 불변 상태Immutable를 유지하거나, 상태를 외부에서 직접 변경하지 않도록 해야 합니다. 전역 상태를 관리할 때, Singleton 인스턴스의 초기화와 소멸 주기를 명확하게 관리해야 합니다. 상태를 필요에 따라 초기화하거나 재설정하는 방법을 고려할 수 있습니다.

멀티스레딩

멀티스레드 환경에서는 인스턴스가 중복 생성될 수 있습니다. 이를 방지하기 위해 lock 문을 사용하여 스레드 안전성을 보장할 수 있습니다.

테스트의 어려움

Singleton 패턴은 전역적으로 인스턴스가 관리되므로, 단위 테스트에서 의존성이 높은 Singleton 객체를 다루기가 어려울 수 있습니다. Singleton 인스턴스가 테스트 환경에서 공유되면, 테스트의 독립성이 손상될 수 있습니다.

Mocking과 Dependency Injection

테스트 시 Singleton 패턴을 사용한 객체를 대신할 수 있는 Mock객체를 활용하거나, 의존성 주입Dependency Injection을 통해 테스트 환경에서 다른 객체를 주입받아 테스트할 수 있도록 설계할 수 있습니다.

리셋 가능한 Singleton

테스트 환경에서 Singleton 객체를 초기화하거나 리셋할 수 있는 메서드를 제공하여, 테스트의 독립성을 유지할 수 있도록 해야 합니다.

객체지향 원칙과의 관계

Singleton과 캡슐화

Singleton 패턴은 인스턴스 생성을 외부로부터 숨기고, 전역적으로 단일 인스턴스에만 접근하도록 캡슐화합니다. Instance 속성을 통해서만 인스턴스에 접근할 수 있으므로, 인스턴스 생성과 관리를 안전하게 제어할 수 있습니다.

Singleton과 단일 책임 원칙

Singleton 패턴의 주된 역할은 인스턴스를 하나만 유지하고 관리하는 것입니다. 이 점에서 단일 책임 원칙SRP을 따릅니다. 인스턴스 생성과 관리라는 책임이 분리되므로, 다른 클래스가 직접적으로 인스턴스를 생성하거나 관리할 필요가 없습니다. 그러나 SRP를 완전히 준수하려면 Singleton 클래스가 단순히 인스턴스 관리만 하고, 과도한 비즈니스 로직을 포함하지 않도록 주의해야 합니다. 즉, 인스턴스 관리 외에 불필요한 책임이 추가되지 않도록 설계해야 합니다.

Singleton과 개방_폐쇄 원칙

Singleton 패턴은 기본적으로 인스턴스가 하나만 존재해야 하므로, 기능 확장에는 제한이 있습니다. 그러나 인스턴스 생성 로직을 변경하지 않고도 확장할 수 있는 방법을 제공하기도 합니다. 예를 들어, 새로운 기능을 추가할 때 Instance 속성을 그대로 유지하면서 내부 로직을 확장할 수 있습니다. 그러나 Singleton 클래스 자체가 비대해지거나 다양한 기능을 포함하게 되면, 개방_폐쇄 원칙OCP를 위반할 가능성이 커집니다. 이를 피하기 위해서는 Singleton을 복잡한 기능 대신 인스턴스 관리 역할에만 집중시켜야 합니다.

Singleton과 의존 역전 원칙

Singleton 패턴은 구현 방식에 따라 의존 역전 원칙DIP을 위반할 수 있습니다. Singleton 클래스가 전역 상태에 의존하거나 구체적인 클래스에 강하게 의존하게 되면 DIP를 위반하게 됩니다. 이를 해결하기 위해 Singleton이 인터페이스나 추상 클래스를 통해 인스턴스를 제공하도록 설계할 수 있습니다. 즉, 클라이언트는 구체적인 Singleton 클래스가 아닌 추상화된 인터페이스에 의존하게 되어, DIP를 준수하면서도 Singleton 패턴을 활용할 수 있습니다.

Singleton과 상속 및 확장성의 문제

Singleton 패턴은 기본적으로 인스턴스를 하나만 유지해야 한다는 제약 때문에 상속을 통한 확장이 어려운 구조를 가집니다. 이를 통해 개방-폐쇄 원칙을 완벽히 준수하지 못할 수 있습니다. 상속을 통한 확장이 필요한 경우, 구성Composition을 사용하여 확장성을 높이는 방식을 고려할 수 있습니다. Singleton 클래스 자체의 비대화를 방지하고, 기능 확장은 별도의 구성 요소로 관리하는 방식이 유리합니다.

맺음말

Singleton 패턴은 시스템 내에서 단일 인스턴스가 필요한 자원을 안전하고 효율적으로 관리하는 데 유용한 패턴입니다. 멀티스레드 환경에서도 안정적으로 작동할 수 있으며, 자원 낭비를 줄이고 코드의 일관성을 유지할 수 있습니다. 하지만 전역 상태 관리에 따른 위험성과 테스트의 어려움에 대해 주의해야 하며, 적절한 사용이 필요합니다.

심화 학습

Double-Checked Locking

멀티스레드 환경에서 Singleton 패턴을 구현할 때 가장 중요한 것은 스레드 안전성이며, Singleton 인스턴스가 여러 스레드에 의해 동시에 생성될 수 없도록 보장해야 합니다. 이를 해결하기 위해 lock 문을 사용하여 인스턴스가 중복 생성되지 않도록 할 수 있지만, lock은 인스턴스가 생성된 이후에도 계속 호출되기 때문에, 불필요한 락을 사용하게 되어 성능 저하를 유발할 수 있습니다.

Double-Checked Locking의 개념

Double-Checked Locking은 이러한 성능 문제를 해결하기 위한 최적화 방법입니다. 인스턴스가 이미 생성된 경우에는 lock을 사용하지 않고 바로 반환하고, 인스턴스가 없을 때만 lock을 사용하여 인스턴스를 생성합니다.

public class Singleton
{
    private static Singleton _instance;
    private static readonly object _lock = new object();
    private Singleton() {}
    public static Singleton Instance
    {
        get
        {
            // 첫 번째 검사: 이미 인스턴스가 생성되었는지 확인
            if (_instance == null)
            {
                lock (_lock)  // 두 번째 검사에서만 락 사용
                {
                // 두 번째 검사: 락을 획득한 후 다시 인스턴스 확인
                    if (_instance == null)  
                    {
                        _instance = new Singleton();
                    }
                }
            }
            return _instance;
        }
    }
}

.NET에서는 메모리 모델과 컴파일러 최적화로 인해 특정 상황에서 Double-Checked Locking이 제대로 동작하지 않을 수 있습니다. 이를 해결하기 위해 volatile 키워드를 사용할 수 있습니다.

private static volatile Singleton _instance;

volatile 키워드는 컴파일러가 메모리 읽기 및 쓰기 순서를 최적화하는 것을 방지하여, 모든 스레드가 항상 메모리에서 최신 인스턴스 상태를 읽도록 보장합니다. 다만, NET에서는Lazy Singleton를 사용하는 것이 더 권장됩니다.

Lazy Singleton

Lazy Initialization의 필요성

기본 Singleton 패턴 구현에서 인스턴스는 클래스가 처음 로드될 때 즉시 생성되거나, 필요할 때 즉시 생성될 수 있습니다. 후자의 경우, 멀티스레드 환경에서는 여러 스레드가 동시에 Singleton 인스턴스를 요청할 때, 중복된 인스턴스가 생성될 가능성이 있습니다. 이를 방지하기 위해 Lazy Initialization 기법을 활용하면, 인스턴스가 필요할 때까지 생성이 지연되며, 안전하게 단일 인스턴스를 유지할 수 있습니다.

Lazy Singleton 패턴 구현

C#에서는 Lazy<T> 클래스를 사용하여 Lazy Initialization을 간단하게 구현할 수 있습니다. 다음은 도서관 관리 시스템에서 Lazy Singleton 패턴을 적용한 예제입니다.

public class DatabaseManager
{
    // Lazy<T>를 사용하여 Lazy Singleton 인스턴스 생성
    private static readonly Lazy<DatabaseManager> _instance = new Lazy<DatabaseManager>(() => new DatabaseManager());
    // 데이터베이스 연결 객체
    private readonly SqlConnection _connection;
    // private 생성자: 외부에서 인스턴스 생성 방지
    private DatabaseManager()
    {
        _connection = new SqlConnection("YourConnectionStringHere");
        _connection.Open();
    }
    // Singleton 인스턴스 반환 메서드
    public static DatabaseManager Instance => _instance.Value;
    // 데이터베이스 연결 객체 반환 메서드
    public SqlConnection GetConnection() => _connection;
    
}
  • Lazy<T> 클래스: Lazy<DatabaseManager>를 사용하여 DatabaseManager의 인스턴스 생성을 지연시킵니다. 인스턴스는 처음 Instance 속성이 접근될 때 생성됩니다.
  • private 생성자: 클래스 외부에서 인스턴스 생성을 방지합니다.
  • Instance 속성: Lazy<DatabaseManager> 객체의 Value 속성을 통해 Singleton 인스턴스를 반환합니다. 이때 인스턴스가 아직 생성되지 않았다면, 그 시점에서 인스턴스가 생성됩니다.

Singleton 패턴과 Lazy Singleton 패턴의 차이 및 비교

초기화 시점

  • Singleton: 클래스가 처음 호출되거나 로드될 때 인스턴스가 생성됩니다.
  • Lazy Singleton: 인스턴스가 실제로 필요할 때까지 생성이 지연됩니다. 자원 사용 최적화
  • Singleton: 애플리케이션 시작 시 인스턴스가 생성되어 메모리를 차지하지만, 사용되지 않을 수 있습니다.
  • Lazy Singleton: 인스턴스가 필요할 때만 생성되므로 메모리 자원을 절약할 수 있습니다. 구현 복잡성
  • Singleton: 기본적으로 간단하지만 멀티스레드 환경에서 추가 동기화 코드가 필요할 수 있습니다.
  • Lazy Singleton: Lazy<T> 클래스를 사용해 간단하게 구현하며, 스레드 안전성을 기본적으로 제공합니다. 스레드 안전성
  • Singleton: lock 문 등으로 스레드 안전성을 관리해야 합니다.
  • Lazy Singleton: Lazy<T>를 사용하면 스레드 안전성을 별도 코드 없이 기본 제공받습니다.

Lazy Initialization 사용 주의점

초기화 지연이 필요 없는 경우 : 인스턴스 생성이 간단하고, 지연이 필요하지 않다면 Lazy Initialization은 오버헤드가 될 수 있으며, 기본 Singleton 패턴이 더 적합할 수 있습니다. 초기화 중 예외 처리 : 인스턴스 생성 중 예외가 발생하면, 이후 접근 시마다 동일한 예외가 발생할 수 있습니다. 따라서 초기화 과정에서 발생할 수 있는 예외를 잘 처리해야 합니다.

Reflection과 Singleton 파괴

싱글턴 패턴은 기본적으로 클래스의 생성자를 private으로 설정해 외부에서 새로운 인스턴스를 생성하지 못하게 합니다. 그러나 Reflection을 사용하면 private 생성자를 강제로 호출하여 새로운 인스턴스를 만들 수 있습니다. 이로 인해 Singleton 패턴의 의도와 다르게 인스턴스가 여러 개 생길 수 있습니다.

var instance1 = Singleton.Instance;
var constructor = typeof(Singleton).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, new Type[0], null);
var instance2 = constructor.Invoke(null) as Singleton;

이 코드에서 instance1instance2는 서로 다른 인스턴스가 됩니다.
이를 방지하기 위해서는 예외 처리를 사용하여 생성자를 추가 호출하려고 할 때 강제로 예외를 발생시키는 방법을 사용할 수 있습니다.

public class Singleton
{
    private static readonly Singleton _instance = new Singleton();
    private static bool _isInstanceCreated = false;
    private Singleton()
    {
        if (_isInstanceCreated)
        {
            throw new InvalidOperationException("Cannot create more than one instance of Singleton.");
        }
        _isInstanceCreated = true;
    }
    public static Singleton Instance => _instance;
}

Serialization 문제

싱글턴 객체를 직렬화Serialization할 때, 직렬화된 객체를 역직렬화Deserialization하는 과정에서 새로운 인스턴스가 생성될 수 있습니다. 이를 해결하기 위해서는 GetObject 메서드를 오버라이드하여 항상 같은 인스턴스를 반환하도록 해야 합니다.

[Serializable]
public class Singleton
{
    private static readonly Singleton _instance = new Singleton();
    private Singleton() {}
    public static Singleton Instance => _instance;
    // 역직렬화 시 항상 같은 인스턴스를 반환
    protected Singleton GetObject()
    {
        return _instance;
    }
}

Singleton과 Static 클래스

Singleton 패턴과 Static 클래스는 모두 클래스의 유일한 인스턴스 또는 전역 접근성을 제공하는 방식이지만, 그 사용 목적과 구현 방식에는 중요한 차이점이 있습니다. 이 비교를 통해 두 개념의 차이와 각 패턴이 언제 더 적합한지를 이해하는 것이 중요합니다.

객체 지향 설계의 유연성

Singleton 클래스는 객체 지향 설계의 기본 원칙을 따르며, 상속과 인터페이스 구현이 가능합니다. 이는 코드의 확장성과 유지보수성을 높이는 데 큰 도움이 됩니다. 반면, Static 클래스는 상속이나 인터페이스 구현이 불가능하여, 유연한 설계가 어렵습니다.

초기화 제어

Singleton 패턴은 클래스의 인스턴스를 하나만 만들도록 보장합니다. 즉, 인스턴스 초기화 시점을 제어할 수 있습니다. 특히 Lazy Singleton을 사용하면, 인스턴스가 실제로 필요할 때만 초기화되므로 자원을 효율적으로 사용할 수 있습니다. Static 클래스는 인스턴스를 생성할 수 없습니다. 따라서 Static 클래스는 초기화 시점을 제어할 수 없고, 클래스가 로드될 때 모든 정적 멤버가 초기화됩니다.

상태 관리와 스레드 안전성

Singleton 패턴은 인스턴스 기반으로 상태를 관리할 수 있으며, 필요한 경우 동기화를 통해 스레드 안전성을 쉽게 확보할 수 있습니다. Static 클래스의 경우, 모든 상태가 정적static으로 관리되므로 스레드 안전성을 확보하기 위해 추가적인 동기화가 필요합니다. Static 클래스는 상태를 관리하기보다는 단순한 연산이나 공통 기능을 제공하는 데 더 적합합니다.

테스트 용이성

Singleton 인스턴스는 Mocking이나 의존성 주입Dependency Injection 등을 통해 테스트가 가능하지만, Static 클래스는 이러한 테스트 기법을 적용하기 어려워 단위 테스트가 복잡해질 수 있습니다. 테스트 용이성 측면에서 Singleton 패턴이 더 유리합니다.

Early Initialization

초기화 지연을 사용하지 않고, 클래스가 처음 로드될 때 즉시 인스턴스를 생성하는 방법입니다. 멀티스레드 환경에서 안전하고 간단하게 구현할 수 있습니다. 초기화 비용이 적거나 인스턴스가 반드시 필요할 때 적합합니다.

public class Singleton
{
    // 클래스가 로드될 때 인스턴스를 생성
    private static readonly Singleton _instance = new Singleton();
    // 생성자를 private으로 설정하여 외부에서 인스턴스 생성을 막음
    private Singleton() {}
    public static Singleton Instance
    {
        get { return _instance; }
    }
}
  • 장점 : 코드가 간결하며, 초기화 시점에 인스턴스가 바로 생성되므로 스레드 안전성 문제를 걱정할 필요가 없습니다.
  • 단점 : 인스턴스가 반드시 필요하지 않더라도 애플리케이션 시작 시 무조건 생성되기 때문에, 자원을 낭비할 가능성이 있습니다.

Singleton과 메모리 릭

싱글턴 객체가 오래 살아남는 경우, 이 객체가 많은 리소스를 잡고 있을 수 있습니다. 특히 이벤트 핸들러나 콜백 함수에 싱글턴 객체가 구독할 경우, 적절히 해제하지 않으면 메모리 릭Memory Leak을 일으킬 수 있습니다. 따라서 이벤트 구독을 할 때는 구독을 해제하는 로직을 적절히 관리하거나, 싱글턴 객체가 프로그램의 생애 주기 내내 살아남는 구조를 명확히 이해한 상태에서 사용해야 합니다.

Multiton Pattern

멀티톤Multiton 패턴은 싱글턴 패턴의 확장형으로, 하나 이상의 인스턴스를 관리하는 패턴입니다. 일반적으로 키key를 기준으로 여러 인스턴스를 저장하고, 동일한 키로 요청할 경우 항상 동일한 인스턴스를 반환합니다.

public class Multiton
{
    private static readonly Dictionary<string, Multiton> _instances = new Dictionary<string, Multiton>();
    private Multiton() {}
    public static Multiton GetInstance(string key)
    {
        if (!_instances.ContainsKey(key))
        {
            _instances[key] = new Multiton();
        }
        return _instances[key];
    }
}

데이터베이스 연결처럼 여러 유형의 리소스를 관리할 때, 각 리소스별로 하나씩 인스턴스를 유지해야 할 경우 유용합니다.

Singleton 테스트

싱글턴 패턴은 전역 상태를 유지하므로 유닛 테스트에서 어려움을 겪을 수 있습니다. 전역 상태 때문에 테스트 간에 서로 영향을 줄 수 있기 때문입니다.

해결 방안

  • 인터페이스 사용: Singleton 클래스에 인터페이스를 도입하여, 테스트 시 Mock 객체로 대체할 수 있게 만듭니다.
  • 의존성 주입Dependency Injection: 전역 인스턴스가 아닌, 객체 생성 시 의존성을 주입받도록 구조를 바꾸면 더 유연하게 테스트할 수 있습니다.
public interface IDatabaseConnection
{
    void Connect();
}
public class DatabaseConnection : IDatabaseConnection
{
    private static DatabaseConnection _instance;
    private DatabaseConnection() {}
    public static DatabaseConnection Instance => _instance ?? (_instance = new DatabaseConnection());
    public void Connect() { /* 연결 코드 */ }
}

의존성 주입과 Singleton

의존성 주입 프레임워크를 사용할 때, 싱글턴 수명 주기Singleton Lifetime를 관리할 수 있습니다. 예를 들어, ASP.NET Core의 DI 컨테이너는 싱글턴 서비스를 주입 받도록 구성할 수 있습니다.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // Singleton 수명 주기를 가진 서비스 등록
        services.AddSingleton<DatabaseConnection>();
    }
}

이 방식은 애플리케이션이 실행되는 동안 싱글턴 인스턴스를 유지하면서, DI 컨테이너를 통해 관리 및 주입받을 수 있습니다. 이를 통해 싱글턴 패턴과 의존성 주입을 결합하여, 전역 인스턴스의 문제점을 해결하고 더 유연하게 관리할 수 있습니다.