럿고의 개발 노트

아이템 3. private 생성자나 열거 타입으로 싱글턴임을 보증하라. 본문

Java Note/이펙티브 자바 3판(EFFECTIVE JAVA 3E)

아이템 3. private 생성자나 열거 타입으로 싱글턴임을 보증하라.

KimSeYun 2020. 3. 16. 12:19

이펙티브 자바 3판 - 2장. 객체 생성과 파괴

아이템 3. private 생성자나 열거 타입으로 싱글턴임을 보증하라.

싱글턴(Singleton)

  • 인스턴스를 오직 하나만 생성할 수 있는 클래스다.
  • 전형적인 예로는 함수와 같은 무상태 객체나 설계상 유일해야 하는 시스템 컴포넌트를 들 수 있다.
  • 그러나 클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있다.
  • 왜냐하면 타입을 인터페이스로 정의한 다음 그 인터페이스를 구현해서 만든 싱글턴이 아니라면 싱글턴 인스턴스를 가짜(mock) 구현으로 대체할 수 없기 때문이다.
  • 싱글턴을 만드는 방법은 두가지 인데, 두가지 모두 생성자는 private으로 감춰두고, 유일한 인스턴스에 접근할 수 있는 수단으로 public static 멤버를 하나 마련해둔다.

public static final 필드 방식의 싱글턴

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

    private Elvis(){}

    public void leaveTheBuilding(){
        System.out.println("Whoa baby, I'm outta here!");
    }
}
public class ElvisTest {
    public static void main(String[] args) {
        Elvis elvis = Elvis.INSTANCE;
        elvis.leaveTheBuilding();;
    }
}
  • INSTANCE는 초기화할때 딱 한번만 호출된다. public이나 protected 생성자가 없으므로 인스턴스가 하나임을 보장할 수 있다.,
  • 그러나 예외가 하나 있는데, AccessibleObject.setAccessible을 사용하면 private 생성자를 호출할 수 있다.
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

public class ElvisDestroyer {
    public static void main(String[] args) throws IllegalAccessException, InvocationTargetException, InstantiationException {
        Elvis elvis = Elvis.getInstance();
        System.out.println(elvis.toString());

        Constructor[] constructors = Elvis.class.getDeclaredConstructors();
        for (Constructor constructor : constructors){
            constructor.setAccessible(true);
            System.out.println(constructor.newInstance().toString());
            break;
        }
    }
}
  • 위의 코드처럼 newInstance()를 통해 계속해서 다른 인스턴스를 생성할 수 있다. 이 문제에 대한 해결책은 Enum을 이용하는 방법이다.
  • 이걸 방어하기 위해서는 생성자를 수정하여 두 번째 객체가 생성되려 할 때 예외를 던지게 하면 된다.
public class Elvis {
    private static final Elvis INSTANCE = new Elvis();

    private Elvis() {
        if(INSTANCE != null){
            throw new IllegalArgumentException("생성자를 호출 할 수 없습니다.");
        }
    }

    public static Elvis getInstance() {
        return INSTANCE;
    }

    public void leaveTheBuilding() {
        System.out.println("Whoa baby, I'm outta here!");
    }
}
  • 장점
    1. 해당 클래스가 싱글턴임이 API에서 명백히 드러난다는 것이다. pubic static 필드가 final이니 절대로 다른 객체를 참조할 수 없다.
    2. 간결함

