"헤더퍼스트 디자인패턴" + "면접을 위한 CS 전공 지식 노트"에 개인적인 의견과 생각들을 추가하여 작성한 포스팅이므로 틀린 내용이 있을 수 있습니다. (있다면 지적 부탁 드립니다.)
📕 목차
1. 리틀 싱글턴
2. 싱글턴 패턴
3. 싱글턴 패턴 구현 전략
1. 리틀 싱글턴
📌 The Little Lisper
클래스의 인스턴스를 생성하기 위해서는 일반적으로 다음과 같이 new 연산자를 사용한다.
new Foo();
다른 객체에서 또 Foo라는 클래스의 인스턴스가 필요하면, 또 new 연산자를 사용해 인스턴스를 생성하면 된다.
그런데 만약, Foo 클래스의 생성자를 private로 바꾼다면 어떻게 될까?
class Foo() {
private Foo() {}
}
이렇게 되면 Foo 클래스 내부에서만 생성자를 호출할 수 있으며, 외부에서는 Foo 클래스의 인스턴스를 함부로 생성할 수 없게 된다.
그렇다면 생성자의 범위 지정자를 고치지 않고, 외부에서 Foo 클래스 인스턴스를 사용하려면 어떻게 해야 할까?
class Foo() {
private Foo() {}
public static Foo getInstance() {
return new Foo();
}
}
단순하다. Foo 클래스에서 정적 팩토리 메서드를 제공해주면 된다.
// new Foo();
Foo.getInstance();
이제 외부에서는 메서드를 사용하여 Foo의 instance를 사용할 수 있게 된다.
여기서 중요한 것은 new 연산자로 인스턴스를 생성했던 것과는 달리, 정적 팩토리 메서드를 사용하는 호출자는 Foo 클래스가 제공해주는 인스턴스를 사용한다는 점이다.
2. 싱글턴 패턴
- 클래스 인스턴스를 오직 하나만 만들고, 그 인스턴스로의 전역 접근을 제공하는 패턴
- 싱글턴 패턴을 적용한 해당 클래스에서 인스턴스를 관리하고, 호출자는 인스턴스를 사용하기 위해 요청해야만 한다.
- 데이터베이스 연결 모듈, 레지스트리 설정이 담긴 객체, 연결 풀이나 스레드 풀과 같은 자원 관리 목적으로 많이 사용한다.
- 매번 새로운 인스턴스를 만들기 비싼 객체일 경우, 한 번만 만들어서 사용할 수 있게 된다.
- 한 애플리케이션의 모든 객체는 동일한 자원을 활용할 수 있다.
- 객체의 정적 레퍼런스인 전역 변수보다 싱글턴이 낫다
- 지연 초기화가 가능하다.
- 클래스가 하나의 인스턴스만 가지며, 전역적인 접근을 제공한다. (전자는 전역 변수로 해결할 수 없다.)
- 전역 변수는 사용하다보면 간단한 객체의 전역 레퍼런스를 계속 생성하여, 네임 스페이스가 지저분해질 우려가 있다.
📌 단점
- private 생성자를 가지므로 상속이 불가능하다. (단, 캡슐화는 확실하다.)
- 테스트가 어렵다.
- TDD 방식을 적용하기 위해 단위 테스트를 해야 하나, 미리 생성된 하나의 인스턴스를 기반으로 구현하므로 각 테스트마다 독립적인 인스턴스 생성이 어렵다.
- 서버 환경에서는 Singleton이 하나만 만들어짐을 보장할 수 없다.
- 서버 클래스 로더 구성 방식, 혹은 여러 개의 JVM 분산되어 설치되는 경우엔 하나의 인스턴스가 생성될 것이라 보장할 수 없다.
- Singleton 사용은 전역 상태를 만들 수 있어 바람직하지 못하다.
3. 싱글턴 패턴 구현 전략
📌 고전적인 싱글턴 패턴 구현 방법
대부분의 상황에서 일반적인 초기화가 지연 초기화보다 낫다
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singletone() {}
public static Singleton getInstance() {
return INSTANCE;
}
}
- 정적 팩토리 메서드를 제공하지 않고 INSTANCE를 public으로 열어도 되지만, 그보다 유연성이 높은 방법이다.
- API를 바꾸지 않으면서, Singleton Pattern에서 다른 패턴으로 변경 가능한 확장성을 제공해주기 때문이다.
- 예를 들어, getInstance()로 유일한 인스턴스를 반환하다가 호출하는 thread 별로 다른 인스턴스를 넘겨주도록 리턴하는 등 기능 변환이 가능하다.
- 불변 객체를 여러 타입으로 활용 가능하게 하기 위해, 제네릭 싱글턴 팩토리 메서드로 사용할 수도 있다.
- 해당 인스턴스를 사용하지 않더라도 항상 인스턴스가 생성되므로, 프로그램 시작부터 종료까지 항상 메모리를 차지한다.
🛡️ Reflection 방어
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singletone() {
if (INSTANCE != null) { throw new RuntimeException("Can't create instance"); }
}
...
}
- Reflection으로 접근해오면 private 생성자를 호출 가능하다.
- 이 경우엔 비정상적인 접근이므로, 반드시 악의적인 공격일 것이라 판단하여 예외 처리해주어야 한다.
🛡️ 역직렬화 생성자
// 싱글턴임을 보장해주는 readResolve 메서드
private Object readResolve() {
return INSTANCE;
}
- 클래스에는 명시한 생성자 외에도, 역직렬화에 사용하는 숨겨진 생성자 또한 존재한다.
- 역직렬화는 기본 생성자를 호출하지 않고, readResolve() 메서드로 값을 복사해 새로운 인스턴스를 생선한다.
- 인스턴스가 하나임을 보장하기 위해선 readResolve() 메서드도 재정의하고, 모든 필드에 transient 키워드를 추가해야 한다.
📌 지연 초기화 전략
public class Singleton {
private static Singleton INSTANCE;
private Singletone() {}
public static Singleton getInstance() {
if (INSTANCE == null)
INSTANCE = new Singleton();
return INSTANCE;
}
}
- 인스턴스를 실제 필요한 시점에 생성하므로, 비용이 비싼 객체일 수록 부하를 줄일 수 있다.
- 멀티스레드 환경에서 thread-safe하지 못하다.
- 여러 thread에서 getInstance()를 호출하면 Singleton이 깨질 수 있다.
🤔 어째서 Singleton이 깨지는 거지?
- 멀티 스레드 환경에서 동시에 getInstance()를 호출했다고 가정하자.
- 각각의 Thread에서 INSTANCE는 둘 다 null이므로 검사를 통과하게 된다.
- 따라서 각각 생성자를 호출하여, 서로 다른 INSTANCE를 받게 된다.
- 물론 멀티 스레드 환경에서도 운 좋게 Singleton이 유지될 수도 있겠지만, 예측 불가능한 상황에서 조금이라도 Singleton이 깨질 수 있다면 thread-safe하다고 할 수 없다.
📌 지연 초기화 동기화 전략
public class Singleton {
...
public static synchronized Singleton getInstance() {
...
}
}
- 지연 초기화의 멀티쓰레딩 문제를 해소할 수 있다.
- 동기화로 인한 지연 시간(Latency), 즉 자원을 획득하기 위해 경쟁하느라 낭비하는 시간이 증가한다.
- 병렬로 실행할 기회를 잃는다. (성능이 100정도 저하된다.)
- 모든 core가 memory 일관성을 보장받기 위한 지연시간이 발생한다.
- JVM가 코드를 최적화하는데 제한한다.
- 차라리 아예 동기화하지 않고, Singleton 클래스를 호출하는 클래스에서 알아서 동기화하도록 하는 것이 낫다.
- 성능이 딱히 중요하지 않다면 이대로 사용해도 된다.
📌 지연 초기화 홀더 클래스 관용구
class Singleton {
private static class SingleInstanceHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingleInstanceHolder.INSTANCE;
}
}
- 굳이 지연 초기화를 하겠다면, Initialization on demand holder idiom을 사용하라
- 내부 정적 클래스는 클래스 로딩 시점에 초기화되며, 클래스 로더에 의해 thread-safe하게 초기화된다.
- 클래스 로딩은 JVM에 의해 단일 스레드에서만 이루어진다. (따라서 thread-safe가 보장된다.)
- 로딩(Loading): 클래스 파일의 내용을 읽어 메모리에 적재
- 링크(Linking): 메모리에 적재된 클래스 파일의 레퍼런스들이 연결되고, 필요한 리소스를 준비
- 초기화(Initalization): 클래스 변수들을 초기화하고, 클래스가 필요한 경우 해당 클래스의 초기화 블럭 실행
- 일반적인 VM은 오직 클래스 초기화 단계에서만 필드 접근 동기화를 건다.
- JVM이 내부에서 적절한 Lock을 사용하여, 로딩 단계에서 클래스는 단 한 번만 로딩되며 여러 스레드 간에 동시에 이루어질 수 없다.
- 클래스 로딩은 JVM에 의해 단일 스레드에서만 이루어진다. (따라서 thread-safe가 보장된다.)
- 명시적인 동기화, volatile 키워드가 필요없으므로 성능 저하 없이 thread-safe한 Singleton을 구현할 수 있다.
📌 이중검사 관용구
class Singleton {
private volatile static Singleton INSTANCE;
private Singleton() {}
public static Singleton getInstance() {
if (INSTANCE != null) return INSTANCE; // 첫 번째 검사 (LOCK 사용 안 함)
synchronized(Singleton.class) {
if (INSTANCE == null) // 두 번째 검사 (생성이 필요한 경우 LOCK 사용)
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
- 필드 값을 두 번 검사하는 지연 초기화 전략
- 동기화 없이 검사 (이미 INSTANCE가 존재하는 경우)
- 동기화하여 검사 (INSTANCE가 아직 초기화되지 않은 경우)
- 지연 초기화하려는 INSTANCE 필드는 반드시 volatile로 선언하라
- volatie로 선언한 인스턴스 필드는 모든 Thread에서 가시성을 보장한다.
- 즉, 한 Thread에서 INSTANCE 값을 수정하면, 이 변경 사항을 모든 thread에 반영하여 cache 문제를 방지하고 최신 데이터를 보장한다.
📌 원소가 하나인 Enum
enum Singleton {
INSTANCE;
public void leaveTheBuilding() {} // Singleton.INSTANCE.leaveTheBuilding();
}
// 혹은
enum Singleton {
INSTANCE; // Singleton.INSTANCE;
}
- 열거 타입의 특징을 이용하면, 앞의 방식보다 훨씬 간단하고 리플렉션 공격, 아주 복잡한 직렬화 상황까지 알아서 처리하는 Singleton 인스턴스를 만들 수 있다.
- 클래스 로딩 문제, 동기화 문제도 해결된다.
- 다만, 만들려는 Singleton 타입이 특정 클래스를 상속받아야 한다면 사용할 수 없다.