[Effective Java]readObject 메소드는 방어적으로 작성하라

직렬화, 네 번째 아이템

Posted by SungBeom on June 04, 2020 · 7 mins read

readObject 메소드를 방어적으로 작성하는 이유 및 방법

불변인 날짜 범위 클래스를 만드는 데 가변인 Date 필드를 이용한 예를 보자. 불변식을 지키고 불변을 유지하기 위해 생성자와 접근자에서 Date 객체를 방어적으로 복사하느라 코드가 상당히 길어졌다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 방어적 복사를 사용하는 불변 클래스
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; }
 
    ...  // 나머지 코드는 생략
}
cs

이 클래스를 직렬화한다면 Period 객체의 물리적 표현이 논리적 표현과 부합하므로 기본 직렬화 형태를 사용해도 나쁘지 않다. 그러니 이 클래스 선언에 implements Serializable을 추가하는 것으로 모든 일을 끝낼 수 있을 것 같지만, 이렇게 해서는 이 클래스의 주요한 불변식을 더는 보장하지 못하게 된다.

문제는 readObject 메소드가 실질적으로 또 다른 public 생성자이기 때문에 주의를 기울여야 한다. 보통의 생성자처럼 readObject 메소드에서도 인수가 유효한지 검사해야 하고 필요하다면 매개변수를 방어적으로 복사해야 한다. readObject가 이 직업을 제대로 수행하지 못하면 readObject는 매개변수로 바이트 스트림을 받는 생성자라 할 수 있게 된다. 불변식을 깨뜨릴 의도로 임의 생성한 바이트 스트림을 건네면 정상적인 생성자로는 만들어낼 수 없는 객체를 생성해낼 수 있기 때문이다.

이 문제를 고치려면 Period의 readObject 메소드가 defaultReadObject를 호출한 다음 역직렬화된 객체가 유효한지 검사해야 한다. 이 유효성 검사에 실패하면 InvalidObjectException을 던지게 하여 잘못된 역직렬화가 일어나는 것을 막을 수 있다.

1
2
3
4
5
6
7
8
9
// 유효성 검사를 수행하는 readObject 메소드
private void readObject(ObjectInputStream s)
        throws IOException, ClassNotFoundException {
    s.defaultReadObject();
 
    // 불변식을 만족하는지 검사한다.
    if (start.compareTo(end) > 0)
        throw new InvalidObjectException(start + "가 " + end + "보다 늦다.");
}
cs

이상의 작업으로 공격자가 허용되지 않는 Period 인스턴스를 생성하는 일을 막을 수 있지만, 아직도 문제가 있다. 정상 Period 인스턴스에서 시작된 바이트 스트림 끝에 private Date 필드로의 참조를 추가하면 가변 Period 인스턴스를 만들어낼 수 있다. 이 문제의 근원은 Period의 readObject 메소드가 방어적 복사를 충분히 하지 않은 데 있다. 객체를 역직렬화할 때는 클라이언트가 소유해서는 안 되는 객체 참조를 갖는 필드를 모두 반드시 방어적으로 복사해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 방어적 복사와 유효성 검사를 수행하는 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 + "보다 늦다.");
}
cs

방어적 복사를 유효성 검사보다 앞서 수행하며, Date의 clone 메소드는 사용하지 않았음에 주목하자. 두 조치 모두 Period를 공격으로부터 보호하는 데 필요하다. 또한 final 필드는 방어적 복사가 불가능하니 주의하자.

기본 readObject 메소드를 써도 좋을지를 판단하는 방법

transient 필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자를 추가해서는 안된다면, 커스텀 readObject 메소드를 만들어 모든 유효성 검사와 방어적 복사를 수행해야 한다. 혹은 직렬화 프록시 패턴을 사용하는 방법도 있다.

final이 아닌 직렬화 가능 클래스라면 readObject와 생성자의 공통점은 마치 생성자처럼 readObject 메소드도 재정의 가능 메소드를 호출해서는 안 된다는 점이다. 이 규칙을 어겼는데 해당 메소드가 재정의되면, 하위 클래스의 상태가 완전히 역직렬화되기 전에 하위 클래스에서 재정의된 메소드가 실행된다.


핵심 정리

readObject 메소드를 작성할 때는 언제나 public 생성자를 작성하는 자세로 임해야 한다. readObject는 어떤 바이트 스트림이 넘어오더라도 유효한 인스턴스를 만들어내야 한다. 바이트 스트림이 진짜 직렬화된 인스턴스라고 가정해서는 안 된다. 이번 아이템에서는 기본 직렬화 형태를 사용한 클래스를 예로 들었지만 커스텀 직렬화를 사용하더라도 모든 문제가 그대로 발생할 수 있다. 이어서 안전한 readObject 메소드를 작성하는 지침을 요약해보았다.

private이어야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라. 불변 클래스 내의 가변 요소가 여기 속한다.
모든 불변식을 검사하여 어긋나는 게 발견되면 InvalidObjectException을 던진다. 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야 한다.
역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면 ObjectInputValidation 인터페이스를 사용하라.
직접적이든 간접적이든, 재정의할 수 있는 메소드는 호출하지 말자.