컴퓨터공학

싱글턴과 DI 컨테이너 구조

nyambu 2025. 5. 23. 08:00

싱글턴과 DI 컨테이너 구조
싱글턴과 DI 컨테이너 구조

1. 싱글턴 패턴의 본질과 한계

1-1. 싱글턴은 왜 등장했을까?

 객체지향 설계에서 싱글턴(Singleton)은 "클래스의 인스턴스를 단 하나만 생성하고, 어디서든 접근할 수 있도록 하는 패턴"이다. 이 패턴은 주로 다음 두 가지 상황에서 유용하다.

  • 애플리케이션 전체에서 공유 자원이 필요할 때
  • 하나의 인스턴스만 있어야 일관성을 유지할 수 있는 경우

 예를 들어, 로그 기록기(Logger), 설정 객체(Config), DB 커넥션 풀(Connection Pool)처럼 애플리케이션 전체에서 동일한 인스턴스를 사용하는 것이 바람직한 경우 싱글턴이 사용되었다.

public class Logger {
    private static final Logger instance = new Logger();
    private Logger() {}
    public static Logger getInstance() {
        return instance;
    }
}

1-2. 싱글턴이 가진 명확한 장점

  • 메모리 효율: 하나의 객체만 생성되기 때문에 자원을 절약할 수 있다.
  • 글로벌 접근성: 어디서든 동일한 객체를 사용할 수 있어 편리하다.
  • 데이터 일관성: 상태가 공유되기 때문에 전역 설정이나 공통된 데이터를 일관되게 유지할 수 있다.

 그래서 예전에는 싱글턴이 모든 프레임워크와 서비스 설계에서 거의 당연하게 도입되었다.

1-3. 그러나 싱글턴은 점점 문제를 낳기 시작했다

 초기에는 싱글턴이 매우 실용적인 패턴으로 받아들여졌지만, 시스템이 커지고 복잡도가 증가하면서 그 설계적 한계가 드러나기 시작했다. 객체가 단 하나만 존재해야 한다는 고정된 구조는 변화에 대한 유연성을 현저히 떨어뜨린다. 특히 객체 생성에 전략이 개입되거나, 구성에 따라 구현체를 바꿔야 하는 상황에서는 싱글턴이 구조적으로 큰 제약이 된다.

 

 또한 싱글턴은 일반적으로 정적 접근을 통해 사용되기 때문에, 객체 간 의존 관계를 코드상에서 명확하게 드러내지 않는다. 이러한 특성은 유지보수 시 큰 혼란을 불러올 수 있다. 특정 객체가 내부적으로 어떤 싱글턴에 의존하고 있는지를 파악하기 어려워지고, 변경 영향 범위를 추적하기가 매우 까다로워진다.

 

 무엇보다 테스트 자동화나 병렬 요청 처리가 보편화된 현재 환경에서는, 싱글턴이 암묵적인 상태 공유와 함께 의도치 않은 사이드 이펙트를 유발할 수 있다. 테스트 간 격리가 어렵고, 멀티스레드 환경에서는 동기화 이슈가 발생한다. 이는 싱글턴 구조가 “객체 생명주기를 제어하기 어렵다”는 중요한 경고로 작용한다.

1-4. 요즘은 싱글턴을 직접 만들지 않는다

 현대 개발에서는 대부분 프레임워크가 싱글턴 객체 생성을 자동으로 처리한다. 예를 들어 Spring, NestJS, Angular 등의 DI 컨테이너는 Bean 또는 서비스 클래스를 싱글턴으로 관리한다. 따라서 개발자는 객체 생성 로직에 집중하지 않고 역할만 선언하면 프레임워크가 알아서 싱글턴을 관리해준다.


2. DI(Dependency Injection)와 컨테이너란 무엇인가?

