목차
앱 전체에 하나만 있어야 하는 것
어떤 것은 앱 안에 딱 하나만 있어야 한다. 예를 들어 앱 설정을 담은 객체다.
설정 객체가 여기저기서 new로 만들어지면 어떻게 될까. 인스턴스가 여럿 생기고, 하나에서 값을 바꿔도 다른 것들은 모른다. 같은 설정을 봐야 할 코드들이 서로 다른 객체를 보게 된다. 로거, 캐시, 커넥션 풀 매니저처럼 “앱에 하나뿐이어야 자연스러운 것”들이 다 이렇다.
그래서 “이건 하나만 만들어지게 하자”는 요구가 생긴다.
싱글턴 = 인스턴스를 하나로 보장한다
싱글턴 패턴은 이걸 코드로 보장한다.
클래스의 인스턴스가 하나만 존재하도록 보장하고, 그 하나에 접근하는 전역 통로를 제공한다.
두 가지가 핵심이다. 인스턴스는 하나뿐이고, 어디서든 같은 하나에 닿을 수 있다.
만드는 법: 생성자를 막고 자기가 관리한다
방법은 단순하다. 밖에서 new를 못 하게 막고, 클래스가 자기 인스턴스를 하나 들고 나눠준다.
public class Config {
private static final Config INSTANCE = new Config(); // 자기 인스턴스 하나
private Config() { } // private - 밖에서 new 못 한다
public static Config getInstance() {
return INSTANCE; // 늘 같은 하나를 준다
}
}생성자가 private이라 아무도 new Config()를 못 한다. 대신 Config.getInstance()로만 얻고, 그건 늘 같은 INSTANCE다. 이게 가장 기본형이다.
늦게 만들려다 인스턴스가 둘이 된다
위는 클래스가 로드될 때 바로 만든다(이른 생성). 그런데 “실제로 쓸 때 만들자”며 늦게 생성하도록 바꾸면, 함정이 하나 열린다.
private static Config instance;
public static Config getInstance() {
if (instance == null) { // (1) 아직 없네
instance = new Config(); // (2) 만들자
}
return instance;
}한 스레드만 있으면 문제없다. 그런데 두 스레드가 거의 동시에 getInstance()를 부르면, 둘 다 (1)에서 “없다”를 보고 통과해 각자 (2)에서 새로 만든다. 싱글턴이라며 인스턴스가 둘 생긴다.
안전하게 만드는 법들
그래서 늦은 생성을 멀티스레드에서 안전하게 만드는 방법이 여럿 있다.
| 방법 | 늦은 생성 | 스레드 안전 | 비고 |
|---|---|---|---|
이른 생성 (static final) | X | O | 가장 간단. 항상 미리 만든다 |
synchronized getInstance | O | O | 매 호출마다 락 - 느리다 |
| 이중 검사 잠금 | O | O | 복잡하고 volatile이 필요 |
| 홀더 관용구 | O | O | 깔끔 (아래) |
| enum | X | O | 가장 안전 (직렬화·리플렉션도 방어) |
홀더 관용구가 깔끔하다
가장 자주 쓰는 깔끔한 방법이 홀더 관용구다. 정적 중첩 클래스를 하나 둔다.
public class Config {
private Config() { }
private static class Holder {
private static final Config INSTANCE = new Config();
}
public static Config getInstance() {
return Holder.INSTANCE;
}
}핵심은 Holder가 처음 참조되는 순간에야 로드된다는 점이다. 그래서 getInstance()를 부르기 전까지는 안 만들어지고(늦은 생성), 클래스 로딩은 JVM이 스레드 안전하게 보장하니 락도 필요 없다. 늦은 생성과 스레드 안전을 락 없이 동시에 얻는다.
더 단단하게는 enum 싱글턴이 있다. public enum Config { INSTANCE; ... } 꼴인데, 자바가 enum 상수를 하나만 만들어주는 데다 직렬화·리플렉션으로 사본을 만드는 공격까지 막아준다. 다만 상속이 안 되고 지연 생성이 아니라, 상황에 따라 홀더 관용구와 갈라 쓴다.
싱글턴의 그늘 - 전역 상태
싱글턴은 편하지만 값비싼 그늘이 있다. 전역에서 접근되는 하나의 상태라는 점이다.
- 테스트가 어렵다. 어디서든
Config.getInstance()로 꺼내 쓰면, 그 코드는 싱글턴에 숨어서 의존한다. 테스트에서 가짜로 바꿔 끼우기가 까다롭다. - 결합이 숨는다. 생성자나 파라미터에 안 드러나고 코드 속에서 조용히 불려서, 무엇이 무엇에 의존하는지 파악이 어려워진다.
- 가변 상태면 위험하다. 하나뿐인 객체를 여러 곳이 동시에 바꾸면, 그 자체가 공유 상태 문제가 된다.
그래서 싱글턴은 “남발하면 안티패턴”이라는 말을 듣는다. 정말 하나여야 하는가, 아니면 그냥 하나를 돌려쓰고 싶은 것인가를 구분해야 한다.
실무: Spring 빈이 이미 싱글턴이다
실무에서 위 싱글턴 코드를 직접 짤 일은 생각보다 드물다. Spring의 빈이 기본적으로 싱글턴이기 때문이다. @Service·@Component를 붙이면 컨테이너가 인스턴스를 하나 만들어 관리하고, 필요한 곳에 넣어준다.
그리고 이 방식은 앞의 그늘도 피한다. 전역에서 getInstance()로 꺼내는 게 아니라 밖에서 주입받으니, 테스트에선 가짜를 넣으면 되고 의존도 겉으로 드러난다.
다만 “Spring 싱글턴”은 GoF 싱글턴과 범위가 다르다. GoF는 JVM에 하나를 노리지만, Spring 빈은 컨테이너 하나당 하나다. 컨테이너가 둘이면 빈도 둘이다. 이름은 같아도 보장 범위가 다르다는 걸 알아둘 값어치가 있다.
정리
| 싱글턴이란 | 인스턴스를 하나로 보장하고, 그 하나에 접근하는 통로를 준다 |
| 만드는 법 | 생성자를 private으로 막고, 클래스가 자기 인스턴스를 나눠준다 |
| 함정 | 늦은 생성을 멀티스레드에서 하면 인스턴스가 둘 생길 수 있다 |
| 안전한 법 | 이른 생성 · 홀더 관용구(추천) · enum |
| 그늘 | 전역 상태라 테스트가 어렵고 결합이 숨는다 - 남발하면 안티패턴 |
| 실무 | Spring 빈이 이미 싱글턴. 직접 짜기보다 주입받아 쓴다 (컨테이너당 하나) |
하나만 있어야 하는 걸 하나로 보장하는 것. 개념은 단순하지만, 어떻게 안전하게 하나로 만드느냐와 하나여서 생기는 대가까지가 싱글턴의 진짜 내용이다.