What is Singleton pattern?

싱글턴 패턴은 클래스가 하나의 유일한 인스턴스만 가지면서, 해당 인스턴스에 대해 전역 액세스를 제공하는 디자인 패턴입니다. 데이터베이스 객체처럼 프로그램 전반에 걸쳐서 단 하나의 유일한 객체만 존재하며, 여러 클라이언트에서 호출이 되어야 하는 경우 싱글턴 패턴을 고려해 볼 수 있습니다. 또한 전역 변수와 비슷한 효과를 지니지만, 좀 더 엄밀한 제어가 가능합니다. 이번 포스트에서는 싱글턴 패턴의 목적과 구현 방법, 장/단점 그리고 실제 사용 사례를 다뤄보겠습니다.

How to implement?

싱글턴 패턴은 GoF에서 소개하는 여러 가지 디자인 패턴 중 구현 난이도가 쉬운 편에 속하는데요. 우선 클래스 다이어그램을 먼저 보고 실제 구현된 코드를 같이 보면서 얘기해 보겠습니다
다음은 refactoring.guru에서 가져온 클래스 다이어그램입니다

위 그림과 같이 Singleton 클래스는 유일한 객체가 될 instance를 property로 가지고, 클라이언트에서 해당 객체와 메시지 패싱하기 위한 getInstance 메서드를 가지는 것을 볼 수 있습니다. 생성자는 외부에서는 호출할 수 없게 private로 되어있는 부분도 유심히 봐야겠네요. 싱글톤 패턴에서의 핵심은 getInstance 메서드이며 해당 메서드 내부에서는 원래의 목표였던 Singleton 클래스의 객체가 유일함을 보장하는 로직이 포함되어 있음을 알 수 있습니다. Python에서 Single pattern은 여러 가지 방식 (base class, decorator, metaclass)로 구현할 수 있습니다
이번 포스트에서는 refactoring.guru의 방식인 metaclass 로 코드를 살펴보겠습니다

class SingletonMeta(type):
    """
    The Singleton class can be implemented in different ways in Python. Some
    possible methods include: base class, decorator, metaclass. We will use the
    metaclass because it is best suited for this purpose.
    """
    _instances = {}

    def __call__(cls, *args, **kwargs):
        """
        Possible changes to the value of the `__init__` argument do not affect
        the returned instance.
        """
        if cls not in cls._instances:
            instance = super().__call__(*args, **kwargs)
            cls._instances[cls] = instance
        return cls._instances[cls]

class Singleton(metaclass=SingletonMeta):
    def some_business_logic(self):
        """
        Finally, any singleton should define some business logic, which can be
        executed on its instance.
        """
        # ...

if __name__ == "__main__":
    # The client code.
    s1 = Singleton()
    s2 = Singleton()

    if id(s1) == id(s2):
        print("Singleton works, both variables contain the same instance.")
    else:
        print("Singleton failed, variables contain different instances.")

# Execution result: Singleton works, both variables contain the same instance.

type을 상속받은 SingletonMeta를 구현합니다. 해당 클래스 내부에서 _instances 딕셔너리 객체를 가지고 있고 해당 딕셔너리 객체는 type->object key->value 형태입니다. 따라서 객체 생성 시 해당 딕셔너리에 Singleton이길 원하는 type이 존재하지 않는다면 객체를 한 번 생성 후 재사용할 수 있게 딕셔너리에 집어넣고, 이미 존재한다면 단순히 딕셔너리에서 가져오기만 하면 됩니다. 구현은 매우 간단하지만, 앞에서 얘기했던 클래스 다이어그램의 주석처럼 멀티스레드 환경에서는 객체 생성 시 Lock을 걸어줘야 한다는 부분도 챙겨가시면 좋겠습니다!

Pros and cons

