디자인 패턴 - 싱글턴(Singleton)

싱글턴(Singleton)


생성자가 여러 차례 호출되더라도 실제로 생성되는 객체는 하나이고 최초 생성 이후에 호출된 생성자는 최초의 생성자가 생성한 객체를 리턴한다. ~ 위키백과 ~

싱글턴(Singleton) 패턴은 특정 클래스의 인스턴스가 프로그램 전체에서 단 하나만 생성되도록 보장하는 패턴입니다.

그냥 전역 변수로 쓰면 안돼?


전역 변수를 쓰지 않고 싱글턴 패턴을 쓰는 가장 큰 이유는 접근 제어 때문입니다.

언제 써야 하나


굳이 여러번 만들어질 필요가 없는 객체를 생각하면 됩니다. 대표적인 경우는 아래와 같습니다.

파이썬에서 싱글턴 구현하기


파이썬에서 여러가지 방법으로 싱글턴을 구현 할 수 있습니다. 여기에서는 5가지 방법으로 싱글턴을 구현해보고 각각의 특성을 알아보겠습니다.

  1. GoF(Gang of Four)
  2. __new__
  3. metaclass
  4. Eager
  5. Thread Safe

클래스 다이어그램 : GoF(Gang of Four)


classDiagram
    direction BT

    class Singleton{
        -_instance: Singleton

        +get_instance() Singleton
    }

싱글턴 구현 : GoF(Gang of Four)


가장 클래식한 방법으로 GoF에서 말하는 패턴입니다.

  1. __init__ 메서드를 Override해서 사용하지 못하게 한다.
  2. 클래스 메서드 get_instance를 호출하여 인스턴스를 생성 또는 이미 만들어져 있는 인스턴스를 리턴 한다.
gof/singleton.py
from typing import Self

class Singleton:
    _instance = None

    def __init__(self) -> None:
        raise RuntimeError("Call instance() method")

    @classmethod
    def get_instance(cls) -> Self:
        if not cls._instance: 
            cls._instance = super().__init__(cls)
        return cls._instance
gof/test_gof_singleton.py
from gof.singleton import Singleton

def test_gof_singleton():
    obj_1 = Singleton.get_instance()
    obj_2 = Singleton.get_instance()

    assert obj_1 is obj_2

클래스 다이어그램 : __new__


classDiagram
    direction BT

    class Singleton{
        -_instance: Singleton

        +__new__() Singleton
    }

싱글턴 구현 : __new__


파이썬에서 가장 일반적으로 구현할 수 있는 방법입니다.

  1. __new__ 메서드를 오버라이드(Override) 한다.
  2. __new__ 메서드에서 인스턴스를 생성 또는 이미 만들어져 있는 인스턴스를 리턴 한다.
new/singleton.py
from typing import Self

class Singleton:
    _instance = None

    def __new__(cls) -> Self:
        if not cls._instance:
            cls._instance = super().__new__(cls)

        return cls._instance
new/test_new_singleton.py
from new.singleton import Singleton

def test_new_singleton():
    obj_1 = Singleton()
    obj_2 = Singleton()

    assert obj_1 is obj_2

메타클래스(metaclass)


메타클래스(metaclass)는 클래스의 클래스입니다. 즉, 메타클래스는 클래스를 생성하는 것입니다. 파이썬에서 모든 것은 객체이며, 클래스도 객체입니다. 따라서 클래스를 생성하는 것 역시 객체인데, 이를 메타클래스라고 합니다.

사용자 정의 메타클래스를 만드는 방법은 크게 두가지가 있습니다.

  1. type을 상속받아서 메타클래스를 만들고 메서드를 오버라이드
  2. type을 사용하여 동적으로 클래스를 생성하는 방식

클래스 다이어그램 : meta class


%%{
  init: {
    'theme': 'default',
    'themeVariables': {
      'lineColor': '#F8B229',
      'tertiaryColor': '#fff'
    }
  }
}%%
classDiagram
    direction BT

    class SingletonMeta{
        - _instances: dict

        - __call__() Singleton
    }

    class Singleton{
        + some_business_logic()
    }

    Singleton --|> SingletonMeta

싱글턴 구현 : meta class


type을 상속하여 메타클래스를 만드는 방법을 이용하여 싱글턴 패턴을 만들 수 있습니다.

  1. type 을 상속하는 metaclass를 정의한다.
  2. __call__ 메서드를 오버라이드 한다.
  3. 만들고 싶은 싱글턴 클래스에 metaclass를 지정 합니다.
meta/meta.py
import logging