정적 팩터리 방식의 싱글턴

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

    private Elvis() {
    }

    public static Elvis getInstance() {
        return INSTANCE;
    }

    public void leaveTheBuilding() {
        System.out.println("Whoa baby, I'm outta here!");
    }
}
public class ElvisTest {
    public static void main(String[] args) {
        Elvis elvis = Elvis.getInstance();
        elvis.leaveTheBuilding();
    }
}
  • getInstance()는 아이템 1에서 정적 팩터리 메서드에 흔히 사용하는 명명 방식에 설명되어 있다. 매개변수가 없는 싱글턴 패턴을 따를 경우 항상 같은 객체를 반환하는 의미로 사용한다.
  • 역시 리플렉션을 통한 예외는 public static final fleid와 똑같이 적용된다.
  • 장점
    1. API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다. 유일한 인스턴스를 반환하던 getInstance()가 호출하는 스레드별로 다른 인스턴스를 넘겨주게 할 수 있다.
    2. 원한다면 정적 팩터리를 제네릭 싱글턴 팩터리로 만들수 있다.
    3. 정적 팩터리의 메서드 참조를 공급자(supplier)로 사용할 수 있다. 예를 들어 Elvis::getInstanceSupplier<Elvis>로 사용할 수 있다.
  • 위의 3가지 장점들이 필요하지 않다면 public static final field방식이 더 좋다.

싱글턴 클래스 직렬화

  • 단순히 Serializable을 구현한다고 선언하는 것만으로는 부족하다.

cf) 직렬화(Serializable)
- 자바 직렬화란 자바 시스템 내부에서 사용되는 객체 또는 데이터를 외부의 자바 시스템에서도 사용할 수 있도록 바이트 형태로 데이터 변환하는 기술과 바이트로 변환된 데이터를 다시 객체로 변환하는 기술(역직렬화)을 아울러서 이야기합니다.
- 자바에서는 java.io.Serializable 인터페이스를 상속받은 객체는 직렬화 할 수 있는 기본 조건입니다.

import java.io.Serializable;

public class Elvis implements Serializable {
    private static final Elvis INSTANCE = new Elvis();

    private Elvis() {
    }

    public static Elvis getInstance() {
        return INSTANCE;
    }

    public void leaveTheBuilding() {
        System.out.println("Whoa baby, I'm outta here!");
    }
}
import java.io.*;

public class ElvisSerialize {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Elvis elvis = Elvis.getInstance();
        System.out.println(elvis.toString());

        // 직렬화
        ObjectOutput out = new ObjectOutputStream(new FileOutputStream("output.text"));
        out.writeObject(elvis);
        out.close();

        // 역직렬화
        ObjectInput in = new ObjectInputStream(new FileInputStream("output.text"));
        Elvis elvis1 = (Elvis) in.readObject();
        in.close();

        System.out.println(elvis1.toString());
    }
}
  • 위와 같이 한다면 서로 다른 elviselivs1이 서로 다른 참조 객체를 가르킬 것 이다.
  • readObject()를 할때 새로운 인스턴스를 생성하기 때문이다.
  • 이를 해결해서 위해서 readResolve()만 추가하면 된다.
import java.io.Serializable;

public class Elvis implements Serializable {
    private static final Elvis INSTANCE = new Elvis();

    private Elvis() {
    }

    public static Elvis getInstance() {
        return INSTANCE;
    }

    private Object readResolve(){
        return INSTANCE;
    }

    public void leaveTheBuilding() {
        System.out.println("Whoa baby, I'm outta here!");
    }
}
  • 이렇게 한다면 역직렬화를 진행할때 계속 같은 참조 객체를 가르킬것 이다.
  • readResolve()가 최초 생성된 인스턴스를 readResolve()를 통해 전달하면 readObject에서 생성된 인스턴스를 GC에 의해 해제됩니다.

열거 타입 방식의 싱글턴

public enum Elvis {
    INSTANCE;

    public void leaveTheBuilding() {
        System.out.println("금방 나갈께!");
    }
}
public class ElvisTest {
    public static void main(String[] args) {
        Elvis elvis = Elvis.INSTANCE;
        elvis.leaveTheBuilding();
    }
}
  • 더 간결하고, 추가 노력 없이 직렬화할 수 있고, 심지어 아주 복잡한 직렬화 상황이나 리플렉션 공격에서도 제2의 인스턴스가 생기는 일을 완벽히 막아준다.
  • 대부분 상황에서는 원소가 하나뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이다.
  • 단, 만들려는 싱글턴이 Enum 외의 클래스를 상속해야 한다면 이 방법은 사용할 수 없다.(열거 타입이 다른 인터페이스를 구현하도록 선언할 수 없다.)
Comments