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

2023. 11. 14. 22:52Book/이펙티브 자바

 

 

객체 생성과 파괴

 

인스턴스를 오직 하나만 생성해야 할 경우 싱글톤으로 만드는 방법

 

1) private 생성자 + public static final 필드

 장점 : 간결하고 싱글턴임을 API에 들어낼 수 있다.

public class Elvis implements IElvis, Serializable {

    /**
     * 싱글톤 오브젝트
     */
    public static final Elvis INSTANCE = new Elvis();
    
    public void leaveTheBuilding() {
        System.out.println("leave the building");
    }

    public void sing() {
        System.out.println("sing~ sing! sing~");
    }
}

 

 

 

 단점1 : 싱글톤을 사용하는 클라이언트를 테스트하기 어렵다.

class ConcertTest {

    @Test
    void perform() {
        Concert concert = new Concert(Elvis.INSTANCE);
        concert.perform();

        assertTrue(concert.isLightsOn());
        assertTrue(concert.isMainStateOpen());
    }
}

 Elvis 클래스를 생성하는데 비용이 아주 크다고 가정했을 때 Mock 구현으로 대체 할 수 없기 때문에 테스트하기 어렵다.

 

 ==> 대안 타입을 인터페이스로 정의하고 싱글턴이 그 인스턴스를 구현하면 가능

public interface IElvis {
    void leaveTheBuilding();
    void sing();
}

================================================================================

public class Elvis implements IElvis {

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

    public void leaveTheBuilding() {
        System.out.println("leave the building");
    }

    public void sing() {
        System.out.println("sing~ sing! sing~");
    }
}

==================================================================================

/**
 * 테스트
 */
 
==================================================================================

public class MockElvis implements IElvis{
    @Override
    public void leaveTheBuilding() {

    }

    @Override
    public void sing() {
        System.out.println("Mock sing~");
    }
}

===================================================================================

class ConcertTest {

    @Test
    void perform() {
        Concert concert = new Concert(new MockElvis());
        concert.perform();
        assertTrue(concert.isLightsOn());
        assertTrue(concert.isMainStateOpen());
    }
}

 

 

 단점2 : 리플렉션으로 private 생성자를 호출할 수 있다. (싱글톤이 깨질 수 있다.)

public class ElvisReflection {

    public static void main(String[] args) {
        try {
            //리플렉션을 이용해 기본 생성자 접근
            Constructor<Elvis> defaultConstructor = Elvis.class.getDeclaredConstructor();
            //private 한 생성자에 접근 허용
            defaultConstructor.setAccessible(true);
            Elvis elvis1 = defaultConstructor.newInstance();
            Elvis elvis2 = defaultConstructor.newInstance();
            System.out.println(elvis1 == elvis2);
        } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
}

==> false

권한이 있는 클라이언트는 리플렉션API인 AccessibleObject.setAccessible을 사용해 private 생성자를 호출할 수 있다.

 

==> 대안

public class Elvis implements IElvis {

    /**
     * 싱글톤 오브젝트
     */
    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() {
        System.out.println("leave the building");
    }

    public void sing() {
        System.out.println("sing~ sing! sing~");
    }

    public static void main(String[] args) {
        Elvis elvis = Elvis.INSTANCE;
        elvis.leaveTheBuilding();
    }
}

boolean 타입으로  필드를 만들고 플래그를 세워 인스턴스가 하나만 생성됨을 보장하면 된다.

 

 

 단점3 : 역질렬화 할 때 새로운 인스턴스가 생길 수 있다. 

public class ElvisSerialization {

    public static void main(String[] args) throws FileNotFoundException {
        //저장
        try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("elvis.obj"))) {
            out.writeObject(Elvis.INSTANCE);
        } catch (IOException e) {
            e.printStackTrace();
        }

        //읽기
        try (ObjectInput in = new ObjectInputStream(new FileInputStream("elvis.obj"))){
            Elvis elvis3 = (Elvis) in.readObject();
            System.out.println(elvis3 == Elvis.INSTANCE);
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

==> false

직렬화를 통해 데이터를 저장하고, in.readObject() 를 통해 데이터를 읽어들일 때 새로운 인스턴스가 생성된다.

 

==> 대안

public class Elvis implements IElvis, Serializable {

    /**
     * 싱글톤 오브젝트
     */
    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() {
        System.out.println("leave the building");
    }

    public void sing() {
        System.out.println("sing~ sing! sing~");
    }

    public static void main(String[] args) {
        Elvis elvis = Elvis.INSTANCE;
        elvis.leaveTheBuilding();
    }

    private Object readResolve() {
        return INSTANCE;
    }
}

 

 Serializable을 구현한다고 선언하고 readResolve() 메서드를 제공해야 한다. 역직렬화 과정에서 호출되는 readObject 메서드가 있더라도 readResolve 메서드에서 반환한 인스턴스로 대체된다. readObject 메서드를 통해 만들어진 인스턴스는 가비지 컬렉션 대상이 된다.

 

 

 

 

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

 단점   :  1) private 생성자 + public static final 필드 와 동일

 

 장점1 : API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다.

public class Elvis {
    private static final Elvis INSTANCE = new Elvis();
    private Elvis() {}
    public static Elvis getInstance() {
        return INSTANCE;
        // Or new Elvis => 클라이언트에 영향을 주지 않고 싱글톤이 아니게 할 수 있음
    }

    public void leaveTheBuilding() {
        System.out.println("leave the building");
    }

    public static void main(String[] args) {
        Elvis elvis = Elvis.getInstance();
        elvis.leaveTheBuilding();
    }
}

정적 팩토리 메서드의 return 값을 new 연산자를 통해 생성해서 반환하면 클라이언트 코드에 영향을 주지 않고 싱글톤이 아니게 바꿀 수 있다.

 

 

 장점2 : 정적 팩토리를 제네릭 싱글턴 팩토리로 만들 수 있다.

public class MetaElvis<T> {
    private static final MetaElvis<Object> INSTANCE = new MetaElvis<>();
    private MetaElvis() {}

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

    public void say(T t) {
        System.out.println(t);
    }
    public void leaveTheBuilding() {
        System.out.println("leave the building");
    }

    public static void main(String[] args) {
        MetaElvis<String> elvis1 = MetaElvis.getInstance();
        MetaElvis<Integer> elvis2 = MetaElvis.getInstance();
        
        elvis1.say("hello");
        elvis2.say(100);
    }
}

동일한 인스턴스를 각자 다른 타입으로 사용할 수 있다.

 

 장점3 : 정적 팩토리의 메서드 참조를 공급자로 사용할 수 있다.

public class Concert {

    public void start(Supplier<Singer> singerSupplier) {
        Singer singer = singerSupplier.get();
        singer.sing();
    }

    public static void main(String[] args) {
        Concert concert = new Concert();
        concert.start(Elvis::getInstance);
    }
}

 

 

public interface Singer {
    void sing();
}

=============================================================================================

public class Elvis implements Singer{
    private static final Elvis INSTANCE = new Elvis();
    private Elvis() {}
    public static Elvis getInstance() {
        return INSTANCE;
    }

    public void leaveTheBuilding() {
        System.out.println("leave the building");
    }

    public static void main(String[] args) {
        Elvis elvis = Elvis.getInstance();
        elvis.leaveTheBuilding();
    }

    @Override
    public void sing() {
        System.out.println("sing!! sing~~");
    }
}

 

Supplier.get() 메서드에 Elvis.getInstance()로 재정의 해 참조를 공급자로 사용할 수 있다.

 

3) 열거 타입 방식 - 추천!

public enum Elvis {
    INSTANCE;

    public void leaveTheBuilding() {
        System.out.println("빌딩 나가기");
    }

    public static void main(String[] args) {
        Elvis elvis = Elvis.INSTANCE;
        elvis.leaveTheBuilding();
    }
}

 

대부분 상황에서 원소가 하나뿐인 열거 타입이 싱글턴을 만드는데 가장 좋은 방법이다.

역질렬화나 리플렉션을 걱정할 필요 없이 안전하다 테스트의 어려움 또한 인터페이스를 구현하면 된다.