5 분 소요

Item 3. private 생성자나 열거 타입으로 싱글톤임을 보증하라

싱글톤이란?

   싱글톤이란, 인스턴스를 단 하나만 만들 수 있는 클래스를 의미한다. 즉, 애플리케이션 내에 해당 클래스의 인스턴스가 유일하는 뜻이다.

싱글톤이 필요한 이유

   간혹 인스턴스 하나를 만들어놓고 공용으로 사용해야 하는 객체가 있다. 가장 많은 예시로 나오는 것이 ‘설정(setting)’ 객체인데, 설정 객체의 인스턴스를 여러 개 만들 수 있으면 오히려 복잡해질 수 있다. 기껏 설정 다 해놨는데 다른 인스턴스를 또 생성하게 되면 설정 불일치 버그가 발생할 수 있고, 다시 설정을 맞추는 데에 비용이 발생하기 때문이다. 이런 상황에서는 싱글톤 패턴을 적용하는 것이 적절할 수 있다.

객체를 싱글톤으로 만드는 방법

private 생성자 + public static final 필드

   싱글톤으로 만드는 첫 번째 방법은 생성자를 private으로 막아놓고, 유일 객체를 public static final 필드로 사용하도록 하는 것이다.

   예제 코드를 보자.

public class Elvis {

    /**
     * 싱글톤 오브젝트
     */
    public static final Elvis INSTANCE = new Elvis();

    private Elvis() {
    }

    public void leaveTheBuilding() {
        // ...
    }

    public void sing() {
        // ...
    }
}

   클라이언트는 간편하게 Elvis.INSTANCE 코드로 해당 인스턴스를 사용할 수 있다.

장점

간단하고 간결하다

   일단 만들기 편하다. 상수 필드 하나와 private 생성자 단 두 줄만으로 싱글톤을 구현했다! 이 코드는 읽기도 쉽고 누구나 싱글톤 객체임을 알 수 있다.

싱글톤임을 API 문서에 드러낼 수 있다

   상수 필드가 맨 위에 있으므로 INSTANCE 필드를 보자마자 싱글톤 패턴임을 직감할 수 있다.

private 생성자 + 정적 팩토리 메서드

   또 다른 방법은 상수 필드 대신에 정적 팩토리 메서드를 제공하는 것이다. 역시 예제 코드를 살펴보자.

public class Elvis {

    /**
     * 싱글톤 오브젝트
     */
    private static final Elvis INSTANCE = new Elvis();
    
    private Elvis() {
    }
    
    public static Elvis getInstance() { return INSTANCE; }

    public void leaveTheBuilding() {
        // ...
    }

    public void sing() {
        // ...
    }
}

   다른 점이 있다면 INSTANCE 필드는 private 지시자로 막아놓고 대신 getInstance() 정적 팩토리 메서드로 인스턴스에 접글할 수 있게 한다는 점이다.

장점

클라이언트 코드를 변경하지 않고(API를 변경하지 않고)도 싱글톤이 아니게 변경할 수 있다

   클라이언트는 유일하게 Elvis.getInstance() 정적 팩토리만으로 인스턴스를 가져올 수 있다. getInstance() 메서드의 내부가 변경되어도 클라이언트는 이를 감지하지 못한다. 즉, Elvis 클래스에 권한이 있는 사용자가 Elvis 클래스를 싱글톤이 아니게 바꿔도 클라이언트는 모른다. 클라이언트가 모른다는 뜻은 클라이언트의 코드를 변경하지 않고도 사용자는 getInstance() 메서드 내부를 변경할 수 있다는 뜻이다.

정적 팩토리를 지네릭 싱글톤 팩토리로 만들 수 있다

   먼저 코드를 살펴보자.

public class MetaElvis<T> {

    private static final MetaElvis<Object> INSTANCE = new MetaElvis<>();

    private MetaElvis() { }

    @SuppressWarnings("unchecked")
    public static <E> MetaElvis<E> getInstance() {
        return (MetaElvis<E>) INSTANCE;
    }

    public void say(T t) {
        System.out.println(t);
    }

    public static void main(String[] args) {
        MetaElvis<String> elvis1 = MetaElvis.getInstance();
        MetaElvis<Integer> elvis2 = MetaElvis.getInstance();
        System.out.println(elvis1); // 인스턴스 해시코드 값이 같다.
        System.out.println(elvis2); // 인스턴스 해시코드 값이 같다.
        elvis1.say("hello");
        elvis2.say(100);
    }
}

   위 코드에서 MetaElvis 클래스는 정적 팩토리 메서드로 싱글톤 인스턴스를 제공한다. MetaElvis 클래스는 지네릭 타입을 사용하고 있는데, 정적 팩토리 메서드를 사용하게 되면 클라이언트가 타입 변수를 자유롭게 사용할 수 있게 된다.

   위의 main 메서드를 살펴보자. elvis1 인스턴스와 elvis2 인스턴스는 서로 같은 인스턴스이다. 두 변수 모두 MetaElvis 클래스의 INSTANCE 필드를 가리키고 있다.

   같은 인스턴스를 가리키고 있지만 지네릭 타입은 다른 것을 확인할 수 있다(그렇기 때문에 == 비교는 할 수 없다). 이처럼 정적 팩토리 메서드로 싱글톤을 구현하면 지네릭 타입 또한 제공할 수 있게 되고 클라이언트는 이를 유용하게 사용할 수 있다.

