research

Singleton 패턴의 3가지 단점

Singleton 패턴의 3가지 단점

do-we-need-a-class|대안 비교 노트에서 나온 단점들을 상세히 풀어본다.

세 단점의 공통 뿌리는 하나다: "하나만 만들겠다"는 결정을 클래스 안에 숨긴 것.


❌ 1. 숨겨진 전역 상태 — 의존성이 코드에 안 보임

class OrderService:
    def create_order(self, data):
        db = DatabaseManager()  # 싱글턴 호출
        db.execute("INSERT INTO orders ...")

이 코드만 보면 OrderService가 DB를 쓴다는 게 안 보인다. 함수 시그니처엔 data만 있기 때문.

DI 방식이라면:

class OrderService:
    def __init__(self, db: DatabaseManager):  # "나 DB 써" 라고 선언
        self.db = db

__init__만 봐도 이 클래스가 뭘 필요로 하는지 바로 알 수 있다.

코드베이스가 커졌을 때 문제가 드러난다. UserService, OrderService, PaymentService가 전부 내부에서 DatabaseManager()를 부르고 있는데, DB 연결 방식을 바꿔야 한다면? 모든 서비스를 열어봐야 어디서 쓰는지 파악할 수 있다.


❌ 2. 테스트가 어렵다 — 테스트끼리 상태 공유

def test_create_order():
    service = OrderService()
    service.create_order({"item": "book"})
    # 내부에서 DatabaseManager() 호출 → 실제 DB에 데이터 삽입됨

def test_cancel_order():
    service = OrderService()
    service.cancel_order(1)
    # 위 테스트에서 삽입된 데이터가 여기서도 남아있음

싱글턴은 앱 전체 생명주기 동안 하나다. 테스트도 그 생명주기 안에서 돌아가니까, 테스트 A에서 DB에 뭔가를 썼으면 테스트 B에서도 그게 남아있다.

두 가지 문제가 생긴다:

  • 테스트 순서에 의존 — A 다음에 B를 실행하면 통과하는데, B만 따로 실행하면 실패
  • mock 교체가 어렵다 — 실제 DB 대신 메모리 DB를 쓰고 싶어도, 싱글턴이 이미 실제 conn을 물고 있으면 중간에 바꾸기 번거로움

❌ 3. SRP 위반 — 클래스가 자기 생명주기를 스스로 관리

SRP(Single Responsibility Principle): "클래스는 하나의 책임만 가져야 한다."

DatabaseManager의 원래 책임은 DB 쿼리를 실행하는 것이다. 근데 Singleton을 넣으면:

class DatabaseManager:
    _instance = None

    def __new__(cls):           # ← "나는 하나만 존재해야 해" 관리
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.conn = sqlite3.connect("db.sqlite")
        return cls._instance

    def execute(self, query):   # ← 원래 책임: DB 쿼리
        return self.conn.execute(query)

이 클래스는 이제 두 가지를 한다:

  1. DB 쿼리 실행
  2. 자신이 하나뿐임을 보장

"하나만 만들겠다"는 건 앱 설계 수준의 결정이다. 그걸 DatabaseManager 클래스 안에 집어넣으면, 나중에 "테스트에서 두 개 만들고 싶은데"가 됐을 때 클래스를 뜯어고쳐야 한다.

DI 방식에서는 이 결정이 클래스 밖(composition root)에 있으니까 DatabaseManager 자체는 건드리지 않아도 된다.