2-1. 의존성 주입(DI)의 개념

 의존성 주입(Dependency Injection, DI)은 객체가 필요한 다른 객체를 직접 생성하지 않고, 외부에서 주입받는 방식의 설계 기법이다. 이는 객체 간 결합도를 낮추고, 변경과 확장에 유리한 유연한 시스템 구조를 만드는 데 핵심적인 역할을 한다. 객체는 더 이상 "필요한 게 있다면 직접 new 해서 만든다"는 사고를 하지 않고, "외부에서 알아서 제공해주는 걸 쓰겠다"는 철학으로 전환하게 된다.

 

 예를 들어 OrderService가 PaymentProcessor를 직접 생성한다면, 구현체가 바뀔 때마다 코드도 수정되어야 한다. 하지만 생성자를 통해 PaymentProcessor를 주입받게 하면, 런타임 시점에 어떤 구현체를 쓰든 상관없이 OrderService는 동일하게 동작할 수 있다. 이 방식은 테스트에서도 매우 유리하다. 실제 구현체 대신 테스트용 Mock 객체를 쉽게 넣어줄 수 있기 때문이다.

 

 DI의 진짜 가치는 객체의 책임을 명확히 나누고, 변경에 강한 시스템을 만드는 데 있다. 클래스는 오직 자신의 역할에 집중하고, 필요한 협력 객체는 외부에서 공급받는다. 이는 객체지향 설계 원칙인 DIP(Dependency Inversion Principle)와 SRP(Single Responsibility Principle)를 함께 실현할 수 있는 가장 대표적인 방법이다.

2-2. DI 컨테이너는 무엇을 해주는가?

 DI를 실무에서 효과적으로 활용하려면, 누군가가 객체를 대신 생성하고 필요한 의존 객체를 알아서 연결해주는 구조가 필요하다. 이를 담당하는 것이 바로 DI 컨테이너다. DI 컨테이너는 클래스 간의 의존성을 분석하고, 객체를 생성하며, 생성된 객체를 적절한 시점에 주입하는 역할을 수행한다. 객체 간의 조합을 코드가 아닌 설정으로 전환시키는 것이다.

 

 Spring에서는 ApplicationContext, Angular에서는 Injector, NestJS에서는 Module 시스템이 이 역할을 한다. 이 컨테이너들은 개발자가 직접 new 키워드를 쓰지 않고도, 필요한 객체를 선언해두면 자동으로 관리하고 주입해준다. 이는 객체 생성과 조합 책임을 완전히 컨테이너로 분리해, 개발자는 객체의 "행동"에만 집중할 수 있게 만든다.

 

 특히 컨테이너는 클래스 간의 순환 참조, 생명주기 관리, 범위 지정 등을 통합적으로 제어할 수 있기 때문에, 시스템이 커질수록 유지보수성과 확장성이 극대화된다. 객체 생성 방식이 프레임워크 차원에서 통일되면서, 협업하는 개발자 간의 코딩 컨벤션도 자연스럽게 일관성을 가지게 된다.

2-3. 선언적 의존성 관리란?

 프레임워크 기반 개발에서는 DI를 "선언적"으로 처리한다. 이는 코드에서 어떤 객체가 어떤 의존성을 가지는지를 명확히 선언만 해두면, 나머지는 프레임워크가 처리한다는 개념이다. 예를 들어 @Autowired, @Inject, @Service 같은 애노테이션을 붙이면 DI 컨테이너가 해당 의존성을 주입해준다.

 

 이 방식은 코드의 목적을 훨씬 명확하게 만들어준다. 생성자나 필드에 어떤 객체가 주입되는지를 명확하게 볼 수 있고, 객체가 혼자 동작하지 않고 어떤 협력 객체와 함께 동작하는지를 쉽게 파악할 수 있다. 즉, 객체의 설계 의도와 협력 구조가 선언적으로 드러나기 때문에 유지보수가 쉬워진다.

 

 또한 선언적 DI는 코드보다 설정으로 설계를 통제하는 구조로, 객체 생성 전략이 코드 밖으로 빠지게 된다. 이는 테스트, 기능 스위칭, A/B 테스트, 멀티 프로파일 환경 등에 유리한 설계로 이어진다. 객체 간의 연결 방식을 하드코딩하지 않아도 되므로, 설계 유연성이 획기적으로 증가한다.

