[EffectiveJava] 인스턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라
요약
불변식을 지키기 위해 인스턴스 수를 통제(싱글턴 구현)해야 한다면, 열거 타입 사용을 권장한다.
직렬화 + 인스턴스 수 통제가 모두 필요하다면 readResolve() + 모든 참조 타입 인스턴스 필드 transient 선언을 하도록 하자.
- 열거타입
- readResolve() + 모든 인스턴스 필드 transient 선언
1. 싱글턴 패턴 + Serializable
싱글턴 패턴을 구현한 경우, implements Serializable로 직렬화를 구현했다면 이는 더이상 싱글턴처럼 동작하지 않는다.
커스텀 직렬화하고, readObject()를 제공하더라도 더이상 싱글톤으로 작동하지 않는다. 왜냐하면 클래스 초기화 시 생성한 인스턴스가 아닌 다른 인스턴스를 반환하기 때문이다.
2. 해결책 - readResolve()
readResolve()를 사용하면 위의 경우에도 싱글턴 속성을 유지할 수 있다.
- readResolve() psuedo code
readResolve(역직렬화 후 새로 생성된 객체) {
return 직렬화한 객체 참조;
}
- readResolve() - 예시
// 인스턴스 통제를 위한 readResolve - readObject()로 인한 문제 해결 가능성 존재
private Object readResolve(매개변수) {
// 진짜 Elvis를 반환하고, 가짜 Elvis는 가비지 컬렉터에 맡긴다.
return INSTANCE;
}
- 이 readResolve()는 역직렬화한 객체는 무시하고 클래스 초기화 때 만들어진 Elvis 인스턴스를 반환한다. 이때 Elvis의 인스턴스의 모든 필드를 transient로 선언해야 한다.
2-1. transient 사용
readResolve()를 인스턴스 통제 목적으로 사용한다면, 객체 참조 타입 인스턴스 필드는 모두 transient로 선언해야 한다. 그렇지 않으면 역직렬화된 객체의 참조 공격의 가능성이 있다.
2-2. 객체 참조 공격
역직렬화할 인스턴스에 non-transient 필드가 존재한다고 가정해보자. 이 경우에는 필드 내용이 역직렬화 된 후에 readResolve()가 실행된다. 이렇게 필드 내용이 역직렬화되는 시점& readResolve()가 실행되기 전 에 잘 조작된 스트림을 사용해 역직렬화된 인스턴스를 훔쳐올 수 있다.
- 공격 원리
[도둑 클래스]
- 필드(impersonator) : 훔칠 예정인 싱글턴 직렬화 참조를 저장하는 역할
[싱글턴 클래스]
- non-transient 불변 필드(INSTANCE) : 도둑의 인스턴스 참조가 저장됨
* 싱글턴이 도둑 클래스의 참조를 포함하므로 싱글턴이 역직렬화될 때 도둑의 readResolve()가 먼저 호출됨(Elvis의 readResolve()가 아닌)
* 도둑의 readResolve()가 수행될 때,
도둑의 인스턴스 필드에는 역직렬화 중인 싱글턴의 readResolve()가 수행되기 전의 싱글턴 참조가 담긴다
- steal 시점
(1) 역직렬화 (2) ——————> (3) readResolve() 실행(싱글턴 객체 반환)
(2)번 단계에서 instance steal이 발생할 수 있다.
- 잘못된 싱글턴
- transient가 아닌 참조 필드를 가지고 있다
- Elvis가 역직렬화 될 때, INSTANCE에 담긴 도둑 클래스 참조로 인해 Elvis의 readResolve()가 아닌, ElvisStealer의 readResolve()가 실행된다
public class Elvis implements Serializable { // transient가 아닌 참조 필드 // transient가 아니기에 이후 도둑의 인스턴스 참조가 담길 예정 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; } }
- 도둑 클래스
public class ElvisStealer implements Serializable {
static Elvis impersonator; // 훔칠 Elvis 인스턴스의 참조를 저장할 필드
private Elvis payload; // 기존의 싱글턴 Elvis를 받음
// .. ElvisStealer 생성자
ElvisStealer(Elvis elvis) {
this.payload = payload;
}
private Object readResolve() {
// resolve 되기 전의 Elvis 인스턴스의 참조를 저장한다.
impersonator = payload;
// favoriteSongs 필드에 맞는 타입의 객체를 반환한다.
return new String[] {"A Fool Such as I"};
}
private static final long serialVersinUID = 0;
}
- 아래 코드를 실행하면 서로 다른 2개의 Elvis 인스턴스를 생성한다
public class ElvisImpersonator {
// 진짜 Elvis 인스턴스로는 만들어질 수 없는 바이트 스트림
private static final byte[] serializedForm = {
(byte)0xac, (byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x05,
0x45, 0x6c, 0x76, 0x69, 0x73, (byte)0x84, (byte) 0xe6,
(byte)0x93, 0x33, (byte)0xc3, (byte)0xf4, (byte)0x86
// ..
};
public static void main(String[] args) {
// ElvisStealer.impersonator 를 초기화한 다음
// 진짜 Elvis(즉, Elvis.INSTANCE)를 반환한다.
Elvis elvis = (Elvis) deserialize(serializedForm);
Elvis impersonator = ElvisStealer.impersonator;
elvis.printFavorites();
impersonator.printFavorites();
}
}
- 결과
- Elvis는 싱글턴 패턴으로 구현했음에도 두 개의 인스턴스가 생성된 것을 확인할 수 있다.
- 따라서, 싱글턴인 경우에도 implements Serializable을 한다면 싱글턴이 깨질 수 있다
[Hound Dog, Heartbreak Hotel]
[A Fool Such as I]
→ 싱글턴 + Serializable + readResolve()를 설정했더라도, non-transient 필드가 있다면 싱글턴이 깨진다.
2-3. 해결방법
위처럼 싱글턴 패턴인데도 직렬화 때문에 2개의 인스턴스가 생성되는 해결법으로는 2가지가 있는데, 다음과 같다.
- 인스턴스 필드 transient 선언 (transient favoriteSongs)
- 이 방법은 readResolve()를 사용해 순간적으로 만들어진 역직렬화된 인스턴스에 접근하지 못하는 로직을 구현해야하고, 이는 깨지기 쉽고 신경을 많이 써야해서 권장하지 않는다.
- Elvis를 원소 하나짜리 열거 타입으로 변경
- 저자는 이 방법을 더 권장!
- 직렬화 가능 인스턴스 통제 클래스 + 열거 타입으로 구현하면 선언한 상수 객체만 존재함을 자바가 보장한다
3. 열거 타입 싱글턴
열거 타입으로 싱글턴은 구현한 코드는 다음과 같다.
public enum EnumElvis {
INSTANCE;
private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
public void printFavorite() {
System.out.println(Arrays.toString(favoriteSongs));
}
}
열거 타입으로 싱글턴을 구현할 수 있지만, 모든 경우에 적용되는 것은 아니다. 만약 컴파일 타임에 어떤 인스턴스가 있는지 알기 어렵다면, 열거타입을 적용하기 어렵다. 이때는 readResolve로 직렬화한 인스턴스를 통제하면 된다.
4. readResolve() 접근성
- final 클래스
- private readResolve()
- non-final 클래스
- private readResolve() : 하위 클래스에서 사용 불가능
- package-private readResolve() : 같은 패키지, 하위 클래스에서만 사용 가능
- protected or public readResolve() : 재정의하지 않은 모든 하위 클래스에서 사용 가능하다
- 다만, 재정의하지 않은 하위클래스를 역직렬화할 경우 상위 인스턴스 생성시 ClassCastException이 발생할 수 있다