아이템 3. private 생성자나 열거 타입으로 싱글턴임을 보증하라. - ksw6169/effective-java GitHub Wiki

싱글톤(Singleton)

  • 싱글톤은 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다.
  • 클래스를 싱글톤으로 만들면 테스트가 어려워질 수 있다. (타입을 인터페이스로 정의하고 이를 구현한 싱글톤이 아니라면 싱글톤을 mock 객체로 대체할 수 없기 때문)
  • ex. 무상태(stateless) 객체(인스턴스 필드를 가지지 않는 클래스의 객체), 설계상 유일해야 하는 시스템 컴포넌트

싱글톤을 만드는 방법

(1) public static final 필드 방식의 싱글톤

@Getter
public class Elvis {
    public static final Elvis INSTANCE = new Elvis(30);

    private int age;

    private Elvis(int age) {
        this.age = age;
    }
}

이 방식은 리플렉션 API인 AccessableObject.setAccessible 을 사용해 private 생성자를 호출하여 두 번째 객체를 생성할 수 있으므로 싱글톤임을 보장하지 못할 수 있다.

@Test
public void test() throws Exception {
    assertEquals(Elvis.INSTANCE.getAge(), 30);

    Constructor constructor = Elvis.class.getDeclaredConstructor(int.class);
    constructor.setAccessible(true);

    Elvis newElvis = (Elvis) constructor.newInstance(20);
    assertEquals(newElvis.getAge(), 20);
}

이럴 때는 생성자를 수정하여 두 번째 객체가 생성되려 할 때 예외를 던지게 하면 된다.

@Getter
public class Elvis {
    public static final Elvis INSTANCE = new Elvis(30);

    private int age;

    private Elvis(int age) {
        if (INSTANCE != null)
            throw new UnsupportedOperationException();

        this.age = age;
    }
}

[장점]

  • 간결하다.
  • 싱글톤 클래스임이 API에 명백히 드러난다.

(2) 정적 팩토리 방식의 싱글톤

정적 팩토리 방식의 싱글톤도 리플렉션을 통한 private 생성자 호출이 가능하다. 따라서 두 번째 객체가 생성되려 할 때 예외를 던지게 하면 된다.

@Getter
public class Elvis {
    private static final Elvis INSTANCE = new Elvis(30);

    private int age;

    private Elvis(int age) {
        this.age = age;
    }

    public static Elvis getInstance() {
        return INSTANCE;
    }
}

[장점]

  • API를 바꾸지 않고도 싱글톤이 아니게 변경할 수 있다.

    • getInstance() 를 호출하는 스레드별로 다른 인스턴스를 넘겨주게 변경할 수 있다.
  • 정적 팩토리를 제네릭 싱글톤 팩토리로 만들 수 있다.

  • 정적 팩토리 메소드 참조를 공급자로 사용할 수 있다.

    • Elvis::getInstanceSupplier<Elvis>로 사용할 수 있다.

(3) 원소가 하나인 열거 타입 방식의 싱글톤

public enum Elvis {
    INSTANCE;
}

[장점]

  • 간결하다.
  • 추가 노력 없이 직렬화가 가능하다.
  • 아주 복잡한 직렬화 상황이나 리플렉션 공격에서도 제2의 인스턴스가 생기는 일을 완벽히 막아준다.
  • 대부분의 상황에서는 원소가 하나뿐인 열거 타입이 싱글톤을 만드는 가장 좋은 방법이다.

싱글톤의 직렬화

싱글톤 클래스를 직렬화하려면 Serializable 만 구현하면 안된다. 직렬화된 인스턴스를 역직렬화할 때마다 새로운 인스턴스를 생성하기 때문이다. (이로 인해 해당 객체가 싱글톤임을 보장하지 못한다.)

// 직렬화 대상 클래스
public class Elvis implements Serializable {
    private static final Elvis INSTANCE = new Elvis();

    private Elvis() {}

    public static Elvis getInstance() {
        return INSTANCE;
    }
}
// 직렬화 시 사용할 MySerializer 유틸 클래스
public class MySerializer {

    public static byte[] serialize(Object instance) {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();

        try (ObjectOutputStream oos = new ObjectOutputStream(bos)) {
            oos.writeObject(instance);
        } catch (IOException e) {
            e.printStackTrace();
        }

        return bos.toByteArray();
    }

    public static Object deserialize(byte[] serializedBytes) {
        ByteArrayInputStream bis = new ByteArrayInputStream(serializedBytes);

        try (ObjectInputStream ois = new ObjectInputStream(bis)) {
            return ois.readObject();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }

        return null;
    }
}
@Test
public void test() {
    Elvis singleton1 = Elvis.getInstance();

    byte[] serializedBytes = MySerializer.serialize(singleton1);

    // 역직렬화 할때마다 새로운 인스턴스를 생성한다.
    Elvis singleton2 = (Elvis) MySerializer.deserialize(serializedBytes);
    
    // 따라서 singleton1 != singleton2 이므로 실패한다.
    assertEquals(singleton1, singleton2);
}

이 때는 모든 인스턴스 필드를 trasient 로 선언하고 readResolve() 를 제공하면 된다. (readResolve() 에서 항상 같은 싱글톤을 반환하도록 메소드를 추가해준다.)

public class Elvis implements Serializable {

    private static final Elvis INSTANCE = new Elvis();

    private Elvis() {}

    public static Elvis getInstance() {
        return INSTANCE;
    }

    // 싱글톤임을 보장해주는 readResolve 메소드
    private Object readResolve() {
        // 진짜 Elvis를 반환하고, 가짜 Elvis는 가비지 컬렉터에 맡긴다.
        return INSTANCE;
    }
}

참고 자료

⚠️ **GitHub.com Fallback** ⚠️