2-4. DI는 싱글턴의 대안이 아닌 진화형이다

 DI는 종종 싱글턴의 대안으로 오해되지만, 실상은 싱글턴 개념을 한층 더 발전시킨 구조다. DI 컨테이너는 내부적으로 객체를 싱글턴으로 생성하고 관리하지만, 개발자는 이를 신경 쓰지 않아도 된다. 즉, 전역 상태를 직접 제어하지 않고도 전역처럼 동작하는 객체를 다룰 수 있게 해준다.

 

 또한, DI는 단지 싱글턴만이 아니라 범위(scope)에 따라 다양한 객체 생명주기를 관리할 수 있다. 예컨대 매번 새 인스턴스가 필요한 객체는 프로토타입으로, 사용자 요청마다 새로 생성되어야 할 객체는 요청 스코프로 설정할 수 있다. 싱글턴의 제약은 남기고, 유연성은 확장한 것이 DI 구조다.

 

 프레임워크를 사용할 때 우리가 직접 싱글턴을 구현할 필요가 없는 것도 이 때문이다. 우리는 단지 ‘어떤 객체가 필요하다’는 선언만 하면, 생성 시점, 횟수, 범위 등은 모두 DI 컨테이너가 알아서 처리한다. 더 이상 getInstance() 같은 정적 호출에 의존할 필요가 없어진 것이다.


3. 프레임워크에서의 싱글턴 vs DI 구조 비교

3-1. 직접 구현한 싱글턴과 DI 컨테이너의 차이

 직접 구현한 싱글턴은 매우 직관적이다. 하나의 정적 필드를 만들고, 외부에서 그 인스턴스를 반환하는 메서드를 통해 접근한다. 하지만 이 방식은 의존성을 숨기고, 테스트가 어려우며, 변경 가능성이 거의 없는 구조를 만들게 된다. 코드가 점점 더 고립되며, 모듈 간의 분리가 어려워지는 결과를 낳는다.

 

 반면, DI 컨테이너는 객체 생성과 의존성 연결을 분리하고, 필요할 때마다 미리 준비된 싱글턴 인스턴스를 주입해준다. 여기서의 핵심은 의존성을 숨기지 않고 명시적으로 표현하며, 유지보수 가능성과 확장 가능성을 함께 확보한다는 점이다. 직접 구현한 싱글턴이 "무조건 하나만 있어야 해"라면, DI 컨테이너의 싱글턴은 "필요하면 하나만 만들고, 유연하게 조정 할 수 있어"에 가깝다. 즉, 개념은 유사하지만 제어 권한과 유연성 면에서 차원이 다른 접근이다.

3-2. 프레임워크에서의 생명주기 관리

 Spring에서는 기본적으로 모든 Bean이 싱글턴이다. 그러나 상황에 따라 생성 주기를 조절할 수 있다. @Scope("prototype")을 설정하면 호출할 때마다 새로운 인스턴스를 주입받을 수 있고, 웹 환경에서는 @RequestScope, @SessionScope 등을 활용하여 요청이나 세션 단위의 객체 생성을 설정할 수 있다. 

 

 NestJS 또한 DI 컨테이너 기반으로 Provider를 싱글턴으로 관리하지만, 모듈 범위나 트랜지언트(Transient) 설정을 통해 생성 주기를 조절할 수 있다. 이처럼 현대 프레임워크는 기본적으로 효율을 위해 싱글턴을 유지하되, 상황에 맞춰 스코프를 변경할 수 있는 유연함을 함께 제공한다. 

 

 이런 생명주기 관리 기능은 단순히 메모리 절약을 넘어서, 설계상의 유연함과 테스트 편의성, 병렬성 고려까지 가능한 확장된 싱글턴 개념이라고 할 수 있다.

