본문 바로가기
개발 공부/Java

이펙티브 자바 아이템 3 - 생성자나 열거 타입으로 싱글턴임을 보증하라 - 핵심 정리

by 개발인생 2022. 9. 15.
반응형

아이템 3 - 생성자나 열거 타입으로 싱글턴임을 보증하라 - 핵심 정리

이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.

애플리케이션을 만들다보면 어떤 인스턴스가 애플리케이션에서 하나만 있어야 하는 경우

혹은 하나만 꼭 유지해야하는 경우가 있다.

예를들어 어떤 설정값을 저장하는 인스턴스 등이 있다.

이러한 상황일 때 사용하는 것이 싱글턴이다.

싱글턴을 만드는 방법은 여러가지가 있다.


첫번째 : private 생성자 + 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 void sing() {
      System.out.println("I'll have a blue~ Christmas without you~");
   }
}

private 생성자를 사용하여 외부에서 생성자를 호출할 수 없게 만들었다.

public static final 필드 를 이용해 해당 클래스 타입의 인스턴스를 만든다.

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

헤딩 클래스를 사용하는 클라이언트 코드는 위와같이 인스턴스를 사용하게 된다.

장점으로는

  • 코드가 간결하다.
  • 자바 docs를 만들때 주석을 통해 api 문서에 드러낼 수 있다.

단점으로는

단점 1. 싱글톤을 사용하는 클라이언트 코드를 테스트하기 어려워진다.

인터페이스 없이 싱글톤을 정의한 경우 클라이언트 코드를 테스트하기 어려워진다.

public class Concert {
   private boolean lightsOn;

   private boolean mainStateOpen;

   private Elvis elvis;

   public Concert(Elvis elvis) {
      this.elvis = elvis;
   }

   public void perform() {
      mainStateOpen = true;
      lightsOn = true;
      elvis.sing();
   }

   public boolean isLightsOn() {
      return lightsOn;
   }

   public boolean isMainStateOpen() {
      return mainStateOpen; 
   }
}

Concert 라는 클래스가 있고 이 클래스에서는 Elvis 를 사용하고있다.

즉, Concert 라는 클래스가 Elvis 클래스의 클라이언트 코드이다.

이 경우 Elvis 클래스를 직접 사용하고 있기 때문에 테스트하기가 어려워진다.

물론 다음과 같이 테스트 코드를 작성할 수 있다.

class ConcertTest {

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

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

만약 Elvis 클래스 가 외부 Api 를 호출하는 경우나 연산이 오래걸리는 작업이 있을수도 있다.

이때마다 테스트 코드를 통해 호출하는건 굉장히 비효율적이다.

인터페이스를 통해 코드를 개선해보면 다음과 같다.

public interface IElvis {

   void leaveTheBuilding();

   void sing();
}

IElvis 라는 인터페이스를 만든다.

public class Elvis implements IElvis{

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

   private Elvis() {}

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

   public void sing() {
      System.out.println("I'll have a blue~ Christmas without you~");
   }

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

IElvisElvis 를 통해 구현한다.

public class Concert {
   private boolean lightsOn;

   private boolean mainStateOpen;

   private IElvis elvis;

   public Concert(IElvis elvis) {
      this.elvis = elvis;
   }

   public void perform() {
      mainStateOpen = true;
      lightsOn = true;
      elvis.sing();
   }

   public boolean isLightsOn() {
      return lightsOn;
   }

   public boolean isMainStateOpen() {
      return mainStateOpen;
   }
}

Concert 클래스의 코드를 인터페이스 기반의 코드로 작성한다.

이렇게하면 IElvis 인터페이스를 구현한 임의의 클래스를 통해 테스트하기가 수월해진다.

class ConcertTest {

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

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

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

리플렉션 을 사용하면 싱글톤이 깨지게된다.

   public static void main(String[] args) {
      try {
         Constructor<Elvis> defaultConstructor = Elvis.class.getDeclaredConstructor();
         defaultConstructor.setAccessible(true); // private 생성자 호출 가능하게 설정

         Elvis elvis1 = defaultConstructor.newInstance();
         Elvis elvis2 = defaultConstructor.newInstance();

         System.out.println(elvis1 == elvis2); // false
      } catch (NoSuchMethodException e) {
         e.printStackTrace();
      } catch (InvocationTargetException e) {
         e.printStackTrace();
      } catch (InstantiationException e) {
         e.printStackTrace();
      } catch (IllegalAccessException e) {
         e.printStackTrace();
      }
   }

getDeclaredConstructor 메서드를 사용하면 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("Whoa baby, I'm outta here!");
   }

   public void sing() {
      System.out.println("I'll have a blue~ Christmas without you~");
   }

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

다음과 같이 private static 필드를 통해 최초의 인스턴스 생성 이후

생성자에 접근하여 인스턴스를 생성하면 오류를 발생하도록 할 수 있다.

이렇게해서 리플렉션 을 통해 싱글턴을 깨뜨릴 수 없게 막을 수 있다.

대신 코드가 간결해진다는 장점은 사라지게 된다.

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

