서버가 뜨면 DB 커넥션 풀, 설정 관리자, 로그 핸들러 같은 객체들이 생성된다. 이 객체들은 요청마다 새로 만들 필요가 없다. 하나만 있어도 충분하고, 오히려 여러 개가 생기면 문제가 된다. Singleton은 이런 상황에서 인스턴스가 딱 하나만 존재하도록 보장하는 패턴이다.
가장 단순한 구현은 이렇다.
class MySingleton {
private static MySingleton instance;
private MySingleton() {}
public static MySingleton getInstance() {
if (instance == null) {
instance = new MySingleton();
}
return instance;
}
}
싱글스레드에서는 문제없이 동작한다. 멀티스레드에서는 다음 상황이 발생한다.
Thread 1: instance == null 확인 → true
Thread 2: instance == null 확인 → true (Thread 1이 아직 생성 중)
Thread 1: new MySingleton() 생성
Thread 2: new MySingleton() 생성 ← 두 번째 인스턴스 생성됨
두 스레드가 동시에 null을 확인하고 각자 인스턴스를 만들어버린다. 하나만 존재해야 한다는 보장이 깨진다.
첫 번째는 getInstance() 메서드 전체에 synchronized를 거는 것이다. 한 번에 하나의 스레드만 진입하게 되어 중복 생성을 막는다. 그런데 인스턴스가 이미 생성된 이후에도 매 호출마다 lock을 확인하는 비용이 남는다.
두 번째는 Double-Checked Locking이다. 인스턴스가 없을 때만 synchronized 블록에 진입해서 lock 비용을 줄인다.
private static volatile MySingleton instance;
public static MySingleton getInstance() {
if (instance == null) {
synchronized (MySingleton.class) {
if (instance == null) {
instance = new MySingleton();
}
}
}
return instance;
}
여기서 volatile이 필요하다. volatile은 해당 변수를 CPU 캐시가 아닌 메인 메모리에서 직접 읽고 쓰도록 강제한다. CPU는 성능 최적화를 위해 명령어 순서를 재배열할 수 있는데, new MySingleton()은 내부적으로 메모리 할당 → 생성자 실행 → 참조 대입 세 단계로 이루어진다. volatile 없이는 참조 대입이 먼저 일어나고 생성자가 아직 실행 중인 상태에서 다른 스레드가 캐시에서 미완성 인스턴스를 읽어갈 수 있다.
세 번째는 static inner class를 이용하는 방법으로, lock 없이도 thread-safe하게 만든다.
class MySingleton {
private MySingleton() {}
private static class Holder {
private static final MySingleton INSTANCE = new MySingleton();
}
public static MySingleton getInstance() {
return Holder.INSTANCE;
}
}
Holder 클래스는 getInstance()가 처음 호출되는 시점에 JVM이 로드한다. JVM의 클래스 로딩은 thread-safe가 보장되기 때문에 별도의 동기화 없이 인스턴스가 딱 한 번만 생성된다.
class UserService {
public String getUser(Long id) {
return UserRepository.getInstance().findById(id);
}
}
getInstance()는 항상 실제 구현체를 반환한다. DB 없이 테스트할 수 없고, Mock으로 교체하는 것도 불가능하다.
의존성이 숨겨지는 문제도 있다.
class OrderService {
public void order(Long userId) {
UserRepository.getInstance().findById(userId);
InventoryService.getInstance().check();
NotificationSender.getInstance().send();
}
}
이 클래스가 무엇에 의존하는지 선언만 봐서는 알 수 없다. 메서드 내부를 전부 읽어야 파악된다. 어디서든 접근 가능한 전역 상태가 되기 때문에 어느 코드가 이걸 건드리는지 추적하기도 어렵다.
요청마다 새 인스턴스를 만들면 객체 생성 비용과 GC 부담이 매 요청마다 반복된다. Singleton으로 관리하면 서버 시작 시 한 번만 생성하고 이후 요청은 기존 인스턴스를 재사용한다.
단점은 DI로 해결한다.
class OrderService {
private final UserRepository userRepository;
OrderService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
생성자 파라미터로 의존성을 받으면 의존 관계가 명시되고, 테스트에서 Mock으로 교체할 수 있다. Spring Container가 빈의 생성과 주입을 관리하기 때문에 개발자는 getInstance()를 직접 호출하지 않아도 된다.
Singleton 패턴은 JVM 전체에서 인스턴스 하나를 보장한다. Spring singleton bean은 ApplicationContext 하나당 인스턴스 하나다. 같은 "싱글톤"이라는 이름을 쓰지만 보장 범위가 다르다.
서버가 여러 대라면 각 서버마다 Spring Container가 따로 떠있고, 인스턴스도 서버마다 존재한다. 분산 환경에서 진짜 "하나"가 필요하다면 Redis 같은 외부 저장소로 상태를 관리해야 한다.
결국 Singleton을 쓸 때 판단해야 할 것은 두 가지다. 이 객체가 정말 하나여야 하는가, 그리고 테스트와 교체를 고려하면 DI로 관리하는 게 더 낫지 않은가.