3-3. 테스트와 구성의 유연성

 DI 구조의 진가는 테스트와 환경 구성 변경에 있다. 객체가 직접 다른 객체를 생성하지 않고, 외부에서 주입받는 구조이기 때문에 테스트에서는 손쉽게 다양한 구현체를 삽입할 수 있다. 예를 들어 실 운영 환경에서는 Redis 캐시를 사용하고, 테스트 환경에서는 InMemory 캐시를 사용하도록 구성 파일만 바꾸면 된다.

 

 또한 DI 컨테이너는 환경에 따라 서로 다른 객체 구성을 자동으로 매칭해줄 수 있다. Spring에서는 @Profile을 사용해 개발, 테스트, 운영 환경별 Bean을 분리하고, NestJS에서는 동적 모듈을 통해 실행 시점의 조건에 따라 구성 요소를 교체할 수 있다. 이 모든 유연성은 객체 생성 책임이 외부로 빠졌기 때문에 가능한 일이다.

 

 이처럼 DI는 단순히 코드 구조를 깔끔하게 만드는 도구가 아니라, 설정과 구성의 확장성과 변경 용이성까지 함께 제공하는 설계 구조다. 새로운 정책이나 기능을 적용할 때 객체를 갈아끼우는 작업이 매우 쉬워지고, 코드 변경 없이 동작 변경이 가능해진다.

3-4. 객체 생성 책임의 이동이 설계를 바꾼다

 DI의 핵심은 객체 생성 책임이 객체 자신이 아니라 외부로 이동한다는 데 있다. 이 변화는 단순한 코드 수정이 아니라, 소프트웨어 설계 철학의 변화를 의미한다. 객체는 이제 협력자와의 관계를 의식하며 살아간다. 각 객체는 독립적인 생명력을 가지고 있으며, 협업의 방식을 컨테이너에 위임함으로써 더 건강한 객체 간 관계망을 형성할 수 있다. 결국 DI 구조는 싱글턴보다 "더 나은 협업 방식"이다. 객체 하나의 생명주기보다, 시스템 전체의 유연성과 테스트 가능성이 더 중요해진 지금, DI는 단순한 기술을 넘어 소프트웨어 설계의 표준으로 자리 잡았다.


4. 실전 예제와 전략적 적용

4-1. 캐시 매니저 설계 예제

 CacheManager는 여러 서비스에서 함께 사용하는 전형적인 싱글턴 대상이다. 직접 싱글턴으로 구현하면 CacheManager.getInstance()처럼 코드 전역에서 호출되는 구조가 되고, 이는 테스트 어려움과 의존성 추적의 문제를 발생시킨다. 

 

 Spring에서는 @Configuration 클래스에서 Redis 또는 Memory 캐시를 Bean으로 등록하고, 필요한 서비스에서 생성자 주입을 통해 사용한다. 이렇게 하면 실제 운영에서는 Redis를, 테스트에서는 메모리 기반의 가짜 캐시를 손쉽게 바꿔치기할 수 있다. DI가 없었다면 매번 조건문으로 캐시 전략을 분기하거나, 테스트 시 객체 생성 로직을 복제해야 했을 것이다.

 

 이처럼 DI는 하나의 객체가 모든 곳에서 일관되게 사용되면서도, 변경과 테스트가 가능한 구조를 만들어준다. 단순히 싱글턴을 “하나만 만든다”는 개념에서, “하나지만 상황에 따라 유연하게 바꿀 수 있다”는 개념으로 진화시킨다.

4-2. 요청 단위 인스턴스를 다뤄야 할 때

 DI 컨테이너가 싱글턴만 제공한다고 오해하는 경우가 많지만, 실제로는 다양한 스코프를 설정할 수 있다. 그중 대표적인 것이 요청(Request) 단위 스코프다. 예를 들어 사용자의 로그인 세션 정보를 다루는 UserSession 객체는 모든 사용자에게 동일한 인스턴스를 주면 안 된다. 요청마다 다른 상태를 유지해야 하기 때문이다.

 

 Spring에서는 이를 위해 @RequestScope를 사용할 수 있다. 이 애노테이션을 붙이면 HTTP 요청이 들어올 때마다 새로운 인스턴스를 만들어 주입한다. NestJS에서도 비슷하게 요청 단위로 생성할 수 있는 Scope.REQUEST 기능을 제공하며, 각 요청에 대해 별도의 Provider 인스턴스를 관리한다.

 

 이 구조는 병렬 요청 처리와 사용자 간 상태 분리에서 결정적인 역할을 한다. 단일 애플리케이션이 여러 사용자의 요청을 동시에 처리해야 할 때, 요청 간 상태 충돌을 방지하기 위한 구조적 해법이기도 하다. 이를 통해 우리는 싱글턴의 이점은 누리되, 요청마다 격리된 객체를 사용할 수 있는 균형 잡힌 구조를 설계할 수 있다.

