Singleton Pattern
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
댓글
이 글 공유하기
다른 글
-
Adapter Pattern
Adapter Pattern
2024.03.31 -
PyTorch의 모듈 import는 어떻게 동작하는 걸까?
PyTorch의 모듈 import는 어떻게 동작하는 걸까?
2024.02.18 -
한국어 형태소 분석기 성능 비교
한국어 형태소 분석기 성능 비교
2018.12.10 -
[Troubleshooting] Spark2 UDF NPE Cases
[Troubleshooting] Spark2 UDF NPE Cases
2018.11.25