private 생성자나 열거 타입으로 싱글턴임을 보증하라
싱글턴(singleton)이란 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다.
싱글톤이란 개념 자체는 그리 어렵지 않다. 간단하게만 보려면 아래 글만 참고해도 된다.
책 이펙티브 자바를 토대로 싱글톤을 만드는 세 가지 방법을 서술했다. 세 가지 방법 외에도 다양한 방법이 존재한다.
대게는 publci static final 필드 방식이나 정적 팩토리 방식을 사용하여 싱글톤을 생성한다.
public static final 필드 방식
public class Elvis {
public static final Elvis INSTANCE = new Elvis(); // 필드 방식의 싱글턴 인스턴스
private Elvis() {} // private 생성자
public void leaveTheBuilding() {...}
}
static final로 선언하여 INSTANCE 값은 변경되지 않는다. 생성자도 private으로 만들었기 때문에 INSTANCE를 생성할 때 new Elvis();를 호출한 이후로 생성자가 호출되지 못한다. 이 외의 생성자가 존재하지 않으므로 Elvis 클래스의 인스턴스는 오직 하나만 존재함을 보장한다.
정적 팩터리 방식
public class Elvis {
private static final Elvis INSTANCE = new Elvis(); // private 인스턴스
private Elvis() {} // private 생성자
public static Elvis getInstance() { // 정적 메서드
return INSTACNE;
}
public void leaveTheBuilding() {...}
}
필드 방식과 다른 점은 INSTANCE가 private 접근 제한자로 선언되어 있다는 것, Elvis 클래스에서 인스턴스를 얻기 위해서 정적 메서드인 getInstance() 메서드를 사용해야 한다는 것이다.
정적 팩토리 방식은 필드 방식과 비교해 아래 세 가지 장점을 가진다.
1. API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다.
2. 정적 팩토리를 제네릭 싱글턴 팩토리로 만들 수 있다.
3. 정적 팩토리의 메서드 참조를 공급자(supplier)로 사용할 수 있다.
필드 방식이 정적 팩토리 방식보다 간결하고, 싱글턴임을 한눈에 알아보기 쉽기 때문에 상황에 따라서는 필드 방식을 사용하는 게 더 좋을 수 있다. 다만 멀티 스레드 환경에서 싱글턴을 사용해야 하는 경우, 정적 팩토리 방식을 사용하는 게 좋다. [멀티 스레드에서 싱글턴을 사용하는 방법]에서 정적 팩토리 방식을 응용한 예제를 작성했다.
정적 팩토리 방식의 세 가지 장점
세 가지 장점에 대해 간단하게만 살펴보고 넘어간다. 이 부분은 굳이 읽지 않아도 된다.
여기서 말하는 API란 Elvis라는 싱글턴 객체를 사용하는 클라이언트 부분을 이야기한다. 정적 팩토리 방식에서는 getInstance() 메서드 내용만 수정하면-INSTANCE 대신 new Elvis();를 통해 매번 새로운 객체를 생성해 준다든가-, 클라이언트 코드를 수정하지 않고도 싱글턴이 아니게 변경할 수 있다.
정적 팩토리 방법을 제네릭 싱글톤 팩토리로 만듦으로써 보다 유연하게 싱글턴을 생성할 수 있다.
public class Elvis<T> {
private static final Elvis<Object> INSTANCE = new Elvis<>();
private Elvis() {}
public static <T> Elvis<T> getInstance() {
return (Elvis<T>) INSTACNE;
}
public void leaveTheBuilding() {...}
public static void main(String[] args) {
Elvis<String> elvis1 = Elvis.getInstance();
Elvis<Integer> elvis2 = Elvis.getInstance();
}
}
제네릭을 이용하여 Elvis를 생성한다면, String 타입인 Elvis를 생성할 수 있고 Integer 타입인 Elvis도 생성할 수 있다.
Supplier는 함수형 인터페이스 중 하나이다. 함수형 인터페이스(Functional Interface)는 1개의 추상 메서드를 가지는 인터페이스를 뜻한다. (1개의 추상 메서드 외에 정적 메서드(static method)나 기본 메서드(default method)도 가질 수 있다.)
Supplier 함수형 인터페이스에서 추상 메서드는 인자를 받지 않고, 오로지 반환만 하는 동작을 한다.
정적 팩토리의 getInstance() 메서드도 같은 동작을 한다. getInstance() 메서드는 아무런 인자를 받지 않으나, INSTANCE를 반환만 한다. 즉, Supplier와 같은 동작을 하므로 정적 팩토리 메서드를 공급자로 사용할 수 있다는 것이다.
열거 타입 (enum) 방식
필드 방식과 정적 팩토리 방식의 공통적인 단점으로, 싱글턴으로 만들었음에도 불구하고 싱글턴이 아니게 만들 수 있다. 리플렉션 API를 사용했을 때와 직렬화-역직렬화가 발생할 때 싱글턴임을 보장할 수 없다. 열거 타입 방식을 사용하면 두 가지 단점을 보완할 수 있다.
열거 타입 방식의 싱글턴은 다음과 같다.
public enum Elvis {
INSTANCE;
}
코드가 엄청나게 간결해진 걸 볼 수 있다. enum을 사용하여 싱글턴을 보장한다면 리플렉션에 의해 싱글턴이 망가지는 걸 막을 수 있고, 복잡한 직렬화 상황에서도 온전히 동일한 인스턴스를 반환하게 할 수 있다.
enum 방식도 완벽한 것은 아니다. 멀티 스레드에서 안드로이드처럼 Context 의존성이 있는 환경인 경우, 싱글턴 초기화 과정에서 Context가 끼어든다. Context는 애플리케이션의 실행 환경으로, 런타임 정보이기 때문에 thread-safe 하지 않을 확률이 있다.
멀티 스레드에서 싱글턴을 사용하는 방법
멀티 스레드 환경에서는 여러 스레드가 동시에 getInstance() 메서드에 접근할 때, 여러 개의 인스턴스가 만들어질 수 있는 상황이 발생할 수 있다.
public class Elvis {
private final static Elvis INSTANCE;
private Elvis() {}
public static Elvis getInstance() {
if (INSTANCE == null) { // <-- 각기 다른 스레드가 동시 접근
INSTANCE = new Elvis();
}
return INSTANCE;
}
public void leaveTheBuilding() {...}
}
스레드 A가 getInstance() 메서드의 if문의 조건 검사까지만 진행한 뒤 제어권이 스레드 B로 옮겨간다고 가정한다.
스레드 B도 getInstance() 메서드를 실행시키고 if문을 실행한다. 아직 생성된 Elvis 인스턴스가 없으므로 조건은 참이다. 새로운 Elvis 인스턴스를 생성한다. 이후 다시 스레드 A로 제어권이 옮겨간다.
스레드 A는 INSTANCE = new Elvis(); 를 실행하지 않았지만, if문의 조건 검사는 끝마쳤었다. 제어권을 돌려받는다면 바로 if문을 실행하여 새로운 Elvis 인스턴스를 생성할 것이다. 스레드 A가 처음 if문에 접근했을 때는 생성된 INSTANCE가 없어서 if문 조건이 참이었기 때문이다.
이렇게 되면 스레드 A와 B 각각에서 각기 다른 Elvis 인스턴스를 가지고 있게 된다. 싱글턴이 보장되지 못한 것이다.
LazyHolder 내부 클래스를 활용하여 멀티 스레드에서도 올바르게 동작하는 싱글턴 객체를 만들 수 있다. 다른 방법도 존재하지만 메모리 효율 측면에서나 성능면에서나 현재까지 이 방식이 가장 좋다고 알려져 있다.
public class Elvis {
private Elvis() {}
private static class LazyHolder {
private static final Elvis INSTANCE = new Elvis();
}
public static Elvis getInstance(){
return LazyHolder.INSTANCE;
}
public void leaveTheBuilding() {...}
}
JVM에서 한 개의 클래스 초기화는 오직 한 번만 진행된다. 여러 스레드가 동시에 클래스를 인스턴스화해도 어쨌거나 클래스 초기화는 한 번만 수행되므로 클래스 초기화 동작은 thread-safe 함을 보장한다.
Elvis 클래스가 먼저 로드되어 초기화되어도 곧바로 INSTANCE가 생성되지 않는다. LazyHolder 클래스 내부에 멤버 변수가 존재하지 않으므로 LazyHolder 클래스도 초기화되지 않는다.
LazyHolder 클래스는 Elvis 클래스의 getInstance() 메서드를 처음 호출할 때 초기화된다. 이때 처음으로 LazyHolder 클래스에 접근-static 멤버인 INSTANCE를 호출-하기 때문이다. (INSTANCE를 static 멤버로 만듦으로써 LazyHolder 클래스를 인스턴스화하지 않아도 된다.)
즉, Elvis 클래스의 싱글턴 객체인 INSTANCE에 처음 접근할 때 LazyHolder 클래스의 초기화 과정이 이루어진다. 클래스의 초기화 과정에서 INSTANCE가 생성되므로 thread-safe 함이 보장된다.
Effective Java (이펙티브 자바) - 조슈아 블로크
[인프런] 이펙티브 자바 완벽 공략 1부 - 백기선