Skip to content

[아이템 88] readObject 메서드는 방어적으로 작성하라 #101

@sso9594

Description

@sso9594

📌 [아이템 88] readObject 메서드는 방어적으로 작성하라

✨ 핵심 내용

방어적 복사를 사용하는 불변 클래스

import java.util.Date;

public final class Period {
    private final Date start;
    private final Date end;

    /**
     * @param start 시작 시각 (null 아님)
     * @param end 종료 시각 (null 아님, 시작 시각보다 같거나 이후여야 함)
     * @throws IllegalArgumentException 시작 시각이 종료 시각보다 늦을 경우
     * @throws NullPointerException start 또는 end가 null일 경우
     */
    public Period(Date start, Date end) {
        if (start == null || end == null)
            throw new NullPointerException("start와 end는 null일 수 없습니다.");
        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());
    }

    @Override
    public String toString() {
        return start + " - " + end;
    }

    // 기타 필요한 메서드는 생략
}
  • implements Serializable을 추가하여 직렬화 한다면?
    • 이 클래스는 불변식을 더이상 보장받지 못함
  • readObject 메서드가 사실 또 다른 객체를 생성함 → 다른 생성자들 처럼 주의해야함
  • 따라서, 인수가 유효한지 검사하고 필요시 매개변수를 방어적으로 복사해야함
    • 이를 하지 않으면 공격자가 해당 클래스의 불변성을 쉽게 깨뜨림
public class BogusPeriod {
    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에서는 분명 start가 end보다 뒤라면 예외를 던짐
  • 하지만 이렇게 readObject를 통해 객체를 생성하면 생성자를 통한 객체 생성이 아니므로 유효성을 검사하지 못함 → 불변식 깨짐
  • 이를 해결하기 위해서는 Period의 readObject 메서드7가 defaultReadObject를 호출한 다음 역직렬화된 객체가 유효한지 검사해야함
private void readObject(ObjectlnputStream s)
		throws lOException, ClassNotFoundException {
	s.defaultReadObject();
	// 불변식을 만족하는지 검사한다.
	if (start.compareTo(end) > 0)
		throw new InvalidObjectException(start + "가" + end + "보다 늦다.");
}

하지만 이 메서드도 문제 하나가 숨어있다

  • 공격자는 여전히 가변 객체(예: Date)의 참조를 악의적으로 조작할 수 있음.
  • 직렬화된 바이트 스트림 끝에 Period 객체가 가진 Date 필드의 참조를 의도적으로 추가함.
  • 그 바이트 스트림을 역직렬화하면:
    • Period 객체는 정상처럼 보이지만,
    • 공격자는 내부 start, end 필드를 가리키는 Date 객체 참조도 따로 얻게 됨.
  • 공격자는 그 참조를 이용해 Date 값을 바꿈
    • 직렬화는 객체 간 참조도 함께 유지 (shared reference)
    • 즉, 같은 객체를 여러 번 직렬화하면 참조 번호만 넘기기 때문에,
    • 공격자는 Period 내부 필드의 참조를 따로 꺼내서 조작할 수 있음.

문제의 근본 원인

  • Period 클래스는 Date라는 가변 객체를 필드로 가지고 있음.
  • 역직렬화 시 readObject()에서 이 Date들을 그대로 사용하면,
    • 공격자가 내부 참조를 외부에서 조작할 수 있음.
    • 결과적으로 Period는 더 이상 불변 객체가 아님.

해결 방법

  • readObject() 메서드에서 모든 private 가변 필드(Date 등)를 반드시 방어적 복사해야 함.
  • 즉, 역직렬화된 가변 필드들을 복사한 값으로 다시 할당해줘야 진짜 불변성을 지킬 수 있음.
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 + "보다 늦다.");
    }
}
  • 방어적 복사를 유효성 검사보다 먼저 수행 → clone 메서드 사용 X
  • final 필드는 방어적 복사 불가능

readObject 메서드를 사용하는 기준

transient 필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자를 추가해도 괜찮은가?

아니오라면?

커스텀 readObject 메서드를 만들어 모든 유효성 검사와 방어적 복사 수행

or

직렬화 프록시 패턴을 사용 → 안전하고 편하므로 추천!

생성자 처럼 readObject 메서드 역시 재정의 가능 메서드를 호출하지 말것

생성자와 똑같은 꼴을 보게 될 것.

💡 새롭게 알게 된 점

  • 학습하면서 이전과 다르게 이해한 점이나 새롭게 알게 된 개념을 공유해주세요.

📚 정리

  • 핵심 내용을 정리해주세요.
  • 해당 개념을 실제 프로젝트에서 어떻게 활용할 수 있을지 아이디어를 공유해주시면 더 좋아요!

📢 댓글로 각자의 학습 내용을 공유해주세요!

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions