Java/Effective Java

[이펙티브 자바] 아이템 03. private 생성자나 열거 타입을 싱글턴임을 보증하라(Re)

메성 2020. 7. 19. 21:52
반응형

 싱글턴이란 모두가 알고 있듯이, 오직 하나의 인스턴스만을 생성할 수 있는 클래스를 말한다.

그런데 클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있다. 그 이유는 타입을 인터페이스로 정의한 다음 그 인터페이스를 구현해서 만든 싱글턴이 아니라면 싱글턴 인스턴스를 mock 구현으로 대체할 수 없기 때문이다.

(이유는? https://bottom-to-top.tistory.com/30 참고)

 

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

아이템 3 private 생성자나 열거타입으로 싱글턴임을 보증하라 p.23 클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있다. 타입을 인터페이스로 정의한 다음 ��

bottom-to-top.tistory.com

 

그럼 이제 싱글턴을 만드는 방식을 살펴보자


public static 멤버가 final 필드인 방식(Eager Initialization)

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 static void main(String[] args) {
    Elvis elvis = Elvis.INSTANCE;
    elvis.leaveTheBuilding();
  }
}

소스에서 보는 바와 같이 private 생성자는 public static final 필드인 Elvis.INSTANCE를 초기화할 때 딱 한 번 호출된다.

public이나 protected 생성자가 없으므로 Elvis 클래스가 초기화될 때 만들어진 인스턴스가 단 하나뿐임을 보장하는 것이다.

하지만, 여기서 예외가 있는 경우가 있다. 권한이 있는 클라이언트는 리플렉션 API인 AccessibleObject.setAccessible을 사용하여 private 생성자를 호출할 수 있다.

//리플렉션 사용 예시
public static void main( String[] args ) throws ClassNotFoundException {
  Elvis elvis1 = Elvis.INSTANCE;
  Elvis elvis2 = Elvis.INSTANCE;
  System.out.println(elvis1 == elvis2);

  Class<Elvis> elvisClass = Elvis.class;
  Arrays.stream(elvisClass.getDeclaredConstructors()).forEach(f -> {
    try {
      //모든 접근제한자 접근
      f.setAccessible(true);

      //private 생성자에 접근하여 호출하고 있다.
      Elvis reflectionElvis = (Elvis) f.newInstance();
      System.out.println(elvis1 == reflectionElvis);
    } catch (Exception e) {
      e.printStackTrace();
    }
  });
}

결과 [true / false]


정적 팩터리 메서드를 제공하는 방식(Lazy Initailization)

public class NotRefElvis {
  private static NotRefElvis INSTANCE;
  private NotRefElvis() throws Exception {
    //리플렉션 방지
    if(true) {
      throw new Exception();
    }
  }

  //정적 팩터리 메서드를 활용한 싱글턴
  public static NotRefElvis getInstance() {
    if(INSTANCE == null) {
      try {
        System.out.println("첫번재 생성자 생성");
        INSTANCE = new NotRefElvis();
      } catch (Exception e) {
        e.printStackTrace();
      }
    }
    return INSTANCE;
  }
}

위 소스는 정적 팩터리 메서드를 활용하여 싱글턴을 만드는 방식이며, 리플렉션을 통해 private 생성자에 접근하는 것을 막기 위해 두번째 객체가 생성하려 할 때 예외를 던지는 모습을 볼 수 있다.

정적 팩터리 메서드를 활용해서 객체를 생성하므로, 해당 메서드만 변경하여 싱글턴이 아닌 여러 객체를 생성하게끔 변경이 가능하다. 즉, Eager Initialization보다 유연하다는 특성을 가지고 있다.

하지만, 해당 방식을 멀티 스레드 환경에서는 객체가 하나만 만들어진다는 보장이 없다.


Thread-safe 싱글톤(Lazy Initialization) - (DCL - Double Checked Locking)현재는 권장하지 않음

public class ThreadSafeElvis {
  private volatile static ThreadSafeElvis INSTANCE;
  private ThreadSafeElvis() throws Exception {
    if(true) {
      throw new Exception();
    }
  }

  public static ThreadSafeElvis getInstance() throws Exception {
    if(INSTANCE == null) {
      synchronized (ThreadSafeElvis.class) {
        if(INSTANCE == null) {
          INSTANCE = new ThreadSafeElvis();
        }
      }
    }
    return INSTANCE;
  }

  private Object readResolve() {
    // 싱글턴을 보장하기 위함!
    return INSTANCE;
    }
}

멀티 스레드 환경에서 동시에 getInstance 메소드를 접근하게 되면 싱글턴의 목적이 달라지기 때문에 synchronized(동기화) 처리를 하여 하나의 인스턴스만 만들 수 있도록 한다.

 

위 소스에서 보는 바와 같이 volatile이라는 키워드가 등장하는데, 멀티 스레드 환경에서 volatile을 사용하지 않으면 성능 향상을 위해 메인 메모리에서 값을 읽어오는 것이 아니라 스레드 마다 별도 메모리 공간인 CPU Cache에서 읽어오게 된다. 이로 인해서 멀티 스레드 환경에서 변수를 읽어올 때 올바르지 않은 값을 읽어올 수 있으므로 volatile 키워드를 사용하는 것이다.

 

결과적으로, DCL은 현재 권장하지 않고 있다.


Enum을 활용한 싱글톤

public enum Elvis {
	INSTANCE; 
    private Elvis() {
    	System.out.println("생성 완료");
    } 
    
    public static void main(String[] args) {
    	Elvis elvis = Elvis.INSTANCE;
        elvis.leaveTheBuilding(); 
    }
 }

Enum을 사용하게 되면 매우 간결하고 직렬화할 수도 있으며, 리플렉션 공격에서도 막을 수 있다는 장점을 가지고 있다.

하지만, Enum에도 한계가 존재한다. 보통 Android 개발을 할 경우 Singleton 객체에 context라는 의존성이 끼어들어 있어 context라는 정보를 넘겨야 하는 비효율적인 상황이 발생할 수 있다.


LazyHolder에 의한 싱글턴 초기화(Lazy Initialization)

LazyHolder 방식은 클래스안에 클래스를 두어 JVM의 클래스 로더와 클래스가 로드되는 시점을 이용한 방식이다.

public class HolderElvis {
  private HolderElvis() throws Exception {
    if(true) {
      throw new Exception();
    }
  }

  public static HolderElvis getInstance() {
    return LazyHolder.INSTANCE;
  }
  
  private static class LazyHolder {
    public static final HolderElvis INSTANCE = new HolderElvis();
  }
}

위 중첩 클래스 LazyHolder는 HolderElvis.getInstance()가 호출되기 이전에는 참조 되지 않으며, 최초로 getInstance()를 호출할 때 클래스 로더에 의해 싱글톤 객체를 생성하게 된다. (getInstance()는 static method이기 때문에 클래스가 로드되는 시점에 한번만 호출된다.) 이로 인해, Thread-Safe하다는 점도 확인할 수 있다.


정리

결과적으로, Singleton 객체를 만들 때 가장 바람직한 방법은 synchronized 설정도 필요없고, Java 버전도 상관없는 Lazy Holder를 방식을 사용하는 것이다. 현재 가장 많이 사용하는 방법으로 알려져 있다.


 

이펙티브 자바 Effective Java 3/E
국내도서
저자 : 조슈아 블로크(Joshua Bloch) / 이복연(개앞맵시)역
출판 : 인사이트 2018.11.01
상세보기
반응형