싱글턴(Singleton) 패턴 : 심화

Eager / Lazy 싱글턴 패턴과 쓰레드

written by tiaz0128
'싱글턴 기본 패턴'에 대해 더 자세히 알고 싶으시다면, "싱글턴(Singleton) 패턴 : 기초" 글을 확인 해주세요!

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': 'base',
    'themeVariables': {
      'primaryColor': '#2a3844',
      'lineColor': '#fff',
      'primaryTextColor': '#fff',
      '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': 'base',
    'themeVariables': {
      'primaryColor': '#2a3844',
      'lineColor': '#fff',
      'primaryTextColor': '#fff',
      '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 디자인 패턴 싱글턴 thread

tiaz0128

Eat Sleep Coding.

Never Never GiveUp.

Security  |  BackEnd  |  Multi Cloud