4-3. 컨테이너 싱글턴은 무상태 객체와 찰떡궁합

 모든 싱글턴 객체가 문제가 되는 건 아니다. 핵심은 그 객체가 상태를 가지느냐 아니냐에 달려 있다. 예를 들어 설정 정보를 읽는 AppConfig, 외부로 이벤트를 전송하는 EventPublisher, 통계를 수집하는 MetricCollector와 같은 클래스는 내부 상태가 없기 때문에 싱글턴으로 두어도 아무런 문제가 없다.

 

 무상태 객체는 동시성 문제도 없고, 테스트에서도 상태 초기화가 필요하지 않기 때문에 오히려 싱글턴으로 관리하는 것이 효율적이다. 컨테이너는 이러한 무상태 객체를 자동으로 싱글턴으로 생성하고, 어디서든 필요할 때마다 주입해준다. 개발자는 객체의 역할만 정의하면 된다.

 

 이러한 구조는 코드의 재사용성을 높이고, 메모리 사용량도 줄인다. “싱글턴은 무조건 나쁘다”는 생각보다는, “상태를 가진 싱글턴은 주의하고, 무상태 객체는 오히려 싱글턴으로 관리하자”는 설계 철학이 더 실무적이다. 이는 컨테이너가 지향하는 기본 전략과도 일치한다.

4-4. 컨테이너 없는 환경에서의 대안

 Go, Rust, C++과 같은 언어는 DI 컨테이너가 기본으로 탑재되어 있지 않다. 이들은 의존성을 명시적으로 전달하고, 객체의 생성과 조합을 개발자가 직접 제어하는 방식을 택한다. 그 결과 DI가 없는 대신, 의존 관계가 명확하고 코드 흐름이 예측 가능하다는 장점이 있다.

 

 예를 들어 Go에서는 서비스 객체를 함수나 구조체 생성자에서 직접 생성하고, 필요한 의존 객체를 인자로 넘겨준다. 이 방식은 코드가 장황해질 수 있지만, 디버깅이 쉽고 테스트에서 필요한 구성 요소를 명확하게 모킹할 수 있다. 또한 객체 간 결합도가 낮기 때문에 유지보수성이 좋다.

 

 결국 DI 컨테이너의 존재 유무보다 중요한 건 의존성 관리에 대한 태도다. 프레임워크가 없더라도 객체가 필요한 협력자를 직접 만들지 않고 외부로부터 주입받는 구조를 유지한다면, DI의 본질은 충분히 실현할 수 있다. 중요한 건 도구가 아니라 설계 철학이다.


5. 설계자가 자주 실수하는 싱글턴 & DI 오용 사례

5-1. 전역처럼 쓰는 싱글턴 객체의 남용

 실제 실무에서 자주 보이는 오용 사례는 “전역처럼 싱글턴 객체를 아무 곳에서나 가져다 쓰는 것”이다. Logger.getInstance(), Config.getInstance() 같은 호출이 서비스, 도메인, 컨트롤러에 중구난방으로 퍼져 있을 때, 시스템은 점점 유지보수 불가능한 구조로 향하게 된다. 이때 가장 큰 문제는 코드에 드러나지 않는 은닉된 의존성이다.

 

 이러한 구조에서는 코드 리뷰나 리팩토링 시 어떤 객체가 어떤 전역 상태에 의존하고 있는지 파악하기 어려우며, 객체 간 협력 관계가 코드 상에서 분명하게 드러나지 않는다. 이는 시스템 확장 시 장애를 야기하고, 코드 수정 범위를 지나치게 넓히는 원인이 된다.

 

 더 나아가, 상태를 가진 싱글턴이 남용되면 병렬 환경에서 상태 충돌과 동기화 문제가 발생할 수 있다. 이는 멀티스레드 환경에서는 치명적인 버그로 이어질 수 있다. 전역 접근이 가능하다는 편리함은 결국 “모든 곳에서 그 객체를 망칠 수 있다”는 위험성과 직결된다.

