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)
이 클래스는 이제 두 가지를 한다:
- DB 쿼리 실행
- 자신이 하나뿐임을 보장
"하나만 만들겠다"는 건 앱 설계 수준의 결정이다. 그걸 DatabaseManager 클래스 안에 집어넣으면, 나중에 "테스트에서 두 개 만들고 싶은데"가 됐을 때 클래스를 뜯어고쳐야 한다.
DI 방식에서는 이 결정이 클래스 밖(composition root)에 있으니까 DatabaseManager 자체는 건드리지 않아도 된다.