Singleton
Singleton은 클래스의 인스턴스가 프로세스 안에서 정확히 하나만 존재하도록 보장하고, 그 인스턴스에 접근할 전역적인 진입점을 만드는 생성 패턴입니다. 생성자를 숨기고 정적 메서드를 통해서만 객체를 내보내기 때문에, 누가 언제 호출하든 같은 인스턴스를 받게 됩니다.
▶아키텍처 다이어그램
🔍 구조 다이어그램점선 애니메이션은 데이터 또는 요청의 흐름 방향을 나타냅니다
앱이 커지면 여러 모듈이 같은 자원을 써야 하는 상황이 생깁니다. 데이터베이스 커넥션 풀, 로거, 설정 객체 같은 것들입니다. 이때 각 모듈이 자기 것을 따로 만들면 커넥션 수가 통제 불능으로 불어나거나, 설정 값이 모듈마다 달라지는 문제가 터집니다. 전역 변수를 쓰면 접근은 쉬워지지만 초기화 시점이 불분명하고, 어디서든 값을 덮어쓸 수 있어 추적이 어렵습니다. '하나만 있어야 하는 객체'를 코드 수준에서 강제할 방법이 없으면, 규약만으로 막아야 하고 규약은 사람이 잊으면 끝입니다.
Gang of Four(GoF)가 1994년에 정리한 23가지 디자인 패턴 중 가장 단순한 축에 속하면서도, 가장 논쟁이 많은 패턴이기도 합니다. 초기 객체지향 설계에서는 전역 자원 접근 문제를 해결하는 깔끔한 방법으로 환영받았습니다. 하지만 프로젝트 규모가 커지면서 Singleton이 사실상 전역 변수와 비슷한 결합도를 만든다는 비판이 나왔고, 테스트에서 mock으로 교체하기 어려운 점도 문제가 됐습니다. 그래서 현대 프레임워크에서는 DI(Dependency Injection) 컨테이너가 인스턴스 생명주기를 관리하는 방식으로 Singleton의 역할을 대체하는 흐름이 강합니다. 그럼에도 패턴 자체를 이해해야 하는 이유는 레거시 코드에 여전히 많고, DI 컨테이너 내부에서도 'singleton scope'라는 이름으로 같은 개념이 쓰이기 때문입니다.
Singleton의 메커니즘은 세 단계로 나뉩니다. 첫째, 생성자를 private으로 막습니다. 외부에서 new를 쓸 수 없으니, 아무도 두 번째 인스턴스를 만들 수 없습니다. 둘째, 클래스 안에 자기 자신의 인스턴스를 담을 정적 필드를 둡니다. 처음에는 비어 있습니다. 셋째, getInstance() 같은 정적 메서드를 통해 접근합니다. 이 메서드는 정적 필드가 비어 있으면 인스턴스를 하나 만들어 채우고, 이미 있으면 그대로 돌려줍니다. 결과적으로 몇 번을 호출하든 반환되는 객체는 항상 같습니다. 멀티스레드 환경에서는 두 스레드가 동시에 getInstance()를 호출할 때 인스턴스가 두 개 생길 수 있어, synchronized 블록이나 volatile 필드 같은 동기화 장치를 추가해야 합니다.
Singleton과 전역 변수는 둘 다 '어디서든 접근할 수 있는 하나의 값'을 만든다는 점에서 비슷합니다. 하지만 전역 변수는 초기화 시점과 생명주기를 언어 런타임에 맡기고 아무나 덮어쓸 수 있는 반면, Singleton은 생성 시점을 코드가 통제하고 외부에서 새 인스턴스를 만들지 못하게 강제합니다. Singleton과 DI 컨테이너의 singleton scope는 '인스턴스 하나'라는 결과가 같지만, 누가 생명주기를 관리하느냐가 다릅니다. Singleton 패턴은 클래스가 스스로 '나는 하나뿐'이라고 강제하고, DI 컨테이너는 외부 설정으로 scope를 지정해 같은 효과를 냅니다. 테스트에서 인스턴스를 교체해야 하거나 의존성을 명시적으로 드러내야 하는 상황이라면 DI 쪽이 더 유연합니다. 반대로 프레임워크 없이 가볍게 전역 자원을 하나로 묶어야 할 때는 Singleton이 가장 직관적입니다.
Singleton은 '프로세스 안에서 정확히 하나만 있어야 하는' 자원을 다룰 때 등장합니다. 로거, 설정 매니저, 커넥션 풀, 스레드 풀 같은 인프라 객체가 대표적입니다. 이런 객체를 여러 개 만들면 자원이 낭비되거나 상태가 어긋나기 때문에, 하나로 고정하고 어디서든 접근하게 해야 합니다. 다만 Singleton을 남발하면 클래스 간 숨은 의존이 늘어나고, 테스트에서 상태를 초기화하기 어려워집니다. 비즈니스 로직 객체까지 Singleton으로 만들면 코드가 전역 상태에 점점 더 묶이게 됩니다. 적용 전에 '이 객체가 진짜 하나여야 하는가, 아니면 같은 인스턴스를 공유하는 편이 편한 것뿐인가'를 따져 보는 게 좋습니다. 후자라면 DI 컨테이너의 생명주기 관리가 더 적합할 수 있습니다.