1. 게으른 초기화 (Lazy Initialization) - 스레드 안전하지 않음
이 코드는 간단하고 게으른 로딩을 사용하지만 치명적인 문제가 있습니다. 여러 스레드가 getInstance()를 동시에 호출하면 여러 인스턴스가 생성됩니다. 즉, 멀티스레드 환경에서 제대로 작동하지 않습니다.
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
2. 게으른 초기화 - 스레드 안전 (메서드 동기화)
위 문제를 해결하는 가장 간단한 방법은 getInstance() 메서드 전체를 synchronized로 동기화하는 것입니다.
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
스레드 안전성을 확보하고 다중 인스턴스 문제를 해결했지만, 비효율적입니다. 특정 시점에 오직 하나의 스레드만 getInstance()를 호출할 수 있기 때문입니다. 동기화는 첫 번째 호출(인스턴스 생성 시)에만 필요합니다. 이는 이중 검증 잠금(Double-Checked Locking) 패턴으로 이어집니다.
3. 이중 검증 잠금 (Double-Checked Locking)
이 패턴은 동기화 블록을 사용하는 방법입니다. instance == null 검사를 두 번 수행하는 데서 이름이 유래했습니다. 한 번은 동기화 블록 밖에서, 다른 한 번은 블록 안에서 수행합니다. 블록 안에서 다시 검사하는 이유는 여러 스레드가 동시에 블록 밖의 if 문에 진입할 수 있기 때문입니다. 블록 안에서 재검사를 하지 않으면 여러 인스턴스가 생성될 수 있습니다.
public static Singleton getSingleton() {
if (instance == null) { // 첫 번째 검사
synchronized (Singleton.class) {
if (instance == null) { // 두 번째 검사
instance = new Singleton();
}
}
}
return instance;
}
이 코드는 완벽해 보이지만 문제가 있습니다. instance = new Singleton()은 원자적(atomic) 연산이 아닙니다. JVM에서 이 한 줄은 대략 다음 세 가지 작업을 수행합니다.
- 1.
instance를 위한 메모리 할당 - 2.
Singleton의 생성자를 호출하여 멤버 변수 초기화 - 3.
instance객체를 할당된 메모리 공간에 연결 (이 단계가 완료되면instance는null이 아닙니다)
그러나 JVM의 JIT 컴파일러는 명령어 재정렬(instruction reordering) 최적화를 수행합니다. 즉, 위 단계 2와 3의 순서가 보장되지 않습니다. 최종 실행 순서는 1-2-3 또는 1-3-2가 될 수 있습니다. 후자의 경우, 단계 3이 완료되고 단계 2가 실행되기 전에 스레드 2가 개입하면 instance는 이미 null이 아니지만(초기화되지 않음) 스레드 2는 instance를 직접 반환하여 사용하려고 시도하고 오류가 발생합니다.
instance 변수를 volatile로 선언하여 이 문제를 해결할 수 있습니다.
public class Singleton {
private volatile static Singleton instance; // volatile로 선언
private Singleton() {}
public static Singleton getSingleton() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
volatile을 사용하는 이유를 가시성(visibility) 때문이라고 생각하는 사람들이 있습니다. 즉, 스레드가 인스턴스의 로컬 복사본을 저장하지 않고 항상 메인 메모리에서 읽도록 보장합니다. 하지만 이것은 정확하지 않습니다. volatile을 사용하는 주된 이유는 명령어 재정렬 최적화를 금지하기 때문입니다. 즉, volatile 변수에 값을 할당하는 연산 뒤에는 메모리 장벽(memory barrier)이 생성되어, 읽기 연산이 이 장벽 앞으로 재정렬되지 않습니다. 위 예시에서는 인스턴스를 가져오는 연산이 1-2-3 또는 1-3-2 순서가 완전히 끝난 후에만 실행되며, 1-3까지 실행되고 값을 가져오는 상황은 발생하지 않습니다.
그러나 Java 5 이전 버전에서는 volatile을 사용한 이중 검증 잠금에도 문제가 있었습니다. 이전 JMM(Java Memory Model)에는 결함이 있어서 volatile 변수를 선언해도 재정렬을 완전히 막을 수 없었으며, 특히 volatile 변수 전후의 코드 간 재정렬 문제가 있었습니다. 이 문제는 Java 5에서 수정되었으므로 이후 버전에서는 안심하고 사용할 수 있습니다.
이렇게 복잡하고 잠재적 문제가 있는 방식보다는 더 나은 스레드 안전한 싱글톤 구현 방법이 있습니다.
4. 즉시 초기화 (Eager Initialization) - static final 필드
이 방법은 매우 간단합니다. 싱글톤 인스턴스가 static 및 final 변수로 선언되어, 클래스가 처음 메모리에 로드될 때 초기화됩니다. 따라서 인스턴스 생성 자체가 스레드 안전합니다.
public class Singleton {
// 클래스 로딩 시 초기화
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
이 방법이 완벽하다면 이중 검증 잠금에 대해 자세히 논할 필요가 없을 것입니다. 단점은 게으른 로딩(lazy initialization)이 아니라는 점입니다. 클라이언트가 getInstance()를 호출하지 않더라도 싱글톤은 클래스 로딩 직후 초기화됩니다. 또한, 싱글톤 인스턴스 생성이 매개변수나 설정 파일에 의존하는 경우에는 이 방식을 사용할 수 없습니다. 예를 들어, getInstance() 호출 전에 특정 메서드를 통해 매개변수를 설정해야 하는 상황에서는 사용하기 어렵습니다.
5. 정적 내부 클래스 (Static Nested Class)
저는 정적 내부 클래스를 선호하며, 이 방법은 《Effective Java》에서도 권장됩니다.
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton() {}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
이 방식은 JVM 자체 메커니즘을 사용하여 스레드 안전성을 보장합니다. SingletonHolder는 private이므로 getInstance() 외에는 접근할 방법이 없어 게으른 로딩이 가능합니다. 또한 인스턴스를 읽을 때 동기화가 필요 없어 성능 저하가 없으며, JDK 버전에 의존적이지 않습니다.
6. 열거형 (Enum)
열거형을 사용한 싱글톤은 매우 간단합니다! 이것이 가장 큰 장점입니다. 아래 코드는 열거형 인스턴스를 선언하는 일반적인 방법입니다.
public enum EasySingleton {
INSTANCE;
}
EasySingleton.INSTANCE로 인스턴스에 접근할 수 있으며, 이는 getInstance()를 호출하는 것보다 훨씬 간단합니다. 열거형은 기본적으로 스레드 안전하게 생성되므로 이중 검증 잠금에 대해 걱정할 필요가 없으며, 역직렬화로 인한 새로운 객체 생성을 방지합니다. 하지만 많이 사용되지는 않는데, 익숙하지 않기 때문일 수 있습니다.
요약
일반적으로 싱글톤 패턴은 다섯 가지 주요 방식으로 구현됩니다: 게으른 초기화, 즉시 초기화, 이중 검증 잠금, 정적 내부 클래스, 열거형. 위에서 설명한 방식들은 모두 스레드 안전한 구현이며, 첫 번째 방법은 올바른 작성법이 아닙니다.
개인적으로는 일반적인 상황에서 즉시 초기화 방식을 사용합니다. 게으른 로딩이 명시적으로 필요할 때는 정적 내부 클래스를 선호하며, 역직렬화를 통해 객체가 생성될 가능성이 있는 경우 열거형 방식을 시도합니다.