정적 팩토리의 메서드 참조를 공급자(Supplier)로 사용할 수 있다

   정적 팩토리 메서드는 람다 즉, 메서드 참조로서 사용될 수 있다. 굳이 Supplier뿐 아니더라도 혹여 getInstance() 메서드에 매개변수가 있다면 Function으로도 사용될 수 있게 된다.

단점

싱글톤 객체를 사용하는 클라이언트를 테스트하기 어려워질 수 있다

   싱글톤 패턴이 적용된 클래스는 가짜(mock) 객체를 만들 수 없으므로 테스트하기 어려울 수 있다. 테스트 하고자 하는 내용이 많은 비용을 발생시키거나 반복하기 어려운 경우에는 가짜(mock) 객체를 만들어 테스트 해야 하는데 싱글톤이기 때문에 이것이 힘들다는 뜻이다.

   대안으로는 해당 클래스를 인터페이스를 구현하도록 한 다음 그 인터페이스를 구현하는 가벼운 클래스를 만들어 테스트에 사용하는 것이지만, 해당 객체의 코드를 내가 변경할 수 없는 상황이라면 이마저도 허용되지 않는다.

리플렉션으로 private 생성자를 호출할 수 있다

   Reflection API를 통해 private 생성자를 호출해서 새로운 인스턴스를 만들어낼 수 있다. 어딘가에라도 리플렉션을 통해 새로운 인스턴스를 만들게 되는 순간 싱글톤이 깨지게 되는 것이므로 아주 주의해야 한다.

   대안으로는 생성자 내에 인스턴스 생성을 막는 로직을 추가하면 된다. 예시를 보자.

public class Elvis {

    /**
     * 싱글톤 오브젝트
     */
    public static final Elvis INSTANCE = new Elvis();
    
    private static boolean created;

    private Elvis() {
        if (created) {
            throw new UnsupportedOperationException("can't be created by constructor.");
        }

        created = true;
    }

    public void leaveTheBuilding() {
        // ...
    }

    public void sing() {
        // ...
    }
}

   기본 생성자에서 created 필드를 통해 인스턴스 생성 여부를 체크하고 있다. 리플렉션을 통해 생성자를 호출하게 되면 예외가 발생하므로 새로운 인스턴스 생성을 막을 수 있다.

역직렬화 할 때 새로운 인스턴스가 생길 수 있다

   객체를 직렬화 한 후 역직렬화를 할 때 새로운 인스턴스가 생길 수 있다. 실제로 위의 Elvis 클래스를 역직렬화 하면 INSTANCE 필드와는 다른 인스턴스가 생성되는 것을 확인할 수 있다.

   대안으로는 Elvis 클래스에 readResolve() 메서드를 작성해 주면 된다. readResolve() 메서드에서 INSTANCE 필드를 반환하도록 하면 해당 문제를 해결할 수 있다.

Serializable 인터페이스 & readResolve() 메서드

   자바에서는 직렬화를 할 때 Serializable 인터페이스를 구현해야 한다. 어차피 Serializable 인터페이스를 마커 인터페이스이기 때문에 클래스 선언부에 implements Serializable를 적어주기만 하면 된다.

   역직렬화를 할 때 해당 클래스에 Object readResolve() 메서드가 있으면 해당 메서드를 통해 역직렬화를 수행한다. 이 메서드는 private으로 선언해도 되며 상위 메서드를 오버라이드 하는 것은 아니기 때문에 @Override 어노테이션은 사용할 수 없다. 다만, Java 14부터는 @Serial 어노테이션이 등장하였으니 해당 메서드에 이 어노테이션을 사용하여 컴파일러에게 역직렬화 메서드를 알려줄 수 있게 되었다. 이 외에도 직렬화, 역직렬화를 돕는 메서드가 더 있는데 자세한 사항은 Serializable 인터페이스 혹은 @Serial 어노테이션(Java 14 이상)을 참고하기 바란다.

열거 타입

   위의 단점들을 커버하는 싱글톤으로 만드는 세 번째 방법은 클래스를 enum으로 선언하는 것이다.

public enum Elvis {

    INSTANCE;

    public void leaveTheBuilding() {
        // ...
    }
}

   위 인스턴스 또한 Elvis.INSTANCE로 손쉽게 사용 가능하며 작성하기도 아주 편하다. 단순히 열거 타입으로 선언하기만 하면 싱글톤임을 보장받을 수 있다.

   열거 타입은 생성자가 있긴 하지만 리플렉션으로 가져올 수 없다. 애초에 생성자로 객체를 만들 수 없도록 설계된 타입이기 때문에 리플렉션으로도 인스턴스를 만들 수 없는 것이다(리플렉션으로 생성자를 가져오려고 하면 에러가 발생한다). 역직렬화를 하더라도 별다른 장치 없이 동일한 인스턴스를 만들어준다.

   단, 열거 타입은 상속을 사용할 수 없기 때문에 상속을 사용해야만 하는 클래스는 이 방법으로 싱글톤을 만들 수 없다(인터페이스는 구현할 수 있다).


[참고]
백기선, inflearn 강의 이펙티브 자바 완벽 공략 1부
Joshua Bloch, 이펙티브 자바

<== Prev                                                Next ==>