디자인 패턴 - 싱글턴(Singleton)
싱글턴(Singleton)
생성자가 여러 차례 호출되더라도 실제로 생성되는 객체는 하나이고 최초 생성 이후에 호출된 생성자는 최초의 생성자가 생성한 객체를 리턴한다. ~ 위키백과 ~
싱글턴(Singleton) 패턴은 특정 클래스의 인스턴스가 프로그램 전체에서 단 하나만 생성되도록 보장하는 패턴입니다.
그냥 전역 변수로 쓰면 안돼?
전역 변수를 쓰지 않고 싱글턴 패턴을 쓰는 가장 큰 이유는 접근 제어 때문입니다.
- 전역 변수: 전역 변수는 애플리케이션의 어느 곳에서나 직접 접근하고 수정할 수 있습니다. 이는 데이터의 무결성을 보장하기 어렵게 만들고, 예기치 않은 변경으로 인한 버그의 원인이 될 수 있습니다.
- 싱글턴 패턴: 싱글턴 패턴을 사용하면, 인스턴스의 생성과 접근을 제어하는 메서드를 통해 객체에 접근합니다. 이는 객체의 상태와 생명주기를 더 잘 제어할 수 있게 해주며, 데이터의 무결성을 유지하는 데 도움이 됩니다.
언제 써야 하나
굳이 여러번 만들어질 필요가 없는 객체를 생각하면 됩니다. 대표적인 경우는 아래와 같습니다.
- 데이터베이스 연결
- 로깅
- 설정 정보
- 캐시
파이썬에서 싱글턴 구현하기
파이썬에서 여러가지 방법으로 싱글턴을 구현 할 수 있습니다. 여기에서는 5가지 방법으로 싱글턴을 구현해보고 각각의 특성을 알아보겠습니다.
- GoF(Gang of Four)
- __new__
- metaclass
- Eager
- Thread Safe
클래스 다이어그램 : GoF(Gang of Four)
classDiagram direction BT class Singleton{ -_instance: Singleton +get_instance() Singleton }
싱글턴 구현 : GoF(Gang of Four)
가장 클래식한 방법으로 GoF에서 말하는 패턴입니다.
__init__
메서드를 Override해서 사용하지 못하게 한다.- 클래스 메서드
get_instance
를 호출하여 인스턴스를 생성 또는 이미 만들어져 있는 인스턴스를 리턴 한다.
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
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__
파이썬에서 가장 일반적으로 구현할 수 있는 방법입니다.
__new__
메서드를 오버라이드(Override) 한다.__new__
메서드에서 인스턴스를 생성 또는 이미 만들어져 있는 인스턴스를 리턴 한다.
from typing import Self
class Singleton:
_instance = None
def __new__(cls) -> Self:
if not cls._instance:
cls._instance = super().__new__(cls)
return cls._instance
from new.singleton import Singleton
def test_new_singleton():
obj_1 = Singleton()
obj_2 = Singleton()
assert obj_1 is obj_2
메타클래스(metaclass)
메타클래스(metaclass)는 클래스의 클래스입니다. 즉, 메타클래스는 클래스를 생성하는 것입니다. 파이썬에서 모든 것은 객체이며, 클래스도 객체입니다. 따라서 클래스를 생성하는 것 역시 객체인데, 이를 메타클래스라고 합니다.
사용자 정의 메타클래스를 만드는 방법은 크게 두가지가 있습니다.
type
을 상속받아서 메타클래스를 만들고 메서드를 오버라이드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
을 상속하여 메타클래스를 만드는 방법을 이용하여 싱글턴 패턴을 만들 수 있습니다.
- type 을 상속하는
metaclass
를 정의한다. __call__
메서드를 오버라이드 한다.- 만들고 싶은 싱글턴 클래스에
metaclass
를 지정 합니다.
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)
from meta.meta import SingletonMeta
class Singleton(metaclass=SingletonMeta):
def some_business_logic(self):
...
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
은 세가지 인자를 받아 클래스를 동적으로 생성합니다.
name
: 클래스의 이름bases
: 베이스 클래스의 튜플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 싱글턴 패턴은 인스턴스가 필요하기 전에 미리 생성되는 싱글턴 디자인 패턴의 한 형태입니다.
- 메타클래스를 정의하고
__init__
을 오버라이딩 - 메타클래스의
super
를 이용하여 클래스를 정의 및 객체 생성 - 메타클래스의
__call__
울 오버라이딩
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)
from eager.meta import SingletonMeta
class Singleton(metaclass=SingletonMeta):
def some_business_logic(self):
...
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 싱글턴 패턴
- 정의: Eager 싱글턴 패턴에서는 클래스가 로드되는 시점에 싱글턴 인스턴스가 생성. 즉, 프로그램 시작 시 또는 클래스가 처음 로드될 때 인스턴스가 생성되어 메모리에 할당.
- 장점: 멀티스레딩 환경에서의 안전성이 보장. 인스턴스가 프로그램 시작 시에 생성되기 때문에, 동시성 문제 없이 인스턴스에 접근 가능.
- 단점: 인스턴스가 실제로 필요하지 않은 경우에도 미리 생성되므로, 리소스 낭비가 발생.
Lazy 싱글턴 패턴
- 정의: 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 위험이 있습니다.
따라서 이를 방지하기 위한 동기화 로직이 추가로 필요합니다.
- 메타클래스를 정의하고
__new__
을 오버라이딩 threading.Lock()
을 활용
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]
from thread.meta import SingletonMeta
class Singleton(metaclass=SingletonMeta):
def some_business_logic(self):
pass
멀티 쓰레딩 환경에서 확인하기
멀티 쓰레딩 환경에서 race condition 환경을 재현하기 위해서 각 메타클래스에 time.sleep
을 추가하겠습니다. Lazy 싱글턴 패턴에서 sleep
상대적으로 시간을 짧게 부여해서 테스트가 번갈아 성공과 실패하는 것을 확인 할 수 있습니다. 반면, thread.Lock()
사용한 싱글턴 패턴에서는 긴 sleep
시간을 부여해도 안정적으로 하나의 객체만 생성하는 것을 확인 할 수 있습니다.
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]
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]
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
정리
파이썬 싱글턴 패턴에 대해 알아보았습니다. 여러가지 싱글턴 패턴 중에서 어떤 것을 사용할지는 상황에 맞게 선택해야 합니다.
- 객체가 언제 생성 되는가
- 멀티스레딩 환경
Lazy 싱글턴 패턴은 인스턴스가 실제로 필요한 순간에만 생성되어 리소스를 효율적으로 사용할 수 있지만, 멀티스레딩 환경에서의 동기화 처리가 필요한 단점이 있습니다.
Eager 싱글턴 패턴은 프로그램 시작 시에 인스턴스를 생성하여 멀티스레딩 환경에서의 안전성을 보장하는 반면, 리소스 낭비의 가능성이 있습니다.