싱글턴 패턴은 객체 지향 설계에서 특정 클래스의 인스턴스가 프로그램 전체에서 단 하나만 존재하도록 보장하는 생성 패턴이다. 이 패턴은 전역 접근점을 제공하여 어디서든 동일한 객체를 재사용할 수 있게 한다.
기본 설계 원칙
싱글턴을 구현하려면 세 가지 핵심 원칙을 지켜야 한다.
- 생성자를
private으로 선언해 외부에서 직접 인스턴스를 생성하지 못하게 한다 - 클래스 내부에 정적 참조 변수를 두어 유일한 인스턴스를 보관한다
- 외부에서 인스턴스를 획득할 수 있는 공개 메서드를 제공한다
즉시 초기화 방식
클래스 로딩 시점에 바로 인스턴스를 만드는 방식이다.
public class AppConfig {
private AppConfig() {}
private static final AppConfig uniqueRef = new AppConfig();
public static AppConfig fetchInstance() {
return uniqueRef;
}
}
이 방식은 구현이 단순하고 스레드 안전하지만, 실제 사용 여부와 상관없이 메모리를 점유하는 단점이 있다.
지연 초기화와 동기화
실제 필요할 때 인스턴스를 생성하는 방식을 살펴본다.
public class AppConfig {
private AppConfig() {}
private static AppConfig uniqueRef;
public static AppConfig fetchInstance() {
if (uniqueRef == null) {
uniqueRef = new AppConfig();
}
return uniqueRef;
}
}
위 코드는 멀티스레드 환경에서 여러 개의 인스턴스가 생성될 수 있는 심각한 문제를 내포하고 있다. 이를 해결하기 위해 메서드 수준의 동기화를 적용할 수 있다.
public class AppConfig {
private AppConfig() {}
private static AppConfig uniqueRef;
public static synchronized AppConfig fetchInstance() {
if (uniqueRef == null) {
uniqueRef = new AppConfig();
}
return uniqueRef;
}
}
동기화된 메서드는 안전성은 보장하지만, 인스턴스가 이미 생성된 이후에도 불필요한 락 경합이 발생해 성능이 저하된다.
이중 검증 잠금
동기화 블록의 범위를 최소화하면서도 안전성과 성능을 모두 챙기는 기법이다.
public class AppConfig {
private AppConfig() {}
private static volatile AppConfig uniqueRef;
public static AppConfig fetchInstance() {
if (uniqueRef == null) {
synchronized (AppConfig.class) {
if (uniqueRef == null) {
uniqueRef = new AppConfig();
}
}
}
return uniqueRef;
}
}
volatile 키워드는 JDK 1.5부터 도입되었으며, 인스턴스 생성 과정의 명령어 재배치로 인한 문제를 방지한다. 이중 검증 구조는 첫 번째 조건에서 필터링하여 불필요한 동기화 진입을 줄인다.
정적 내부 클래스 활용
JVM의 클래스 로딩 메커니즘을 활용한 우아한 해결책이다.
public class AppConfig {
private AppConfig() {}
private static class InstanceHolder {
private static final AppConfig INSTANCE = new AppConfig();
}
public static AppConfig fetchInstance() {
return InstanceHolder.INSTANCE;
}
}
정적 내부 클래스 InstanceHolder는 실제 참조될 때까지 로딩되지 않는다. 이 특성 덕분에 지연 초기화가 자연스럽게 이루어지며, 클래스 로딩 과정이 스레드 안전을 보장하므로 별도의 동기화 코드가 필요 없다.
검증 예시
내부 클래스의 지연 로딩 여부를 확인하기 위한 테스트 코드다.
public class AppConfig {
public AppConfig() {}
private static class InstanceHolder {
static {
System.out.println("내부 클래스 로딩 완료");
}
private static final AppConfig INSTANCE = new AppConfig();
}
public static AppConfig fetchInstance() {
return InstanceHolder.INSTANCE;
}
}
단순히 new AppConfig()를 호출하거나 AppConfig.class에 접근할 경우 내부 클래스는 초기화되지 않는다. 반면 fetchInstance()가 호출되는 순간 비로소 내부 클래스가 로딩되며 단일 인스턴스가 생성된다.
public class Verification {
public static void main(String[] args) {
System.out.println(AppConfig.fetchInstance());
System.out.println(AppConfig.fetchInstance());
new Thread(() -> {
System.out.println(AppConfig.fetchInstance());
}).start();
}
}
실행 결과는 동일한 해시코드를 출력하며, 내부 클래스 로딩 메시지는 단 한 번만 찍힌다.
구현 방식 선택 가이드
| 방식 | 장점 | 단점 | 장 상황 |
|---|---|---|---|
| 즉시 초기화 | 단순, 스레드 안전 | 불필요한 자원 점유 | 항상 사용되는 리소스 |
| 메서드 동기화 | 구현 간단 | 성능 저하 | 저빈도 접근 |
| 이중 검증 | 지연 초기화, 고성능 | 복잡함, volatile 필수 | 고빈도 멀티스레드 |
| 정적 내부 클래스 | 우아함, 자동 동기화 | 클래스가 많아짐 | 대부분의 상황 |
정적 내 클래스 방식은 지연 초기화와 스레드 안전을 모두 만족하면서도 코드가 깔끔하므로 실무에서 가장 선호되는 방법이다. 다만 모든 싱글턴 구현은 리플렉션을 통한 생성자 호출이나 직렬화/역직렬화 과정에서의 규칙 위반에 취약하므로, 이러한 상황을 방어해야 할 때는 enum 기반 구현을 고려해야 한다.