본문은 Effective Java를 읽고 간단하게 정리한 글입니다. 필요에 따라 생략/수정된 부분이 있을 수 있으며, 내용이 추후 변경될 수 있습니다.
선결론
- readObject 메서드는 public 생성자처럼 조심스럽게 다루자
- 다음과 같이 작성하자
- 클래스 내 private 참조 필드는 참조 대상이 되는 객체를 방어적으로 복사해야 한다
- 방어적 복사 이후에는 불변식 검사를 수행하여 InvalidObjectException을 던져라
- 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면 ObjectInputValidation 인터페이스를 사용하라(이 책에서는 다루지 않는다)
- 직접적이든 간접적이든, 재정의할 수 있는 메서드는 호출하지 말자
readObject 메서드는 왜 또 다른 public 생성자라고 불리는가?
- wireteObject/readObject는 각각 직렬화/역직렬화 과정에서 자동으로 호출된다
- 이때, readObject 메서드는 항상 새로 생성된 인스턴스를 반환한다
불변식을 보장하는 방법 1 - 역직렬화된 객체의 유효성을 검사하라
아래의 Period 클래스가 Serializable을 구현하게 만들면 다음과 같은 문제가 발생한다.
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());
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; }
... // 나머지 코드는 생략
}
- 해당 클래스는 불변식을 지키고 불변을 유지하기 위해 방어적 복사를 수행하고, 검증을 수행한다
- 그러나 readObject를 통해 인스턴스가 생성되면 이러한 노력이 무효화될 수 있다
- 따라서 readObjec에서도 인수의 유효성을 검사하고, 매개변수를 방어적으로 복사해야 한다
public class BogusPeriod {
// 진짜 Period 인스턴스에서는 만들어질 수 없는 바이트 스트림
private static final byte[] serializedForm = { ... } // 생략
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);
}
}
}
- 위의 코드에서는 바이트 스트림을 조작하여 불변식이 깨진, 즉 검증을 거치지 않은 인스턴스가 생성된다
- 이는 Period를 직렬화할 수 있도록 선언한 것만으로 클래스의 불변식이 쉽게 깨질 수 있음을 보여준다
이 문제를 해결하기 위해선 Period의 readObject 메서드가 defaultReadObject를 호출한 다음 역직렬화된 객체가 유효한지 검사해야 한다.
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
// 불변식을 만족하는지 검사한다.
if(start.compareTo(end) > 0) {
throw new InvalidObjectException(start + "가 " + end + "보다 늦다.");
}
}
- readObject에서는 불변식을 만족하는지 검사하는 코드가 추가되었다
- 검증에 실패하면 InvalidObjectException을 던지게 해서 잘못된 역직렬화가 일어나는 것을 막는다
- 그러나 아직 문제가 남아있다
불변식을 보장하는 방법 2 - 역직렬화 시 모든 private 참조 필드에 대해서 방어적 복사를 수행하라
위와 같이 readObject를 작성했다 하더라도, 정상 Period 인스턴스에서 시작된 바이트 스트림 끝에 private Date 필드로의 참조를 추가하면 가변 Period 인스턴스를 만들어 낼 수 있다.
public class MutablePeriod {
//Period 인스턴스
public final Period period;
//시작 시각 필드 - 외부에서 접근할 수 없어야 한다.
public final Date start;
//종료 시각 필드 - 외부에서 접근할 수 없어야 한다.
public final Date end;
public MutablePeriod() {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectArrayOutputStream out = new ObjectArrayOutputStream(bos);
//유효한 Period 인스턴스를 직렬화한다.
out.writeObject(new Period(new Date(), new Date()));
/**
* 악의적인 '이전 객체 참조', 즉 내부 Date 필드로의 참조를 추가한다.
* 상세 내용은 자바 객체 직렬화 명세의 6.4절을 참고
*/
byte[] ref = {0x71, 0, 0x7e, 0, 5}; // 참조 #5
bos.write(ref); // 시작 start 필드 참조 추가
ref[4] = 4; //참조 #4
bos.write(ref); // 종료(end) 필드 참조 추가
// Period 역직렬화 후 Date 참조를 훔친다.
ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
period = (Period) in.readObject();
start = (Date) in.readObject();
end = (Date) in.readObject();
} catch (IOException | ClassNotFoundException e) {
throw new AssertionError(e);
}
}
}
아래와 같이 불변 객체의 내부 값을 수정하는 것이 가능하다.
public static void main(String[] args) {
MutablePeriod mp = new MutablePeriod();
Period p = mp.period;
Date pEnd = mp.end;
// 시간 되돌리기
pEnd.setYear(78);
System.out.println(p);
// 60년대로 회귀
pEnd.setYear(60);
System.out.println(p);
/**
* result
* Wed Nov 22 00:21:29 PST 2017 - Wed Nov 22 00:21:29 PST 1978
* Wed Nov 22 00:21:29 PST 2017 - Wed Nov 22 00:21:29 PST 1969
*/
}
- 이 문제의 근원은 Period의 readObject가 방어적 복사를 충분히 하지 않은 데 있다
- 객체를 역직렬화할 때엔 private 참조 필드 모두를 방어적으로 복사해야 한다
다음과 같이 방어적 복사와 유효성 검사를 모두 수행하게끔 readObject를 수정했다
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
// 가변 요소들을 방어적으로 복사한다.
start = new Date(start.getTime());
end = new Date(end.getTime());
// 불변식을 만족하는지 검사한다.
if(start.compareTo(end) > 0) {
throw new InvalidObjectException(start + "가 " + end + "보다 늦다.");
}
}
- 방어적 복사를 유효성 검사보다 앞서 수행했고, Date의 clone 메서드는 사용하지 않음으로써 방어적 복사본을 만들었다(아이템 50)
- readObject 내에서 재할당되어야 하므로 final 한정자는 제거되었다
- final 필드는 방어적 복사가 불가능한 점을 주의하자
이외에도 몇가지 권장사항이 있다.
- 역직렬화 대상이 되는 필드 중에 하나라도 유효성 검사가 필요하다면 readObject 메서드를 만들어 유효성 검사와 방어적 복사를 수행하거나 직렬화 프록시 패턴(아이템 90)을 사용하라
- final이 아닌 직렬화 가능 클래스의 readObject 메서드 내에서 재정의 가능 메서드를 호출해서는 안 된다(아이템 19)
- 이는 하위 클래스의 상태가 역직렬화되기 전에 상위 클래스에서 (하위 클래스에서) 재정의된 메서드를 실행하는 문제를 일으킬 수 있고, 결국 프로그램 오류로 이어질 수 있다
'책 > Effective Java' 카테고리의 다른 글
[이펙티브 자바] 아이템 80: 스레드보다는 실행자, 태스크, 스트림을 애용하라 (0) | 2022.07.21 |
---|---|
[이펙티브 자바] 아이템 72: 표준 예외를 사용하라 (0) | 2022.07.16 |
[이펙티브 자바] 아이템 71: 필요 없는 검사 예외 사용은 피하라 (0) | 2022.07.16 |
[이펙티브 자바] 아이템 70: 복구할 수 있는 상황에는 검사 예외를, 프로그래밍 오류에는 런타임 예외를 사용하라 (0) | 2022.07.15 |
[이펙티브 자바] 아이템 69: 예외는 진짜 예외 상황에만 사용하라 (0) | 2022.07.15 |