Devlog

[디자인 패턴 + Python] 딱 하나의 객체만 만들어 주는 싱글톤 패턴 (Singletone Pattern) 본문

개발 방법론/디자인패턴

[디자인 패턴 + Python] 딱 하나의 객체만 만들어 주는 싱글톤 패턴 (Singletone Pattern)

recoma 2022. 6. 14. 20:10
728x90

개요

프로젝트, 특히 서버 관련 프로젝트를 진행하다 보면 가끔씩 이런 생각을 하게 됩니다.

이거 만약에 트래픽이 순식간에 몇천 몇만 개 들어오면 어떡하지? 똑같은 객체를 순식간에 몇 만개를 만들면 메모리가 터지지 않을까?
아니 애초에 굳이 내용물까지 똑같은 객체를 여러 개 만들 필요가 있을까?

예를 들어 외부 리소스와 상호작용하는 모듈 같은 경우(데이터베이스, 캐시 등...) 특정 리소스만 바라보기 때문에, 특수한 경우가 아닌 이상 내용물까지 동일한 객체만 생성하게 됩니다. 이 똑같은 객체 혹은 인스턴스를 생성했다가 삭제했다가, 수천수만 번을 반복하는 것은 시간 뿐만 아니라 메모리 낭비입니다. 이러한 낭비를 막으려면 한번 할당 하고 그 이후에는 할당된 인스턴스만 따로 불러오면 생성/삭제를 더이상 반복할 필요가 없습니다. 이러한 솔루션을 제공해 주는 디자인 패턴이 바로 싱글톤 패턴(Singletone Pattern) 입니다.

원리

원리는 무지무지 간단합니다. 해당 인스턴스를 지역 변수가 아닌 전역 변수에다 선언하면 끝입니다. 이게 다입니다. 아래의 예제를 봅시다.

my_list = None

def get_list():
    global my_list
    if my_list is None:
        my_list = list()
    return my_list

a1 = get_list()
a1.append(3)
a2 = get_list()

print(a1)
print(a2)

# OUTPUT
# [3]
# [3]

get_list() 함수는 my_list가 None이면 리스트를 할당해주고 그렇지 않으면 my_list를 다시 선언하지 않고 기존의 것을 리턴합니다. a1은 새로 할당된 list객체를 받고, a2는 기존에 할당 받았던 객체를 다시 생성하지 않고 그대로 받습니다. 그렇기 때문에 a1과 a2는 동일한 객체가 됩니다. 당연하고 뻔하지만 이는 싱글톤 패턴을 이해하는데 초석이 됩니다.

하지만 이건 어디까지나 싱글톤 개념을 파악하는데 사용될 예제일 뿐이지 실제 싱글톤 패턴은 아닙니다. 당장 날코드에 전역변수를 박아놓는다고 해서 다른 위치의 코드에서 작동한다는 보장은 없습니다. 함수가 아닌 클래스 단위어야 하고 어느 위치에서든 간에 할당된 객체를 그대로 받을 수 있어야 합니다.

예제

from typing import List, Any

class MyArray:

    a: List[Any]
    instance = None

    def __new__(cls):
        if MyArray.instance is None:
            MyArray.instance = super().__new__(cls)
        return MyArray.instance

a1 = MyArray()
a2 = MyArray()

print(id(a1) == id(a2))

# Output
# True

MyArray의 변수 선언부에 a는 할당이 되어 있지 않지만, instance는 None으로 할당되었습니다. __init__이 아닌 아예 처음부터 변수를 할당하게 되면 마치 전역 변수 처럼 어느 위치에 불러와도 같은 인스턴스를 불러오게 됩니다. 이렇게요

>> print(MyArray.instance)
None
>> MyArray.instance = 1
>> print(MyArray.instance)
1

그렇기 때문에 이 점을 활용해서 MyArray.instance가 None이면 객체를 생성해 주고 그렇지 않으면 이 과정을 패스해서 객체인 MyArray.instance를 리턴합니다. __new__에서 리턴된 이 객체는 __init__으로 넘어가서 작업을 수행하게 됩니다.

일단 이 코드는 싱글톤의 기본적인 예 입니다. 하지만 실제로 사용하기에는 적합하지가 않습니다. 이 코드가 실제로 사용할 수 있는 조건은 인스턴스를 초기화 (__init__)할 때 파라미터를 요구하지 않는 경우 입니다. __init__이 파라미터를 요구하게 될 경우. 이미 인스턴스는 할당되었기 때문에, 더이상 파라미터가 필요 없음에도 불구하고 계속 요구하게 됩니다. 아래 예제를 봅시다.

from typing import List, Any

class MyArray:

    a: List[Any]
    instance = None

    def __new__(cls, *args, **kwargs):
        if MyArray.instance is None:
            MyArray.instance = super().__new__(cls)
        return MyArray.instance

    def __init__(self, b: List[Any]):
        self.a = b

a1 = MyArray([1,2,3])
a2 = MyArray([1,2,4])
#a2 = MyArray()
#   TypeError: __init__() missing 1 required positional argument: 'b'

파라미터 없이 MyArray()로 기존 데이터를 받아오는것은 불가능합니다.

따라서 처음 생성할 때, 파라미터를 요구하고, 두 번 째 부터는 기존의 것만 불러오는것이 목적이므로 파라미터가 없는 get_instance를 staticmethod형식으로 구현합니다.

__new__에 *args, **kwargs가 붙은 이유는 파라미터가 __new__에서부터 들어오기 때문입니다.