   public static void main(String[] args) {
      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();
      }
   }

직렬화 를 통해 객체 정보를 저장할 수 있고,

역직렬화 를 통해 어딘가에 저장되어있는 객체의 정보를 읽어올 수 있다.

저장을 했다가 읽어올 때 새로운 인스턴스가 생기게된다.

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("Whoa baby, I'm outta here!");
   }

   public void sing() {
      System.out.println("I'll have a blue~ Christmas without you~");
   }

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

직렬화를 하려면 Serializable 인터페이스를 구현해야한다.

주의할 점은 아규먼트가 없는 기본 생성자가 있어야한다 는 점이다.

역직렬화시 호출되는 메서드가 있는데 그 메서드를 선언하는 방법이 있다.

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("Whoa baby, I'm outta here!");
   }

   public void sing() {
      System.out.println("I'll have a blue~ Christmas without you~");
   }

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

   private Object readResolve() {
      return INSTANCE;
   }
}

readResolve 메서드를 선언한다.

역직렬화시 새로운 인스턴스가 아니라 기존에 사용하던 인스턴스를 리턴하도록 한다.

문법적으로 오버라이딩은 아니지만 역직렬화시 해당 메서드가 사용이 된다.

이렇게 단점을들 보완하는 코드를 작성하다보면

싱글턴의 간결한 코드라는 장점이 사라지게된다.

스프링 사용시 빈으로 등록해놓으면 싱글턴 오브젝트를 사용할 수 있다.


두번째 : private 생성자 + 정적 팩터리 메서드

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!");
   }
}

정적 팩터리 메서드를 통해 인스턴스를 가져오는 방법이다.

첫번째 방법과 비슷하지만 INSTANCE 를 private static final 로 선언하고

INSTANCE 를 public static 메서드를 통해 가져간다 는 점이다.

단점은 이전 방법과 동일하지만 첫번째 방법에 비해 장점이 몇가지 있다.

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

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

클라이언트 코드에서 getInstance 메서드를 그대로 사용하면서 동작을 변경할 수 있다.

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

   private Elvis() {}

   public static Elvis getInstance() {
      return new Elvis(); // 새로운 인스턴스를 매번 넘겨주도록 수정
   }

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

위처럼 getInstance 메서드를 수정하면 클라이언트 코드를 바꾸지 않고도

동작을 변경할 수 있게된다.

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

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 void leaveTheBuilding() {
      System.out.println("Whoa baby, I'm outta here!");
   }
}

MetaElvis 클래스를 생성한다.

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

      System.out.println(elvis1.equals(elvis2)); // true (제네릭 타입이 다르기 때문에 equals로 비교)
   }

제네릭한 타입으로 동일한 싱글턴 인스턴스를 사용하고 싶을 때

제네릭 싱글턴 팩터리 를 만들어 사용할 수 있다.

인스턴스는 동일하지만 각각의 타입으로 바꿔서 사용할 수 있다.

원하는 타입으로 형변환을 해줄 수 있다는 장점이 있다.

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

public interface Singer {

   void sing();
}

Singer 인터페이스를 생성한다.

public class Concert {

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

Concert 클래스를 생성한다.

SupplierFunctionalInterface 라고 한다.

@FunctionalInterface
public interface Supplier<T> {

    /**
     * Gets a result.
     *
     * @return a result
     */
    T get();
}

Supplier 인터페이스를 만족하기만 하면 어떠한 메서드든 Supplier 타입으로 사용할 수 있다.

즉, 어떤 타입이든 리턴하기만 하면 된다.

Supplier 를 직접 구현하지 않아도 조건을 만족하기만 하면 사용할 수 있다.

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("Whoa baby, I'm outta here!");
   }

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

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

Singer 인터페이스를 구현하도록 Elvis 클래스를 만든다.

   public static Elvis getInstance() {
      return INSTANCE;
   }

Elvis 클래스의 getInstance 메서드는

인자 없는 메서드를 호출해서 무언가를 리턴해주는

Supplier 에 준하는 메서드가 된다.

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

다음과 같이 메서드 레퍼런스로 getInstance 를 참조하여 사용할 수 있다.

이렇게 정적 팩터리의 메서드 참조를 공급자로 사용할 수 있다.

세번째 : 열거 타입

열거 타입을 사용해 싱글턴을 생성하는 방법이다.

public enum Elvis {
   INSTANCE;

   public void leaveTheBuilding() {
      System.out.println("기다려 자기야, 지금 나갈께!");
   }
}

위와 같이 enum 을 사용해 싱글턴을 만들 수 있다.

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

클라이언트 코드에서는 위와같이 인스턴스를 사용한다.

이 방법은 리플렉션과 직렬화, 역직렬화에 굉장히 안전한 방법이다.

Enum 은 리플렉션을 내부코드로 막아 놓았기 때문에 생성자를 불러오려고 하면 에러가 발생한다.

Enum 은 생성자를 통해 인스턴스를 만들 수 없다.

public enum Elvis implements IElvis {
   INSTANCE;

   public void leaveTheBuilding() {
      System.out.println("기다려 자기야, 지금 나갈께!");
   }

   @Override
   public void sing() {

   }

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

enum 은 인터페이스를 구현할 수 있기에 테스트 코드 작성시의 문제까지 해결할 수 있다.

반응형

댓글