인스턴스를 하나만 만들고 전역에서 접근하게 하는 패턴.
구현 방법들
// ① 이른 초기화 — 가장 단순하고 스레드 안전
class Eager {
private static final Eager INSTANCE = new Eager();
private Eager() { }
public static Eager getInstance() { return INSTANCE; }
}
// ② 홀더 패턴 — 지연 초기화 + 스레드 안전 (권장)
class Holder {
private Holder() { }
private static class H { static final Holder INSTANCE = new Holder(); }
public static Holder getInstance() { return H.INSTANCE; }
}
// ③ enum — 직렬화·리플렉션 공격까지 막힌다
enum EnumSingleton { INSTANCE; public void work() { } }
②는 클래스 초기화의 스레드 안전성을 JVM이 보장한다는 성질을 이용한다 — 락도 volatile도 필요 없다.
이중 검사 잠금은 위험하다
private static volatile Singleton instance; // volatile 이 빠지면 조용히 깨진다
static Singleton get() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) instance = new Singleton();
}
}
return instance;
}
객체 생성은 ⓐ할당 ⓑ생성자 실행 ⓒ참조 대입 세 단계다 ⓑ와 ⓒ가 재정렬되면 다른 스레드가 '반쯤 만들어진 객체' 를 받는다
- volatile 이 그 재정렬을 막는다
굳이 이걸 쓸 이유가 없다 — 홀더 패턴이 더 짧고 안전하다
왜 안티패턴 취급을 받나
- 전역 상태다 — 어디서 바뀌는지 추적이 어렵다
- 테스트가 어렵다 — 교체할 수 없고 테스트 간 상태가 새어 나간다
- 의존이 숨는다 — 생성자에 안 드러나므로 무엇을 쓰는지 알 수 없다
- 멀티 클래스로더·멀티 JVM 에서는 하나가 아니다
대안
"인스턴스가 하나" 와 "전역에서 접근" 은 별개의 요구다
- DI 컨테이너에 싱글톤 스코프로 등록하면
- 인스턴스는 하나이면서 주입받아 쓰므로 테스트에서 교체할 수 있다
Spring 의 기본 빈 스코프가 정확히 이 방식이다
면접 함정
- ❌ "싱글톤은 스레드 안전하다" → 인스턴스가 하나일 뿐, 그 안의 가변 상태는 여전히 위험하다.
- ❌ "Spring 싱글톤 = GoF 싱글톤" → Spring은 컨테이너당 하나이고 평범한 클래스다.
안드로이드·서버에서의 현실
싱글톤이 필요해 보이는 것들
- 설정 · 로거 · 커넥션 풀 · 캐시
이들의 공통점: '인스턴스가 하나' 가 목적이 아니라
- '비싼 자원을 공유' 하는 것이 목적이다
- 그렇다면 DI 컨테이너의 싱글톤 스코프가 정확히 맞는 답이다
- (인스턴스는 하나, 주입은 명시적, 테스트에서 교체 가능)
// 코틀린은 object 하나로 끝난다
object Config {
val apiUrl = "https://api.example.com"
}
// 클래스 초기화의 스레드 안전성을 JVM 이 보장한다 — 홀더 패턴과 같은 원리
테스트가 어려운 이유를 구체적으로
테스트 A 가 Singleton 의 상태를 바꾼다 테스트 B 가 그 상태를 그대로 물려받는다
- 실행 순서에 따라 결과가 달라진다 (flaky test)
- 격리하려면 리플렉션으로 인스턴스를 초기화하는 해킹이 필요해진다
"테스트하기 어렵다" 는 대개 설계가 나쁘다는 신호다