from typing import List, Any

class MyArray:

    a: List[Any]
    instance = None

    def __new__(cls, *args, **kwargs):
        if MyArray.instance is None:
            MyArray.instance = super().__new__(cls)
        return MyArray.instance

    def __init__(self, b: List[Any]):
        self.a = b

    @staticmethod
    def get_instance():
        if not MyArray.instance:
            raise ValueError('__init__으로 인스턴스를 먼저 생성하세요')
        else:
            return MyArray.instance

a1 = MyArray([1,2,3])
a2 = MyArray.get_instance()

print(a1 == a2)

get_instance는 instance가 있으면 해당 객체를 리턴하지만 없으면 ValueError를 호출합니다. a1으로 한번 선언하고 그 다음 부턴 get_instance()함수를 사용하면 기존의 객체를 불러올 수 있게 됩니다.

모노스테이트 싱글톤 패턴 (Monostate Singletone Pattern)

방금전 배웠던 싱글톤의 메커니즘이 단 하나의 객체만 생성이었다면, 모노스테이트 싱글톤 패턴(Monostate Singletone Pattern)은 여러개의 객체가 단 하나의 상태를 공유합니다. 즉, 객체는 여러개 생성이 되지만 이 객체들이 서로 공통적으로 공유하는 변수들이 존재합니다. 아래 예제를 봅시다.

class MyInt:

    n: int
    _shared_state: Dict[str, bool] = {'locked': False, 'num': None}
    
    def __init__(self, num: int):
        self.__dict__ = MyInt._shared_state
        self.num = num

a = MyInt(10)
b = MyInt(20)

print(a == b)		# False
a.num = 3			# 3
print(a.__dict__)	# {'locked': False, 'num': 20}
print(b.__dict__)	# {'locked': False, 'num': 20}

일단 __new__ 오버라이딩을 하지 않았습니다. 인스턴스 할당 존재 여부를 물어보지 않았기 않았고 이에 대한 대처가 없기 때문에 a와 b는 서로 다른 객체입니다.

하지만 self.__dict__ = MyInt._shared_state에서 생성과 동시에 전역 변수인 MyInt._shared_state로 덮어썼기 때문에 a와 b는 동일한 멤버 변수를 갖게 되고 a.num = 3으로 num를 변경하게 되면 b.num도 바뀌는 걸 볼 수가 있습니다.

그런데 저는 개인적으로 일반 싱글톤과 다른 점이 없다고 생각하는게,  저 위에나 지금 모노스테이트나 결국 똑같은 데이터를 바라보기 때문에, 차라리 메모리를 덜 잡아먹는 일반 싱글톤으로 사용하는 것이 조금 낫다고 생각했습니다.

그렇기에 개인적으로 모노스테이트 코드를 일부 멤버 변수만 공유할 수 있게 살짝 바꿔보았습니다.

class MyInt:

    class Meta:
        locked: bool = False
    
    num: int
    def __init__(self, num: int):
        self.num = num

    def lock(self):
        self.Meta.locked = True
    
    def unlock(self):
        self.Meta.locked = False

    def get_num(self):
        if self.Meta.locked:
            raise PermissionError('Data Locked')
        return self.num

    def set_num(self, num):
        if self.Meta.locked:
            raise PermissionError('Data Locked')
        self.num = num

a = MyInt(10)
b = MyInt(20)

a.set_num(100)

print(a.get_num())  # 100
print(b.get_num())  # 20

a.lock()

try:
    b.get_num()
except PermissionError:
    print('B Locked')   # B Locked

a.unlock()
print(b.get_num())  # 20

변수를 선언하고 get_num으로 정수를 받되, 잠금이 해제된 상태(False)에서만 받을 수 있는 프로그램을 구현했습니다.

shared_state 대신 class Meta가 선언되었습니다. class단위에서 locked가 False으로 할당되었기 때문에, 어느 위치에서든 간에 Meta.locked를 공유할 수 있게 되었습니다. 대신 변수 num은 class 단위에서 할당되어 있지 않기 때문에 각 객체마다 다른 값을 가지게 됩니다.

따라서 a, b가 선언되었을 때 a에서 lock을 걸어주면 a, b 둘다 동시에 Meta.locked가 True로 설정되어 b에서도 데이터를 받지 못하고  PermissionError를 호출하게 됩니다.

싱글톤 패턴의 단점

메모리 절약에 많은 도움을 주는 싱글톤 패턴은 검증된 패턴이지만 딱 하나 단점이 존재합니다. 바로 인스턴스가 전역 변수라는 점인 데, 이는 곧 다른 로직에서도 접근이 가능하다는 얘기이기 때문에 이에 대한 대비를 따로 해주지 않으면 보안적인 결함이 생길 수도 있습니다. 멀티 쓰레드 단위에서도 동시에 인스턴스를 수정하려고 하는 경우, 무결성에 문제가 생길 수 있습니다. (이때 mutex를 사용하여 해결할 수 있습니다.)

싱글톤 패턴은 객체를 여러 번 생성할 것을 단 한번 생성하고, 생성된 것만 불러들임으로써, 메모리를 절약할 수 있습니다. 사실상 전역 변수 이기에, 보안성에 결함이 생길 수 있지만, 싱글톤 패턴은 여러 바리에이션으로 수정하기 쉬운 패턴으로 상황에 맞게 변형해서 구현하면 성능 최적화에 큰 도움이 될 것입니다.

728x90
반응형