싱글톤 패턴의 유혹과 함정: 왜 모던 아키텍처는 의존성 주입(DI)을 선택했는가?

📌 이 글의 핵심 내용

  • 싱글톤(Singleton) 패턴이 '안티 패턴'으로 불리는 이유 (테스트와 결합도 문제)
  • 제어의 역전(IoC)의존성 주입(DI)을 통한 우아한 해결책
  • GoF의 '싱글톤 패턴'과 Spring/NestJS의 '싱글톤 빈(Bean)'의 결정적 차이
  • 테스트 가능한 코드를 위한 리팩토링 예제

객체 지향 프로그래밍(OOP)을 처음 배울 때 가장 먼저 접하는 디자인 패턴 중 하나가 바로 싱글톤(Singleton)입니다. "애플리케이션 전체에서 단 하나의 인스턴스만 존재함을 보장한다"는 개념은 데이터베이스 연결이나 설정 관리자 등을 만들 때 매우 매력적으로 들립니다.

하지만 실무 경험이 쌓일수록 개발자들은 고전적인 싱글톤 패턴을 기피하게 됩니다. 왜 그럴까요? 그리고 Spring이나 NestJS 같은 모던 프레임워크는 이 문제를 어떻게 해결했을까요?

싱글턴 패턴, 의존성 주입

1. 고전적 싱글톤의 치명적 단점 (Anti-Pattern)

우리가 흔히 Java나 C++ 교과서에서 배우는 getInstance() 방식의 싱글톤은 현대 애플리케이션 개발에서 독이 될 수 있습니다.

① 숨겨진 의존성 (Hidden Dependency)

가장 큰 문제는 의존 관계가 코드 내부에 숨어버린다는 것입니다.

public void processOrder() { // 이 메소드만 봐서는 DatabaseManager가 필요한지 알 수 없음 // 코드 깊숙한 곳에서 전역 상태를 끌어다 씀 (강한 결합) DatabaseManager.getInstance().connect(); }

함수의 시그니처(파라미터)만 보고는 이 함수가 무엇을 필요로 하는지 알 수 없습니다. 코드를 한 줄 한 줄 다 읽어봐야만 "아, 여기서 DB를 쓰는구나" 하고 알 수 있습니다.

② 단위 테스트(Unit Test)의 지옥

싱글톤은 전역 상태(Global State)를 가집니다. 테스트 A에서 싱글톤 객체의 데이터를 변경하면, 그 상태가 테스트 B에도 그대로 남아있습니다.

  • 테스트 순서에 따라 결과가 달라지는 비결정적(Non-deterministic) 테스트가 됩니다.
  • 가짜 객체(Mock)로 대체하기가 매우 어렵습니다. getInstance()는 static 메소드라 오버라이딩이 안 되기 때문입니다.

2. 해결책: 의존성 주입 (Dependency Injection)

이 문제를 해결하기 위해 등장한 것이 DI(Dependency Injection)입니다. 객체가 필요한 의존성을 스스로 찾거나 만들지 않고, "외부에서 넣어주는(주입하는)" 방식입니다.

public class OrderService { private final DatabaseManager dbManager; // 생성자를 통해 외부에서 주입받음 (명시적 의존성) public OrderService(DatabaseManager dbManager) { this.dbManager = dbManager; } public void processOrder() { dbManager.connect(); } }

이제 OrderServiceDatabaseManager가 싱글톤인지, 매번 새로 생성되는지 알 필요가 없습니다. 그저 누군가 넣어준 것을 쓸 뿐입니다. 이를 통해 느슨한 결합(Loose Coupling)이 달성됩니다.

3. 프레임워크의 마법: Singleton Registry

"그럼 싱글톤은 아예 안 쓰나요? 객체를 매번 생성하면 메모리 낭비 아닌가요?"

여기서 중요한 개념이 등장합니다. GoF의 싱글톤 패턴은 지양하지만, 스프링 컨테이너가 관리하는 싱글톤 빈(Singleton Bean)은 적극 권장합니다.

구분 GoF 싱글톤 패턴 스프링 싱글톤 빈
구현 방식 static 메소드, private 생성자 등 코드 레벨에서 강제 평범한 클래스(POJO)를 컨테이너가 1개만 생성해서 관리
테스트 용이성 나쁨 (Mocking 어려움) 좋음 (그냥 인터페이스로 갈아끼우면 됨)
결합도 강한 결합 (Tight Coupling) 느슨한 결합 (Loose Coupling)

Spring이나 NestJS 같은 IoC 컨테이너는 애플리케이션 시작 시 객체를 딱 한 번만 생성해서 보관(Registry)하고 있다가, 필요로 하는 곳에 주입해 줍니다. 개발자는 싱글톤 구현 코드를 짤 필요 없이 @Component@Injectable만 붙이면 됩니다.

4. Mock 객체를 이용한 테스트 예시

DI를 사용하면 테스트가 얼마나 쉬워지는지 보겠습니다.

@Test void orderTest() { // 1. 실제 DB 대신 가짜(Mock) 객체 생성 DatabaseManager mockDb = Mockito.mock(DatabaseManager.class); // 2. 의존성 주입 (생성자에 가짜를 넣음) OrderService service = new OrderService(mockDb); // 3. 테스트 실행 service.processOrder(); // 4. 검증: connect()가 호출되었는지 확인 verify(mockDb).connect(); }

만약 고전적 싱글톤을 썼다면 mockDb를 주입할 방법이 없어 실제 DB 연결을 시도하다가 테스트가 실패했을 것입니다.

5. 결론

싱글톤은 "필요악"이 아니라 "컨테이너의 역할"입니다.

객체를 하나만 유지하고 싶다면, 코드를 복잡하게 꼬아서 싱글톤 패턴을 구현하지 마십시오. 대신 DI 컨테이너에게 그 역할을 위임하고, 여러분의 코드는 순수한 POJO(Plain Old Java Object) 상태로 유지하십시오. 그것이 유연하고 테스트 가능한 아키텍처로 가는 지름길입니다.

이 블로그의 인기 게시물

Docker 컨테이너 'Connection Refused' (Errno 111) 오류 해결 가이드

Redis 캐싱 전략 완벽 가이드: Look Aside부터 Write Back까지 (DB 부하 줄이기)

브라우저 렌더링 원리: Reflow와 Repaint 최적화 가이드 (CRP 심층 분석)