DEV ℧ Developer Diary

[EffectiveJava] item89 - 인스턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라

인스턴스 수를 통제하는 경우 대표적인 예시로 싱글톤 패턴을 들 수 있다.

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

    public void leaveTheBuilding() { }
}

이 클래스에 implements Serializable을 추가하는 순간 더 이상 싱글톤 클래스가 아니게 된다.

기본 직렬화를 쓰지 않거나. 명시적인 readObject를 제공하더라도 소용없다. 어떤 readObject를 사용하든 이 클래스가 초기화될 때 만들어진 인스턴스와는 별개인 인스턴스를 반환하게 된다.

readResolve

readResolve 기능을 이용하면 readObject가 만들어낸 인스턴스를 다른 것으로 대체할 수 있다.

역직렬화한 객체의 클래스가 readResolve 메서드를 적절히 정의해뒀다면, 역직렬화 후 새로 생성된 객체를 인수로 이 메서드가 호출되고, 이 메서드가 반환한 객체 참조가 새로 생성된 객체를 대신해 반환한다.

앞의 Elvis 클래스가 Serializable을 구현한다면 다음의 readReslove 메서드를 추가해 싱글턴이라는 속성을 유지할 수 있다.

/* 인스턴스 통제를 위한 readResolve - 개선의 여지가 있다. */
private Object readResolve() {
    /* 진짜 Elvis를 반환하고, 가짜 Elvis는 가비지 컬렉터에 맡긴다. */
    return INSTANCE;
}

이 메서드는 역직렬화한 객체는 무시하고 클래스 초기화 때 만들어진 Elvis 인스턴스를 반환한다.

readResolve의 함정

사실, readResolve를 인스턴스 통제 목적으로 사용한다면 객체 참조 타입 인스턴스 필드는 모두 transient로 선언해야 한다.
그렇지 않으면 Item88에서 살펴본 MutablePeriod 공격과 비슷한 방식으로 readResolve 메서드가 수행되기 전에 역직렬화 된 객체의 참조를 공격할 여지가 남는다.

잘못된 싱글톤

싱글톤이 transient가 아닌 non-transient 참조 필드를 가지고 있다면, 그 필드 내용은 readResolve 메서드가 실행되기 전에 역직렬화 된다.
그렇다면 잘 조작된 스트림을 써서 해당 참조 필드의 내용이 역질렬화 되는 시점에 그 역직렬화된 인스턴스의 참조를 훔쳐올 수 있다.

다음의 예시를 통해 살펴보자.

잘못된 싱글톤 - transient가 아닌 참조 필드를 가지고 있을 경우

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

    private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
    public void printFavorites() {
        System.out.println(Arrays.toString(favoriteSongs));
    }

    private Object readResolve() {
        return INSTANCE;
    }
}

다음은 Elvis의 readResolve 메서드가 실행되기 전에 Elvis의 인스턴스를 훔쳐 먼저 다른 readResolve가 실행되게 하는 도둑 클래스 이다.

Elvis 도둑 클래스

public class ElvisStealer implements Serializable {
  static Elvis impersonator;
  private Elvis payload;

  private Object readResolve() {
    /* resolve되기 전에 Elvis 인스턴스의 참조를 저장한다. */
    impersonator = payload;

    /* favoriteSongs 필드에 맞는 타입의 객체를 반환한다. */
    return new String[] { "NEW JEANS: Hype Boy ~" };
  }
  private static final long serialVersionUID = 0;
}

마지막으로, 괴의한 프로그램은 수작업으로 만든 스트림을 이용해 2개의 싱글턴을 만들어 낸다.

직렬화의 허점을 이용해 싱글턴 객체를 2개 생성한다.

