Item50 - 적시에 방어적 복사본을 만들라

2023. 12. 27. 21:20Book/이펙티브 자바

자바는 안전한 언어지만 다른 클래스로부터 침범을 막으려면 방어적으로 프로그래밍해야 한다.

 

어떤 객체든 그 객체의 허락 없이는 외부에서 내부를 수정하는 일은 불가능하다. 하지만 주의를 기울이지 않으면 자기도 모르게 내부를 수정하도록 허락하는 경우가 생긴다.

// 코드 50-1 기간을 표현하는 클래스 - 불변식을 지키지 못했다. (302-305쪽)
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) {
        if (start.compareTo(end) > 0)
            throw new IllegalArgumentException(
                    start + "가 " + end + "보다 늦다.");
        this.start = start;
        this.end   = end;
    }

    public Date start() {
        return start;
    }
    public Date end() {
        return end;
    }

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


    // 나머지 코드 생략
}

 

얼핏 이 클래스는 불변처럼 보이지만 Date가 가변이라는 사실을 이용하면 쉽게 불변식을 깨뜨릴 수 있다.

 

Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78);  // p의 내부를 변경했다!
System.out.println(p);

=>
Wed Dec 27 20:40:07 KST 2023 - Wed Dec 27 20:40:07 KST 1978

 

자바 8 이후로는 Date 대신 불변인 Instant를 사용하면 된다(혹은 LocalDateTime이나 ZonedDateTime을 사용해도 된다). Date는 낡은 API이니 새로운 코드를 작성할 때는 더 이상 사용하면 안된다. 하지만 여전히 많은 API와 내부 구현에 그 잔재가 남아 있다. 이번 아이템은 예전에 작성된 낡은 코드들을 대처하기 위한 것이다.

 

외부 공격으로부터 Period 인스턴스의 내부를 보호하려면 생성자에서 받은 가변 매개변수 각각을 방어적으로 복사해야 한다. 그런 다음 Period 인스턴스 안에서는 원본이 아닌 복사본을 사용한다.

 

// 코드 50-3 수정한 생성자 - 매개변수의 방어적 복사본을 만든다. (304쪽)
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(
                this.start + "가 " + this.end + "보다 늦다.");
}

 

 

새로 작성한 생성자를 사용하면 앞서의 공격은 막을 수 있다.

매개변수의 유효성을 검사하기 전에 방저적복사본을 만들고, 이 복사본으로 유효성을 검사한 점에 주목하자.

멀티스레딩 환경이라면 원본 객체의 유효성을 검사한 후 복사본을 만드는 그 찰나의 취약한 순간에 다른 스레드가 원본 객체를 수정할 위험이 있다.

 

// 코드 50-3 수정한 생성자 - 매개변수의 방어적 복사본을 만든다. (304쪽)
public Period(Date start, Date end) {

    if (start.compareTo(end) > 0)
        throw new IllegalArgumentException(
                this.start + "가 " + this.end + "보다 늦다.");
                
    /*
     * 이때 다른 스레드에서 수정할 수 있다.
     *
     */
     
    this.start = new Date(start.getTime());
    this.end   = new Date(end.getTime());
               
}

 

 

방어적 복사에 Date의 clone 메서드를 사용하지 않은 점에도 주목하자. Date는 final이 아니므로 clone이 Date가 정의한 게 아닐 수 있다. 즉 clone이 Period 클래스를 확장한 하위 클래스를 반환할 수도 있다. 하위 클래스가 start와 end 필드의 참조를 private 정적 리스트에 담아뒀다가 공격자에게 이 리스트에 접근하는 길을 열어줄 수도 있다. 매개변수가 제 3자에 의해 확장될 수 있는 타입이라면 방어적 복사본을 만들 때 clone을 사용해서는 안된다.

 

생성자를 수정하면 앞선 공격을 막아낼 수 있지만 접근자 메서드가 내부의 가변 정보를 직접 드러내기 때문에 아직도 불안하다.

 

start = new Date();
end = new Date();
p = new Period(start, end);
p.end().setYear(78);  // p의 내부를 변경했다!
System.out.println(p);

=>
Wed Dec 27 20:40:07 KST 2023 - Wed Dec 27 20:40:07 KST 1978

 

두 번째 공격은 가변 필드의 방어적 복사본을 반환하면 된다.

// 코드 50-5 수정한 접근자 - 필드의 방어적 복사본을 반환한다. (305쪽)
public Date start() {
    return new Date(start.getTime());
}

public Date end() {
    return new Date(end.getTime());
}

 

방어적 복사에는 성능 저하가 따르고, 또 항상 쓸 수 있는 것도 아니다. 호출자가 컴포넌트 내부를 수정하지 않으리라 확신하면 방어적 복사를 생략할 수 있다. 이러한 상황이라도 호출자에서 해당 매개변수나 반환값을 수정하지 말아야 함을 명확히 문서화하는게 좋다.