Pros

  • 클래스가 유일한 객체만 가지도록 보장할 수 있습니다
  • 클래스의 유일한 객체에 대해서 전역 접근을 할 수 있습니다
  • 객체의 생성은 처음 호출에만 발생하며, 이후부터는 새로 생성하지 않고 객체를 재사용합니다

Cons

  • 클래스가 유일한 객체를 가지도록 하는 것과 해당 인스턴스에 전역 접근을 허용 하는 2가지 책임을 가지므로 SRP를 위배합니다
  • 멀티 스레드 환경에서 문제 없이 동작하도록 객체 생성에 신경써야 합니다
  • (중요!) 싱글턴 객체를 사용하는 클라이언트 코드의 단위 테스트가 어렵습니다
    • 클라이언트들은 구상 클래스인 Singleton에 의존하므로 해당 부분을 격리한 테스트가 쉽지 않습니다
    • Open/Closed Princple을 위배합니다 -> 싱글턴의 동작은 확장으로는 수정할 수 없습니다

실제 사용 예제 및 대체 코드

오픈소스 모델 경량화 라이브러리인 AIMET에서도 싱글톤 패턴 또는 비슷한 목적을 달성하기 위한 코드들이 존재하는데요. 첫 번째로 프로그램 전반에 사용될 수 있는 대표적인 예제인 Logger 인데요. 위에서 다룬 구현체와 약간의 차이는 있지만, 전형적인 Singleton 패턴으로 구현된 것을 아래와 같이 확인할 수 있습니다. 우선 메타클래스로 전달할 SingletonType 을 정의합니다 ( link )

AIMET은 특별히 멀티스레드 환경을 고려하지 않기 때문에 Naive한 방식으로 작성되어있는 부분도 눈여겨 볼 수 있네요

class SingletonType(type):
    """ SingletonType is used as a metaclass by other classes for which only one instance must be created.

    A metaclass inherits from "type' and it's instances are other classes.
    """
    _instances = {}

    def __call__(cls, *args, **kwargs):
        """ This function overrides the behavior of type's __call__ function.

        The overriding behavior is needed  so that only one instance of the derived
        class is created. The argument cls is a class variable (similar to self for instances).

        Using AimetLogger  class as an example, when AimetLogger() is called, SingletonType
        (the metaclass) class's __call__ is called which in turn calls AimetLogger's __call__
        creating an instance of AimetLogger. The creation happens only once, making
        aimetLooger a singleton.
        """
        if cls not in cls._instances:
            cls._instances[cls] = super(SingletonType, cls).__call__(*args, **kwargs)
        return cls._instances[cls]

위에서 정의한 메타클래스를 등록하고, Logger에 필요한 로직들을 __init__과 staticmethod로 작성하고 있는 것을 볼 수 있습니다
Logger의 configuration과 LogArea 관련된 로직들은 프로그램에서 딱 한 번만 설정하면 되므로, 싱글턴 패턴의 목적에 부합한다고 볼 수 있겠습니다