5-2. 컨테이너를 ‘마법처럼’ 쓰는 경우

 DI 컨테이너를 쓴다고 해서 무조건 설계가 좋은 건 아니다. 종종 개발자들은 컨테이너가 객체를 알아서 다 주입해주니까, "무조건 주입받고 보자"는 식으로 설계를 하게 된다. 그 결과는 한 서비스 클래스가 5~10개 이상의 의존성을 가지는 거대한 괴물 클래스다. 이 경우 그 클래스는 분명 SRP(Single Responsibility Principle)를 위배하고 있으며, 설계가 이미 응집도를 잃은 상태다. 또한 너무 많은 의존성을 갖는 클래스는 테스트가 매우 어려워지고, 새로운 개발자가 코드를 이해하는 데 걸리는 시간도 늘어난다. 실제로는 컨테이너가 책임을 맡은 것이 아니라, 설계자가 책임 분리를 하지 않은 것이다. 해결 방법은 명확하다.

  • 협력자 수가 많으면 책임이 나뉘어야 한다.
  • 한 클래스는 하나의 핵심 책임만 가지게 하고, 그 책임을 수행하기 위한 보조 객체를 주입받는다.
  • 그리고 그 보조 객체는 다시 다른 작은 책임으로 분리한다.

5-3. 테스트 환경에서 싱글턴의 잔재

 DI를 도입했음에도 테스트가 어려운 시스템이 존재하는 이유는 “싱글턴의 잔재가 여전히 코드에 남아 있기 때문”이다. 특히 레거시 시스템에서는 서비스 클래스 내부에서 SomeService.getInstance() 같은 정적 호출이 여전히 남아 있어, 외부에서 어떤 Mock을 주입하든 테스트 대상이 실제 싱글턴을 참조해버린다.

 

 이러한 구조에서는 단위 테스트가 사실상 불가능해지고, 테스트 환경에서 강제로 싱글턴 객체를 초기화하거나 Reflection으로 내부 상태를 조작하는 불안정한 방법에 의존하게 된다. 이는 테스트의 독립성을 심각하게 훼손한다.

 

 해결하려면 코드 구조를 “완전히 주입 중심으로 바꾸는 것”이 핵심이다. 모든 의존 객체는 반드시 생성자나 메서드 파라미터로 명시적으로 전달받아야 하며, 내부에서 직접 싱글턴 객체를 참조하는 코드는 완전히 제거되어야 한다. 이 방식은 테스트 가능성과 유지보수성을 함께 확보하는 유일한 길이다.

5-4. 오용을 막는 설계자의 관점 변화

 싱글턴과 DI 모두 목적은 “효율성과 안정성 확보”인데, 오용되는 경우는 대개 “편의성을 위주로 사용했기 때문”이다. 개발자는 객체 하나를 여러 군데서 쓰고 싶거나, 객체 생성이 귀찮아서 DI 컨테이너를 사용한다. 그러나 편리함은 의존성 명시, 설계 분리, 책임 구분이라는 중요한 원칙을 희생시켜서는 안 된다. 설계자는 클래스 하나를 만들 때마다 스스로에게 다음과 같은 질문을 던져야 한다.

  • 이 객체는 상태를 가질 필요가 있는가?
  • 이 객체는 매번 새로 생성될 이유가 있는가?
  • 이 객체가 사용하는 협력자는 왜 이 구조로 주입되었는가?
  • 이 협력자가 변경되면 시스템의 어디까지 영향을 미칠 것인가?

 이러한 질문은 단순한 DI 사용 기술을 넘어, 객체지향적 사고를 기반으로 한 설계적 성찰을 유도한다. 결국 좋은 시스템은 DI 컨테이너를 잘 썼느냐가 아니라, 객체 간 책임이 명확히 나뉘고, 협력 구조가 단순한 시스템이다.