class SingletonMeta(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        logging.info("metaclass __call__")

        if cls not in cls._instances:
            instance = super().__call__(*args, **kwargs)
            cls._instances[cls] = instance
        
        return cls._instances.get(cls)
meta/singleton.py
from meta.meta import SingletonMeta

class Singleton(metaclass=SingletonMeta):
    def some_business_logic(self):
        ...
meta/test_meta_singleton.py
from meta.singleton import Singleton

def test_meta_singleton():
    obj_1 = Singleton()
    obj_2 = Singleton()

    assert obj_1 is obj_2

metaclass=를 사용해서 해당 클래스의 생성 과정에 개입하는 클래스를 지정 한다고 생각하면 됩니다. 여기서는 __call__ 메서드를 오버라이드 했기 때문에 객체가 생성되는 시점에 SingletonMeta 메타클래스의 __call__ 메서드가 호출 되는 것입니다.

INFO     root:singleton_meta.py:8 metaclass __call__
INFO     root:singleton_meta.py:8 metaclass __call__

앞서 본 싱글턴 패턴은 클래스 자체에 싱글턴을 구현한 반면, 메타클래스는 상속하는 형태로 코드를 작성 할 수 있습니다. 보다 객체지향스럽게 코드를 작성하는데 도움이 됩니다.

type으로 클래스 동적으로 생성


이번에는 type을 직접 사용해서 클래스를 만드는 방법을 알아보겠습니다. type은 세가지 인자를 받아 클래스를 동적으로 생성합니다.

  1. name : 클래스의 이름
  2. bases : 베이스 클래스의 튜플
  3. attrs : 클래스 딕셔너리
AdvancedList = type('AdvancedList', (list,), { "hello": lambda self: print("hello") })

h = AdvancedList((1,2,3,4))
h.hello()

AdvancedList라는 이름의 클래스를 만들고 기본 베이스 클래스는 list입니다. 그리고 속성으로 hello 메서드를 정의합니다.

클래스 다이어그램 : Eager


%%{
  init: {
    'theme': 'default',
    'themeVariables': {
      'lineColor': '#F8B229',
      'tertiaryColor': '#fff'
    }
  }
}%%
classDiagram
    direction BT

    class SingletonMeta{
        -_instances: dict

        -__init__()
        -__call__() Singleton
    }

    class Singleton{
        +some_business_logic()
    }

    Singleton --|> SingletonMeta

싱글턴 구현 : Eager


Eager 싱글턴 패턴은 인스턴스가 필요하기 전에 미리 생성되는 싱글턴 디자인 패턴의 한 형태입니다.

  1. 메타클래스를 정의하고 __init__을 오버라이딩
  2. 메타클래스의 super를 이용하여 클래스를 정의 및 객체 생성
  3. 메타클래스의 __call__울 오버라이딩
eager/meta.py
import logging

class SingletonMeta(type):
    _instances = {}

    def __init__(cls, name, bases, attrs):
        logging.info("metaclass __init__")

        super().__init__(name, bases, attrs)

        instance = super().__call__()
        cls._instances[cls] = instance

    def __call__(cls, *args, **kwargs):
        logging.info("metaclass __call__")

        return cls._instances.get(cls)
eager/singleton.py
from eager.meta import SingletonMeta

class Singleton(metaclass=SingletonMeta):
    def some_business_logic(self):
        ...
eager/test_eager_singleton.py
from eager.singleton import Singleton

def test_meta_singleton():
    obj_1 = Singleton()
    obj_2 = Singleton()

    assert obj_1 is obj_2

디버깅을 해보면 클래스로 객체를 생성하는 시점이 아니라, 클래스가 로딩되는 시점에 메타클래스의 __init__ 메서드가 자동으로 호출되는 것을 확인 할 수 있습니다. 따라서 메타클래스를 상속 받은 각 클래스에 대해서 인스턴스를 생성 할 수 있습니다.

메타클래스는 super()는 상속 받는 type입니다. 앞서 봤던 type을 사용하여 클래스를 생성하는 과정과 동일합니다. 이후 메타클래스의 __call__ 메서드가 호출되면서 미리 만들어져 있던 객체를 리턴 합니다.

INFO     root:meta.py:8 metaclass __init__

INFO     root:meta.py:15 metaclass __call__
INFO     root:meta.py:15 metaclass __call__

이 방식은 프로그램 시작 시점에 싱글턴 인스턴스를 생성함으로써, 멀티스레딩 환경에서의 동시성 문제를 자연스럽게 회피할 수 있습니다. Eager 싱글턴 패턴은 인스턴스의 생성 시점을 명확하게 제어할 수 있으며, 런타임에 추가적인 처리 없이 인스턴스에 접근할 수 있다는 장점이 있습니다.

반면, 리소스가 낭비 될수 있고 초기화 순서에 의존이 생길 수 있으며, 그로 인해 코드의 유연성이 부족할 수 있습니다.

Eager 패턴 / Lazy 패턴


Eager 싱글턴 패턴과 Lazy 싱글턴 패턴은 객체의 생성 시점에 따라 구분됩니다.

Eager 싱글턴 패턴

Lazy 싱글턴 패턴

클래스 다이어그램 : Thread Safe


%%{
  init: {
    'theme': 'default',
    'themeVariables': {
      'lineColor': '#F8B229',
      'tertiaryColor': '#fff'
    }
  }
}%%
classDiagram
    direction BT

    class SingletonMeta{
        -_instances: dict

        -__call__() Singleton
    }

    class Singleton{
        +some_business_logic()
    }

    Singleton --|> SingletonMeta

싱글턴 구현 : Thread Safe


race condition은 여러 스레드나 프로세스가 동시에 공유 자원에 접근할 때 발생할 수 있는 문제입니다. 멀티 쓰레딩 환경에서 Lazy 싱글턴 패턴은 인스턴스 생성 시점에 여러 스레드가 동시에 접근할 경우, 동일한 싱글턴 인스턴스가 여러 번 생성될 race condition 위험이 있습니다.

따라서 이를 방지하기 위한 동기화 로직이 추가로 필요합니다.

  1. 메타클래스를 정의하고 __new__을 오버라이딩
  2. threading.Lock()을 활용
thread/meta.py
from typing import Self
import threading


class SingletonMeta(type):
    _instances = None
    _lock = threading.Lock()

    def __call__(cls, *args, **kwargs) -> Self:
        with cls._lock:
            if cls not in cls._instances:
                cls._instances[cls] = super().__call__(*args, **kwargs)

        return cls._instances[cls]
thread/singleton.py
from thread.meta import SingletonMeta


class Singleton(metaclass=SingletonMeta):
    def some_business_logic(self):
        pass

멀티 쓰레딩 환경에서 확인하기


멀티 쓰레딩 환경에서 race condition 환경을 재현하기 위해서 각 메타클래스에 time.sleep을 추가하겠습니다. Lazy 싱글턴 패턴에서 sleep 상대적으로 시간을 짧게 부여해서 테스트가 번갈아 성공과 실패하는 것을 확인 할 수 있습니다. 반면, thread.Lock() 사용한 싱글턴 패턴에서는 긴 sleep 시간을 부여해도 안정적으로 하나의 객체만 생성하는 것을 확인 할 수 있습니다.

thread/meta.py
import threading
import time


class SingletonMeta(type):
    _instances = {}
    _lock = threading.Lock()

    def __call__(cls, *args, **kwargs):
        with cls._lock:
            if cls not in cls._instances:
                time.sleep(1) # race condition 발생시키 위해서 추가

                instance = super().__call__(*args, **kwargs)
                cls._instances[cls] = instance

        return cls._instances[cls]
meta/meta.py
import time


class SingletonMeta(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            time.sleep(0.00001) # race condition 발생시키 위해서 추가

            instance = super().__call__(*args, **kwargs)
            cls._instances[cls] = instance

        return cls._instances[cls]
thread/test_thread_safe_singleton.py
import logging
import threading

from meta.singleton import Singleton as LazySingleton
from thread.singleton import Singleton as ThreadSafeSingleton


def test_thread_safe_singleton():
    result = race_condition(get_thread_singleton_instance)

    assert len(set(result)) == 1


def test_lazy_singleton():
    result = race_condition(get_lazy_singleton_instance)

    assert len(set(result)) == 1


def race_condition(func):
    threads = []
    results = [None] * 100  # 결과 저장 리스트
    barrier = threading.Barrier(100)  # 100개의 쓰레드가 동시에 실행되도록 설정

    def wrapper(index):
        barrier.wait()  # 모든 쓰레드가 이 지점에 도달할 때까지 대기
        results[index] = func()

    for i in range(100):
        t = threading.Thread(target=wrapper, args=(i,))
        threads.append(t)

    for t in threads:
        t.start()

    for t in threads:
        t.join()

    return results  # 결과 리스트를 반환


def get_thread_singleton_instance():
    s = ThreadSafeSingleton()
    logging.info(s)

    return s


def get_lazy_singleton_instance():
    s = LazySingleton()
    logging.info(s)

    return s
# test_lazy_singleton 실행시 
# - 테스트를 성공할때도 실패할때도 있는 것을 확인 가능

>       assert len(set(result)) == 1
E       assert 4 == 1

정리


파이썬 싱글턴 패턴에 대해 알아보았습니다. 여러가지 싱글턴 패턴 중에서 어떤 것을 사용할지는 상황에 맞게 선택해야 합니다.

  1. 객체가 언제 생성 되는가
  2. 멀티스레딩 환경

Lazy 싱글턴 패턴은 인스턴스가 실제로 필요한 순간에만 생성되어 리소스를 효율적으로 사용할 수 있지만, 멀티스레딩 환경에서의 동기화 처리가 필요한 단점이 있습니다.

Eager 싱글턴 패턴은 프로그램 시작 시에 인스턴스를 생성하여 멀티스레딩 환경에서의 안전성을 보장하는 반면, 리소스 낭비의 가능성이 있습니다.

python 디자인패턴 싱글턴