class AimetLogger(metaclass=SingletonType):
    """ The aimet Logger class. Multiple Area Loggers have been defined.
    Each Area Logger could be set at a different logging level. """
    _logger = None

    class LogAreas(Enum):
        """ Defines the LogAreas used in aimet. """
        Quant = 'Quant'
        Svd = 'Svd'
        Test = 'Test'
        Utils = 'Utils'
        # ...

    def __init__(self):
        self._logger = logging.getLogger()

        dir_name = os.path.dirname(__file__)
        rel_path = "default_logging_config.json"
        abs_file_path = os.path.join(dir_name, rel_path)

        with open(abs_file_path, encoding='utf-8') as logging_configuration_file:
            try:
                config_dict = json.loads(logging_configuration_file.read())
            except:
                raise ValueError("Logging configuration file: default_logging_config.json contains invalid format")

        logging.config.dictConfig(config_dict)

        # Validate JSON  file default_logging_config.json for correct Logging Areas
        #TODO This results in a pylint error: Instance of 'RootLogger' has no 'loggerDict' member.
        # Need to fix this issue and then remove the pylint disablement.
        configured_items = list(logging.root.manager.loggerDict.items()) # pylint: disable=no-member

        log_areas_list = list()
        for x in AimetLogger.LogAreas:
            log_areas_list.append(x.value)

        configured_areas_list = list()
        for name, _ in configured_items:
            configured_areas_list.append(name)

        for area in log_areas_list:
            if area not in configured_areas_list:
                raise ValueError(" ERROR: LogArea: {} NOT configured".format(area))

        log_package_info()

    @staticmethod
    def get_area_logger(area):
        """ Returns a specific Area logger. """
        AimetLogger()
        area_logger = logging.getLogger(area.value)
        return area_logger

    @staticmethod
    def set_area_logger_level(area, level):
        """ Sets a logging level for a single area logger. """
        area_logger = logging.getLogger(area.value)
        area_logger.setLevel(level)

    @staticmethod
    def set_level_for_all_areas(level):
        """ Sets the same logging level for all area debuggers. """
        for area in AimetLogger.LogAreas:
            AimetLogger.set_area_logger_level(area, level)

이제부터 AIMET 프로그램 전반에 걸쳐서 로깅이 필요하다면 logger 관련 객체를 생성하지 않고,
AimetLogger 클래스의 get_area_logger만 호출해서 아래와 같이 로깅 작업을 할 수 있습니다

logger = AimetLogger.get_area_logger(AimetLogger.LogAreas.Utils)
logger.info("Logger object from Singleton!")

사실 싱글턴 패턴을 유심히 보신 분들은 느끼셨겠지만, 싱글톤 클래스가 내부의 instance를 관리하는 방식이 일종의 cache로도 볼 수 있으셨을 텐데요. 딥러닝에서는 자주 사용하는 텐서들이 존재하는데요. 이런 텐서를 매번 생성 및 GPU 장치에 올리는 작업이 많이 반복되는 부분이 프로그램 전체 성능에 영향을 줄 수 있습니다. 비슷하게 싱글턴 클래스를 만들어서 구현할 수도 있겠지만, 딕셔너리 객체를 잘 활용하면 싱글턴 패턴 없이도 비슷한 효과를 만들 수 있겠네요!

0과 epsilon처럼 자주 사용하는 값을 타겟 device에 따라 처음 한 번은 실제로 생성하고, 이후부터는 factory에서 요청 시 만들어진 객체를 그냥 불러오기만 하면 됩니다

_cache: Dict[Tuple[float, torch.device], torch.Tensor] = {}
def constant_tensor_factory(val: float,
                            device: Union[str, torch.device]) -> torch.Tensor:
    """
    Factory function to generate constant tensor or return cached object
    :param val: value to obtain corresponding torch.Tensor
    :param device: device str ('cpu', 'cuda', ...) or torch.device
    :return: Constant tensor
    """
    if isinstance(device, str):
        device = torch.device(device)
    if (val, device) in _cache:
        return _cache[(val, device)]
    tensor = torch.tensor([val], device=device)
    _cache[(val, device)] = tensor
    return tensor

zero_tensor = constant_tensor_factory(0., "cuda")
eps_tensor = constant_tensor_factory(1e-5, "cpu")

마무리

이번 포스트를 통해서 싱글턴 패턴이 어떤 목적에 부합하고, 어떻게 구현할 수 있으며, 장단점이 무엇인지 마지막으로 실제 소프트웨어에서의 사용 사례와 다른 방식으로 비슷한 목적을 이룰 수 있는 코드까지 살펴보았습니다. 싱글턴 패턴은 여러 GoF 패턴들 중 안티 (?)도 많은 패턴 중 하나인데요. 비판하더라도 패턴이 무엇인지 알고 비판하는 것과 그렇지 않은 것은 차이가 있으므로 한 번쯤은 다들 살펴보면 좋은 패턴 중 하나라고 생각합니다

Reference