Programming/Design patterns

[Design patterns] Singleton Pattern (싱글턴 패턴)

Jonghyuk Baek 2022. 5. 2. 23:38

어디서든 단일 인스턴스에 접근하고 싶다면?

Introduction

Design Patterns: Elements of Reusable Object-Oriented Software (1994) 의 저자인 Erich Gamma 는 책이 출판된 15년 후의 인터뷰에서 이렇게 언급한 적이 있다.

 

Larry: How would you refactor "Design Patterns"?
Erich: ... When discussing which patterns to drop, we found that we still love them all. (Not really — I'm in favor of dropping Singleton. Its use is almost always a design smell.)

 

의역하자면 다음과 같다.

 

Larry: "Design Patterns" 책을 어떤 식으로 개정하고 싶나요?
Erich: (중략) 저희는 특정 디자인 패턴을 제외시킬까 하다가도, 결국에는 전부 다 필요하다는걸 깨달았습니다. 그런데 사실 저는 싱글턴 패턴은 빼고 싶어요. 이걸 쓰면 열에 아홉은 프로그램 디자인 기본 원칙을 위반하게 되거든요.

 

...뭔가 싱글턴 패턴이라는 것이 굉장히 박한 대접을 받고 있는 것을 알 수 있다.

 

오늘은 한번 싱글턴 패턴에 대해 정리해 보고, 이게 정말 사용을 지양해야 하는 프로그램 디자인 패턴인지를 알아보도록 하겠다.


Singleton Pattern

프로그램 내에는 시스템 내에서 딱 하나만 가지고 있어도 되는 요소들이 가끔씩 등장한다.

  • 예를 들면 데이터를 데이터베이스로부터 불러오는 경우, 단 한번만 데이터를 불러오고 나서는 굳이 리소스를 들여 가며 추가적으로 데이터를 불러올 필요가 없을 수 있다.
  • 지난번 소개한 로거 (Logger) 의 경우처럼, 로거 이름만 가지고 어디서든 동일한 로거 인스턴스를 가져와 쓸 수 있게 만들고 싶은 경우도 있다.
  • 혹은 클래스 팩토리 (적절한 방식으로 초기화한 클래스 인스턴스를 리턴해 주는 클래스라고 보면 된다) 의 경우, static method만으로도 충분히 구현할 수 있으며 굳이 클래스 인스턴스를 한 번 혹은 여러 번 생성할 필요가 없다.

클래스의 인스턴스를 생성하는 작업은 시간, 공간적으로 꽤 많은 리소스를 잡아먹기 때문에, 굳이 인스턴스를 여러 번 생성하지 않고 단 하나의 인스턴스 생성만을 허용하도록 만들어 두고 싶은 경우가 있을 것이다.

 

싱글턴 패턴 (Singleton Pattern) 은 어떤 오브젝트의 초기화를 단 한 번만 허용하며, 어디에서든 오브젝트에 접근하려 하면 처음 초기화된 바로 그 인스턴스를 사용하도록 하는 프로그램 디자인 패턴이다. (A component which is instantiated only once.)

 

How to Implement?

파이썬을 기준으로 싱글턴 패턴을 구현하는 세 가지 방법을 소개하고, 싱글턴 패턴과 유사하지만 다른 용도로 쓰일 수 있는 Monostate (모노스테이트) 패턴 구현 방법까지 optional로 소개한다.

Method 1. Class Allocator

class Database:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if not cls._instance:
            cls._instance = super(Database, cls).__new__(cls, *args, **kwargs)

        return cls._instance

첫 방법은 allocator를 건드리는 것이다. 클래스 자체에 static property로 instance를 하나 만들어 두고, __new__ 메소드에서 기존에 인스턴스가 이미 만들어졌는지를 한번 판단한 후 없으면 새 걸, 있으면 기존 걸 리턴하도록 하면 된다.

>>> d1 = Database()
... d2 = Database()
... print(d1 == d2)
True

실제로 해당 클래스 인스턴스 두 개를 생성한 후 확인해 보면, 실제로 완전히 동일한 주소를 가지는 것을 확인할 수 있다. 일반적으로는 인스턴스 두 개를 생성하면 둘은 서로 다른 주소를 가진다.

 

하지만 이렇게만 해 둔다고 구현이 완전해지는 것은 아닌데, __new__ 이후에 곧바로 호출되는 __init__ 메소드 내부에서는 여전히 새 오브젝트를 만들어내고 있을 수 있기 때문이다. __new__에서 같은 인스턴스를 반환한다 해도, 여전히 __init__은 매 인스턴스 호출마다 불리게 된다.

 

아래의 예시를 보자. __init__ 메소드를 추가했고, 이 안에서는 랜덤 integer를 생성해 멤버 변수로 저장하고 있다.

import random


class Database:
    _instance = None

    def __init__(self):
        self.id = random.randint(0, 10000)
        print(f"Instance ID: {self.id}")

    def __new__(cls, *args, **kwargs):
        if not cls._instance:
            cls._instance = super(Database, cls)\
                .__new__(cls, *args, **kwargs)

        return cls._instance


>>> d1 = Database()
769
>>> d2 = Database()
9348
>>> print(d1 == d2)
True
>>> print(d1.id, d2.id)
9348 9348

결과가 굉장히 이상한 것을 볼 수 있다. 첫번째, 두번째 호출 모두 별도로 __init__ 함수가 돌아가고, 생성된 id 값도 다르다. 하지만 이후에 d1==d2를 검사해 보면 여전히 True가 반환되며, id를 각각 출력해 봤을 때도 동일한 id 값이 출력된다.

 

초기화가 각각 한 번씩 돌지만 대상이 되는 인스턴스는 동일한 상황이기 때문에, 두 번째 초기화에서 생성한 값으로 덮어씌워지는 상황인 것이다.

 

일단 동작 파악이 어려워지는 것은 차치하더라도, 우리는 매 인스턴스 생성 시에 기존에 생성해 놓은 인스턴스를 고스란히 받아오고 싶은 것인데 이렇게 내부 요소들이 새로 덮어씌워지는 것은 동작 자체도 잘못된 상황이고, 리소스 면에서도 낭비이다.

 

Method 2. Decorator

이 문제를 해결하는 방법 하나는 데코레이터를 활용하는 것이다.

import random


def singleton(class_):
    instances = {}

    def get_instance(*args, **kwargs):
        if class_ not in instances:
            instances[class_] = class_(*args, **kwargs)
        return instances[class_]

    return get_instance


@singleton
class Database:
    def __init__(self):
        self.id = random.randint(0, 10000)
        print(f"Instance ID: {self.id}")

인스턴스 목록 state를 하나 데코레이터 내에 만들어 두고, 클래스 인스턴스 생성 전에 이미 해당 클래스의 인스턴스가 만들어져 있는 지 판단한 후, 있다면 해당 인스턴스를, 없다면 새로 만들어 반환하는 구현이다. 아래에서 작동 결과를 보자.

>>> d1 = Database()
Instance ID: 8940
>>> d2 = Database()

>>> print(d1 == d2)
True
>>> print(d1.id, d2.id)
8940 8940

싱글턴 데코레이터를 먹여 클래스를 선언하고, 인스턴스 생성을 두 번 해 봤을 때 __init__이 단 한 번만 불렸으며, 주소 값이 동일하며 state 또한 덮어씌워지지 않은 것을 볼 수 있다! 참고로 class_ 와 같이 후에 언더스코어가 붙는 건 변수의 파이썬 기본형과의 네이밍 중복을 피하기 위함이다.

 

Method 3. Metaclass

인스턴스 목록을 state로 가지고 있는 메타클래스를 하나 정의해 구현할 수도 있다.

import random


class Singleton(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super(Singleton, cls)\
                .__call__(*args, **kwargs)
        return cls._instances[cls]


class Database(metaclass=Singleton):
    def __init__(self):
        self.id = random.randint(0, 10000)
        print(f"Instance ID: {self.id}")

클래스 인스턴스를 생성할 때 클래스 ~ 클래스 인스턴스 짝을 저장해 두고 있다가, 이후에 다시 생성할 때에는 해당 클래스 인스턴스가 존재하면 그걸 대신 내보내는 구현이다.

>>> d1 = Database()
Instance ID: 479
>>> d2 = Database()

>>> print(d1 == d2)
True
>>> print(d1.id, d2.id)
479 479

초기화도 단 한번 돌고, 주소도 같으며 같은 state를 공유하는 것을 볼 수 있다. 데코레이터 구현메타클래스 구현 둘 중 취향에 따라 선택해 사용하면 된다.

 

사실 인스턴스 목록을 저장하지 않고 그냥 해당 클래스의 인스턴스만 들고 있어도 (_instance = None으로 초기화 후 인스턴스 대입, 이후 생성시는 None인지 아닌지 확인) 여러 싱글턴 클래스에 대해서 정상적으로 작동하지만, 이런 경우 싱글턴인 클래스를 상속받는 클래스 인스턴스를 생성할 때 부모 클래스에 의해 인스턴스 생성이 막히는 현상이 발생한다. 결국 여러 자식 클래스를 만들어 놓아도 싱글턴 클래스처럼 맨 처음 초기화된 단 한 클래스만을 사용 가능한 것이다.

 

반면 인스턴스 목록을 클래스별로 저장하고 확인하면 부모 클래스와 자식 클래스들의 인스턴스가 별도로 저장되므로, 위와 같은 문제를 방지할 수 있다. 아래 예시를 보고 넘어가자.

class FooFirst(metaclass=Singleton):
    def __init__(self):
        print("Initializing FooFirst!")


class FooSecond(metaclass=Singleton):
    def __init__(self):
        print("Initializing FooSecond!")


class FooChild(FooFirst, metaclass=Singleton):
    def __init__(self):
        super().__init__()
        print("Initializing FooChild!")


>>> foo_1 = FooFirst()
Initializing FooFirst!
>>> foo_2 = FooSecond()
Initializing FooSecond!
>>> foo_3 = FooChild()
Initializing FooFirst!
Initializing FooChild!
>>> foo_4 = FooChild()

>>> print(foo_1 == foo_2)
False
>>> print(foo_1 == foo_3)
False
>>> print(foo_3 == foo_4)
True

 

Optional. Monostate

싱글턴과 비슷한 느낌으로, 모노스테이트 패턴이란 게 존재한다. 이 패턴은 실제로 인스턴스는 별도로 여러개를 가질 수 있지만, 각각은 하나의 state를 서로 공유하도록 만드는 구현이다.

import random


class Database:
    __shared_state = {
        "id": random.randint(0, 10000),
        "num_data": 5,
    }

    def __init__(self):
        self.__dict__ = self.__shared_state
        print(f"Instance ID: {self.id}")

클래스 자체에 shared state 딕셔너리를 하나 만들어 두고 (딕셔너리여야만 한다!), 매 인스턴스를 생성할 때마다 이 딕셔너리를 클래서의 __dict__ 어트리뷰트로 지정한다. __dict__ 어트리뷰트는 클래스의 모든 self variable들이 변수 이름을 키로 가지는 딕셔너리 형태로 저장되어 있는 어트리뷰트라 보면 된다.

(__doc__ 과 마찬가지로 인터랙티브 도구를 쓸 때 정보가 불확실한 클래스의 내부 변수들을 뜯어볼 때 굉장히 유용하게 쓸 수 있다)

 

결국 여기서 하고 있는건 shared state와 모든 클래스 인스턴스의 self 변수 목록을 동기화 시키는 것으로, 딱 하나의 딕셔너리 요소들을 얕은 복사를 통해 각각의 인스턴스 self variable로 뿌리고 있기 때문에 어떤 인스턴스에서 변수 접근을 해도 공통된 딕셔너리의 값을 참조하게 된다. 아래 결과를 보자.

>>> d1 = Database()
Instance ID: 3565
>>> d2 = Database()
Instance ID: 3565
>>> print(d1 == d2)
False
>>> print(d1.id, d2.id)
3565 3565

인스턴스 추기화는 매 호출마다 따로따로 불리고 있고, 실제로 각 인스턴스의 주소도 다르다. 하지만 두 인스턴스의 state는 동일하며, 항상 똑같이 유지된다.

>>> d1.id = 5555

>>> print(d1.id, d2.id)
5555 5555

한 쪽에서 내부 변수 수정을 가하면 다른 쪽에도 적용되는 것을 확인할 수 있다.

class Monostate:
    _shared_state = {}

    def __new__(cls, *args, **kwargs):
        obj = super(Monostate, cls).__new__(cls, *args, **kwargs)
        obj.__dict__ = cls._shared_state
        return obj

class Foo(Monostate):
	def __init__(self):
    	self.foo_var = 0
        self.foo_foo_var = []
        ...

좀 더 간편하게 베이스 클래스를 하나 만들어 두고 이를 상속시키는 식으로 구현할 수도 있다.

 

그런데 이렇게 state를 공유하는 동일한 클래스 인스턴스를 여러 번 만들 수 있게 한다 한들 메소드 목록도 똑같고 그럼 동작도 똑같을 텐데 이걸 어디다 써먹냐.. 생각이 들 수 있는데, 이를 여러 서로 다른 서브클래스로 상속시키면 서로 다른 동작을 하지만 state 자체는 서로 공유하도록 만들 수 있다.

 

Should we not use Singleton?

열심히 싱글턴 패턴 구현에 대해 설명을 했는데.. 잠깐 처음의 이야기로 돌아와 보자. 정말 싱글턴 패턴은 쓰지 말아야 할까? 사실 아니땐 굴뚝에 연기 나지 않듯이 싱글턴 패턴에는 여러 문제가 있긴 하다.

 

어디서든 기존에 초기화해 둔 인스턴스를 아무 조건 없이 가져다 쓸 수 있는 편리함을 주기는 하지만, 프로젝트 레포 코드의 (말 그대로) 어디서든 인스턴스의 접근, 수정이 가능해지면서 점점 기능을 쓰면 쓸 수록 어디서 인스턴스 수정이 가해지는지, 이 인스턴스가 언제 생성되어서 쓰이고 있는지 파악하기가 어려워질 것이다. 이는 코드의 유지보수를 어렵게 만들며, 처음의 편리함을 훨씬 넘어서는 추가 작업이 요구될 수 있다.

 

또한 테스트 코드를 작성할 때에도, 만약 어떤 클래스가 싱글턴 패턴이 적용된 오브젝트에 접근하고 있는 경우 이 오브젝트를 대신할 간단한 dummy 오브젝트를 사용할 방법 없이 얄짤없이 실제 기능을 하는 오브젝트를 사용해야 한다는 문제가 있다.

 

예를 들어, 어떤 클래스가 데이터베이스에 접근하기 위해 self.db = Database() 느낌으로 내부 변수로 지정해 놓았다 해 보자. 이렇게 해 두면 따로 새 데이터베이스 인스턴스를 생성하거나 인자로 받아올 필요 없이 즉각적으로 기존의 데이터베이스 인스턴스에 접근할 수 있지만, 만약 실제 데이터베이스의 데이터가 수시로 바뀌고 있는 상황이거나 테스트 시에 데이터베이스 접근이 불가능한 상황이라면 테스트는 실패하게 될 것이다. 불변하는 간단한 더미 데이터베이스 오브젝트로 격리된 환경에서 테스트를 해야 하지만 코드를 수정하지 않는 이상 따로 방법이 없다.

 

사실 많은 상황에서 싱글턴 패턴을 대체할 만한 구현은 얼마든지 있다. 모듈 내부에서 state를 공유해야 한다면 단순히 모듈 단위로 공유할 수 있는 딕셔너리를 하나 갖고 있어도 되고, 단 한번만 인스턴스를 만들어야 하는 오브젝트는 실제로 단 한번만 만들어 놓고 나서 유저가 굳이 새 인스턴스를 생성하는 작업을 하지 못 하도록 모듈 내부에 잘 숨겨놓는 방법도 있다.

 

매슬로우는 이런 말을 했다. 망치를 든 사람에게는, 모든 문제가 못으로 보인다.

 

어디서든 편리하게 단일 인스턴스를 생성하고 이에 접근할 수 있다는 점은 아주 매력적이지만, 이런 장점으로 인해 더 유지보수가 간편하고 명확성이 더 나은 코드를 작성할 기회를 놓치지 않았으면 좋겠다.

 


아래는 참고할 만한 사이트들이다.

References

Design Patterns 15 Years Later: An Interview with Erich Gamma, Richard Helm, and Ralph Johnson

https://www.informit.com/articles/article.aspx?p=1404056

Udemy: Design Patterns in Python - Singleton

https://www.udemy.com/course/design-patterns-python/

Are Singletons Bad

https://cocoacasts.com/are-singletons-bad

When to use a Singleton in python?

https://stackoverflow.com/questions/14600075/when-to-use-a-singleton-in-python

 

반응형