public class ElvisImpersonator {
    /* 진짜 Elvis 인스턴스로는 만들어질 수 없는 바이트 스트림 */
    private static final byte[] serializedForm = {
            -84, -19, 0, 5, 115, 114, 0, 30, 99, 111, 109, 46,
            101, 102, 102, 101, 99, 116, 105, 118, 101, 74, 97,
            118, 97, 46, 105, 116, 101, 109, 56, 57, 46, 69, 108,
            118, 105, 115, -95, -53, -34, 127, -23, -75, 15, -46,
            2, 0, 1, 91, 0, 13, 102, 97, 118, 111, 114, 105, 116,
            101, 83, 111, 110, 103, 115, 116, 0, 19, 91, 76, 106,
            97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114,
            105, 110, 103, 59, 120, 112, 117, 114, 0, 19, 91, 76,
            106, 97, 118, 97, 46, 108, 97, 110, 103, 46, 83, 116,
            114, 105, 110, 103, 59, -83, -46, 86, -25, -23, 29, 123,
            71, 2, 0, 0, 120, 112, 0, 0, 0, 1, 116, 0, 21, 78, 69,
            87, 32, 74, 69, 65, 78, 83, 58, 32, 72, 121, 112, 101,
            32, 66, 111, 121, 32, 126
    };
    public static void main(String[] args) {
        /* ElvisStealer.impersonator를 초기화한 다음,
           진짜 Elvis(즉 Elvis.INSTANCE)를 반환한다. */
        Elvis elvis = (Elvis) deserialize(serializedForm);
        Elvis impersonator = ElvisStealer.impersonator;

        elvis.printFavorites();
        impersonator.printFavorites();
    }

    /** 주어진 직렬화 형태(바이트 스트림)로부터 객체르 만들어 반환한다. */
    static Object deserialize(byte[] sf) {
        try {
            return new ObjectInputStream(new ByteArrayInputStream(sf)).readObject();
        } catch (IOException | ClassNotFoundException e) {
            throw new IllegalArgumentException(e);
        }
    }
}

이 프로그램을 실행하면 다음 결과를 출력한다. 이것으로 서로 다른 2개의 Elvis 인스턴스를 생성할 수 있음을 증명했다.

[Hound Dog, Heartbreak Hotel]
[NEW JEANS: Hype Boy ~]

favoriteSons 필드를 transient로 선언하여 이 문제를 고칠 수 있지만 Elvis를 원소 하나짜리 열거 타입으로 바꾸는 편이 더 나은 선택이다.

readResolve 메서드는 깨지기 쉽고 신경을 많이 써야하는 작업이기 때문이다.

열거타입의 직렬화

직렬화 가능한 인스턴스 통제 클래스르 ㄹ열거 타입을 이용해 구현하면 선언한 상수 외의 다른 객체는 존재하지 않음을 자바가 보장해 준다.
예외가 있다면 AccessibleObject.setAccessible 같은 특권(privileged) 메서드를 악용하는 방법이다.

열거 타입 싱글톤 - 전통적인 싱글톤보다 우수하다.

public enum Elvis {
    INSTANCE;
    private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
    public void printFavorites() {
      System.out.println(Arrays.toString(favoriteSongs));
    }
}

readResolve를 이용할 경우

readResolve를 사용하는 방식이 완전히 쓸모없는 것은아니다. 직렬화 가능 인스턴스 통제 클래스를 작성할 경우, 컴파일 타임에 어떤 인스턴스들이 있는지 알 수 없는 상황이라면 열거타입으로 표현하는 것이 불가능하다.

readResolve 메서드의 접근성은 매우 중요하다. final 클래스에서라면 readResolve 메서드는 private이어야 한다.

final이 아닌 클래스

final이 아닌 클래스에서는 다음의 몇가지를 주의해서 고려해야 한다.

  • private으로 선언하면 하위 클래스에서 사용할 수 없다.
  • package-private으로 선언하며 같은 패키지에 속한 하위클래스에서만 사용할 수 있다.
  • protected나 public으로 선언하면 이를 재정의하지 않은 모든 하위 클래스에서 사용할 수 있다.
  • protected나 public이면서 하위 클래스에서 재정의하지 않았다면, 하위 클래스의 인스턴스를 역직렬화하면 상위 클래스의 인스턴스를 생성하여 CLassCastException을 일으킬 수 있다.