item 88. readObject 메서드는 방어적으로 작성하라
item 50에서는 불변인 날짜 범위 클래스를 만드는데 가변인 Date 필드를 이용했다.
그래서 불변식을 지키고 불변을 유지하기 위해 생성자와 접근자에서 Date 객체를 방어적으로 복사하느라 코드가 상당히 길어졌다.
public final class Period {
private final Date start;
private final Date end;
/**
* @param start 시작 시각
* @param end 종료 시각; 시작 시각보다 뒤여야 한다.
* @throws IllegalArgumentException 시작 시각이 종료 시각보다 늦을 때 발생한다.
* @throws NullPointerException start나 end가 null이면 발생한다.
*/
public Period(Date start, Date end) {
this.start = new Date(start.getTime()); // 가변인 Date 클래스의 위험을 막기 위해 새로운 객체로 방어적 복사를 한다.
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0) {
throw new IllegalArgumentException(
start + "가 " + end + "보다 늦다.");
}
}
public Date start() { return new Date(start.getTime()); }
public Date end() { return new Date(end.getTime()); }
public String toString() { return start + " - " + end; }
... // 나머지 코드는 생략
}
난 이 클래스를 직렬화하기로 결정했다.
Period 객체의 물리적 표현이 논리적 표현과 부합하므로, 기본 직렬화 형태를 사용해도 나쁘지 않다.
따라서 이 클래스 선언에 implements Serializable을 추가하는 것으로 모든 일을 끝낼 수 있을 것 같다.
---> 하지만, 이렇게 해서는 이 클래스의 불변식을 보장하지 못한다.
---> 또한, readObject 메서드가 실질적으로 또 다른 public 생성자이기 때문에 생기는 문제들이 있다.
- 보통 생성자처럼 readObject 메서드에서도 인수가 유효한지 검사해야 한다.
- 필요하다면 매개변수를 방어적으로도 복사해야 한다.
-> readObject가 이 작업을 제대로 수행하지 못하면 공격자는 아주 손쉽게 해당 클래스의 불변식을 깨뜨릴 수 있다.
readObject?
쉽게 말해 readObject는 매개변수로 바이트 스트림을 받는 생성자라 할 수 있다.
보통의 경우 바이트 스트림은 정상적으로 생성된 인스턴스를 직렬화해야 한다.
-> 하지만 불변식을 깨뜨릴 의도로 임의 생성한 바이트 스트림을 건네면 문제가 생긴다.
-> 정상적인 생성자로는 만들어낼 수 없는 객체를 생성해낼 수 있기 때문이다.
불변식을 보장할 수 없는 예시는 다음과 같다.
다음 클래스를 이용해 종료 시각이 시작 시각보다 앞서는 Period 인스턴스를 만들 수 있다.
public class BogusPeriod {
// 진짜 Period 인스턴스에서는 만들어질 수 없는 바이트 스트림
private static final byte[] serializedForm = {
(byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte)0xf8,
0x2b, 0x4f, 0x46, (byte)0xc0, (byte)0xf4, 0x02, 0x00, 0x02,
0x4c, 0x00, 0x03, 0x65, 0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c,
0x6a, 0x61, 0x76, 0x61, 0x2f, 0x75, 0x74, 0x69, 0x6c, 0x2f,
0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c, 0x00, 0x05, 0x73, 0x74,
0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00, 0x01, 0x78, 0x70,
0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x75,
0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68, 0x6a,
(byte)0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte)0xdf,
0x6e, 0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03,
0x77, 0x08, 0x00, 0x00, 0x00, (byte)0xd5, 0x17, 0x69, 0x22,
0x00, 0x78
};
public static void main(String[] args) {
Period p = (Period) deserialize(serializedForm);
System.out.println(p);
}
// 주어진 직렬화 형태(바이트 스트림)로부터 객체를 만들어 반환한다.
static Object deserialize(byte[] sf) {
try {
return new ObjectInputStream(new ByteArrayInputStream(sf)).readObject();
} catch (IOException | ClassNotFoundException e) {
throw new IllegalArgumentException(e);
}
}
}
별다른 validation check 를 수행하지 않기 때문에, 정상적이지 않은 바이트스트림이 들어오더라도 객체가 생성된다.
문제점이 많다.
- start, end 는 final 변수이지만 해당 필드에 접근하여 수정이 가능하다.
- 해당 문제는 Period의 readObject 메서드가 방어적 복사를 충분히 하지 않은 데 있다.
그럼, 불변식을 보장하기 위해 어떻게 해야 할까?
- 객체를 역직렬화할 때는 클라이언트가 소유해서는 안되는 객체 참조를 갖는 필드를 모두 반드시 방어적으로 복사해야 한다.
- readObject에서는 불변 클래스 안의 모든 private 가변 요소를 방어적으로 복사해야 한다.
[ 기본 readObject 메서드를 써도 좋을지를 판단하는 간단한 방법 ]
질문을 던져라.
Q. transient 필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자를 추가해도 괜찮은가?
A1. 아니오
-> 커스텀 readObject 메서드를 만들어 모든 유효성 검사, 방어적 복사를 수행해야 한다.
-> 혹은 직렬화 프록시 패턴을 사용해 역직렬화를 안전히, 그리고 쉽게 해보자. (권장한다.)
A2. 예
-> 추가해라!
만약 final이 아닌 직렬화 가능 클래스라면, 생성자와의 공통점이 하나 생긴다.
: readObject 메서드도 재정의 가능 메서드를 호출해서는 안된다. 절대! 재정의! 해서는! 안된다.
핵심 정리
readObject 메서드를 작성할 때는 언제나 public 생성자를 작성하는 자세로 임해야 한다.
readObject는 어떤 바이트 스트림이 넘어오더라도 유효한 인스턴스를 만들어내야 한다.
바이트 스트림이 진짜 직렬화된 인스턴스라고 가정해서는 안 된다.
[ 안전한 readObject 메서드를 작성하는 지침 요약 ]
- private 이어야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라. 불변 클래스 내의 가변 요소가 여기 속한다.
- 모든 불별식을 검사하여 어긋나는 게 발견되면 InvalidObjectException 을 던진다. 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야 한다.
- 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면 ObjectInputValidation 인터페이스를 사용해라.
- 직접적이든 간접적이든, 재정의할 수 있는 메서드는 호출하지 말자