private 생성자나 열거 타입으로 싱글턴임을 보장하자

2020-11-10

싱글턴이란 객체를 오직 하나만 생성할 수 있는 클래스를 말한다. 그런데 클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워 질 수 있다.

싱글턴을 만드는 방법

싱글턴을 만드는 방법은 보통 둘 중 하나이다. 두 방식 모두 생성자는 외부에서 호출을 못하도록 private으로 막아놓고, public static 멤버로 객체를 반환하거나, 아니면 메소드로 객체를 반환하는 방법이다.

public static final 필드 방식의 싱글턴

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

private 생성자는 public static final 필드인 Elvis.INSTANCE를 초기화 할 때 딱 한번 호출 된다. 그 외에 public이나 protected 생성자가 없으므로 Elvis 객체는 시스템에서 오직 하나뿐임을 보장한다.

정적 팩터리 방식의 싱글턴

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

정적 팩터리 방식도 필드 방식의 싱글턴과 같이 Elvis 객체가 시스템에 오직 하나뿐임을 보장한다.

둘의 차이점은?

정적 팩터리 방식의 장점은 API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다는 점이다. 프로그래머가 원하면 싱글턴 방식의 정책을 고수하지 않고, 매번 새로운 객체를 반환하도록 내부구현을 쉽게 변경할 수 있다.

또 다른 정적 팩터리 방식의 장점은 제너릭 싱글턴 팩터리를 만들 수 있다는 장점이 있다. 또한 정적팩터리의 메서드 참조를 공급자(supplier)로 사용할 수 있다는 점이다. 가령 Elvis::getInstanceSupplier<Elvis> 로 사용하는 식이다.

이러한 장점들이 굳이 필요하지 않다면 public 필드 방식이 깔끔하고 좋은 것 같다.

문제점은 뭐가 있을까?

싱글턴이라고 하면, 반드시 이 객체는 프로그램 내에 오직 하나만 존재해야한다. 어떠한 이유로 객체가 하나 더 생기면 안된다.

그러나 직렬화하고 다시 역직렬화를 하게 되면 새로운 객체가 생성되게 된다. 이런 상황을 방지하기 위해서는 다음과 같이 readResolve() 함수를 만들면 된다.

//싱글턴임을 보장해주는 readResolve 메서드
private Object readResolve() {
  // 진짜 Elvis를 반환하고 가짜 Elvis는 GC에게 맡긴다.
  return INSTANCE;
}

실제로 직렬화와 역직렬화를 테스트 해보기 위해 다음과 같은 코드를 짜보았다.

먼저 readObject() 가 없으면 어떤 결과가 나오는지 테스트 해봤다.

Singleton.java

public class Singleton implements Serializable {

    private static Singleton instance = new Singleton();
    private int i;

    public static Singleton getInstance() {
        return instance;
    }

    private Singleton() { }
    
    public static void main(String[] args) throws Throwable {
        Singleton s = Singleton.getInstance();
        s.i = 5;

        System.out.println("직렬화 전 ::"+s.i+" "+ s);
        ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
        ObjectOutputStream oos = new java.io.ObjectOutputStream(baos);
        oos.writeObject(getInstance());
        oos.close();

        s.i = 7;
        InputStream is = new ByteArrayInputStream(baos.toByteArray());
        ObjectInputStream ois = new ObjectInputStream(is);
        Singleton deserialized = (Singleton) ois.readObject();
        System.out.println("역직렬화 후::"+deserialized.i+" "+deserialized);
    }
}

Output

직렬화  ::5 Singleton@6d06d69c
역직렬화 후::5 Singleton@776ec8df

콘솔에 찍힌것 과 같이, 객체가 두개가 생겼다. 싱글턴을 위반한 것이다.

그렇다면 readResolve() 함수를 만들어주면 어떻게 될까?

Singleton.java (readResolve() 함수가 있는)

public class Singleton implements Serializable {

    private static Singleton instance = new Singleton();
    private int i;

    public static Singleton getInstance() {
        return instance;
    }

    private Singleton()  }

    private Object readResolve() {
        System.out.println("Executing readResolve");
        return instance;
    }
    public static void main(String[] args) throws Throwable {
        Singleton s = Singleton.getInstance();
        s.i = 5;

        System.out.println("직렬화 전 ::"+s.i+" "+ s);
        ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
        ObjectOutputStream oos = new java.io.ObjectOutputStream(baos);
        oos.writeObject(getInstance());
        oos.close();

        s.i = 7;
        InputStream is = new ByteArrayInputStream(baos.toByteArray());
        ObjectInputStream ois = new ObjectInputStream(is);
        Singleton deserialized = (Singleton) ois.readObject();
        System.out.println("역직렬화 후::"+deserialized.i+" "+deserialized);
    }
}

Output

직렬화  ::5 Singleton@6d06d69c
Executing readResolve
역직렬화 후::7 Singleton@6d06d69c

이전과 다르게 역직렬화를 한 후 같은 객체를 반환한다.

그런데 역직렬화를 디버깅 해보면 다음과 같이 리플렉션을 사용해서 새로운 객체를 만든다. 그럼 이 객체는 어떻게 될까?

직렬화마샬링교집합사진

만들어진 객체는 가비지 컬렉션에 의해서 사라지게 된다.

👊 열거 타입 방식의 싱글턴 - 또다른 방법

public enum Elvis {
  INSTNACE;
  
  public void doSomething() {...}
}

public 필드 방식과 비슷하지만, 더 간결하고, 추가 노력 없이 직렬화할 수 있고, 심지거 아주 복잡한 직렬화 상황잉나 리플렉션 공격에서도 제 2의 인스턴스가 생기는 일을 완벽히 막아준다. 조금 부자연스러워 보일 수는 있으나 대부분 상황에서 원소가 하나뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이다. 단, 만들려면 싱글턴이 Enum 외의 클래스를 상속해야 한다면 이 방법은 사용할 수 없다.