DEV ℧ Developer Diary

[EffectiveJava] item50 - 적시에 방어적 복사본을 만들라

자바는 안전한 언어다.

네이티브 메서드를 사용하지않으니 C, C++ 같이 안전하지 않은 언어에서 흔히 보는 버퍼 오버런, 배열 오버런, 와일드 포인터 같은 메모리 충돌 오류에서 안전하다.

방어 프로그래밍

하지만 아무리 자바라 해도 다른 클래스로부터의 침범을 아무런 노력 없이 다 막을 수 있는건 아니다.

클라이언트가 여러분의 불변식을 깨뜨리려 혈안이 되어 있다고 가정하고 방어적으로 프로그래밍 해야 한다.

깨지는 불변식

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

예를 들어 기간(period)을 표현하는 다음 클래스는 한번 값이 정해지면 변하지 않도록 final로 선언이 되어있다.

기간을 표현하는 클래스 - 불변식을 지키지 못했다.

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

    public Period(Date start, Date end) {
        if (start.compareTo(end) > 0)
            throw new IllegalArgumentException(
                    start + "가 " + end + "보다 늦다.");
        this.start = start;
        this.end = end;
    }

    public Date getStart() {
        return start;
    }

    public Date getEnd() {
        return end;
    }
}

이 클래스는 불변처럼 보이고, 시작 시간이 종료시각보다 늦을 수 없다는 불변식이 무리 없이 지켜질 것 같다. 하지만 Date가 가변이라는 사실을 이용하면 어렵지 않게 그 불변식을 깨뜨릴 수 있다.

public static void main(String[] args) {
    Date start = new Date();
    Date end = new Date();

    Period period = new Period(start, end);
    System.out.println(period.getEnd());

    end.setYear(99);
    System.out.println(period.getEnd());
}
Sun Jun 18 19:18:46 KST 2023
Fri Jun 18 19:18:46 KST 1999

위에서 생성한 end 변수의 인스턴스 주소를 공유하기 때문인데, 각 주소를 출력하면 실제로 같은 것을 확인 할 수 있다.

System.out.println(System.identityHashCode(period.getEnd()));
System.out.println(System.identityHashCode(end));
period.end 주소 : 1349393271
end의 주소 : 1349393271

다행히 자바 8 이후로는 쉽게 해결할 수 있다. Date 대신 불변인 Instant를 사용하면 된다. 혹은 LocalDateTime이나 ZonedDateTime을 사용해도 된다.

Dae는 낡은 API 이니 새로운 코드를 작성할 때는 더 이상 사용하면 안된다.

깨지는 불변식의 보완

이제 위에서 외부의 공격으로부터 깨지는 불변식 Period 인스턴스에 대한 예시를 보완해보도록 하자.

생성자에서의 방어적 복사

첫번째로 생성자에서 받은 가변 매개변수 각각을 방어적으로 복사(defensive copy) 한다. Period 인스턴스 안에서는 원본이 아닌 복사본을 사용한다.

수정한 생성자 - 매개변수의 방어적 복사본을 만든다.

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 + "보다 늦다.");
}

이제 새로운 생성자를 사용하면 앞서의 공격은 더이상 Period 에 위협이 되지 않는다. 매개변수의 유효성을 검사(Item49)하기 전에 방어적 복사본을 만들고, 이 복사본으로 유효성을 검사한 점에 주목하자.

순서가 부자연스러워 보이지만 반드시 이렇게 작성해야한다. 멀티스레딩 환경이라면 원본 객체의 유효성을 검사한 후 복사본을 만드는 찰나의 취약한 순간에 다른 스레드가 원본 객체를 수정할 위험이 있기 때문이다.

방어적 복사를 매개변수 유효성 검사전에 수행하면 이런 위험에서 해방될 수 있다.

컴퓨터 보안 커뮤니티에서는 이를 검사시점/사용시점(time-of-check/time-of-use) 공격 혹은 영어 표기를 줄여 TOCTOU 공격이라고 한다.

방어적 복사를 사용할 때 Date의 객체를 clone 메서드를 사용하지 않은 점에도 주목하자. Date는 final이 아니므로 clone이 Date를 정의한게 아닐 수 있다.

즉 clone이 악의를 가진 하위 클래스의 인스턴스를 반환할 수도 있다.

매개변수가 제 3자에 의해 확장될 수 있는 타입이라면 방어적 복사본을 만들 때 clone을 사용해서는 안된다.

접근자 메서드에서의 방어적 복사

생성자를 수정하면 앞서의 공격은 막아낼 수 있지만, Period 인스턴스는 아직 변경이 가능하다.

접근자 메서드가 내부의 가변정보를 직접 드러내기 때문이다.

Date start = new Date();
Date end = new Date();

Period period = new Period(start, end);
System.out.println(period.getEnd());

period.getEnd().setYear(99); /** period의 내부를 변경했다. */
System.out.println(period.getEnd());
Sun Jun 18 19:40:09 KST 2023
Fri Jun 18 19:40:09 KST 1999

위와 동일하게 인스턴스의 주소를 공유하기 때문에 일어난 일이다.

수정한 접근자 - 필드의 방어적 복사본을 반환한다.

public Date getStart() {
    return new Date(start.getTime());
}

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

두번째 공격을 막아내려면 단순히 접근자가 가변 필드의 방어적 복사본을 반환하면 된다.

새로운 접근자까지 갖추면 Period는 완전한 불변으로 거듭난다. 모든 필드가 객체 안에 완벽하게 캡슐화 되었다.

생성자와 달리 접근자 메서드에서는 방어적 복사에 clone을 사용해도 된다. period가 가지고 있는 Date객체는 java.util.Date임이 확실하기 때문이다.

하지만 Item13에서 설명한 이유때문에 인스턴스를 복사하는 데는 일반적으로 생성자나 정적 팩터리를 쓰는게 좋다.

방어적 복사의 목적

매개변수를 방어적으로 복사하는 목적이 불변 객체를 만들기 위해서만은 아니다.

메서드든 생성자든 클라이언트가 제공한 객체의 참조를 내부의 자료구조에 보관해야 할 때면 항시 그 객체가 잠재적으로 변경될 수 있는지를 생각해야 한다.
변경될 수 있는 객체라면 그 객체가 클래스에 넘겨진 뒤 임의로 변경되어도 그 클래스가 문제없이 동작할지 따져봐야한다. 확신할 수 없다면 복사본을 만들어 저장해야 한다.

내부 객체를 클라이언트에 건네주기 전에 방어적 복사본을 만드는 이유도 마찬가지다.

클래스가 불변이든 가변이든, 가변인 내부 객체를 클라이언트에 반환할 때는 반드시 심사숙고 해야 한다. 안심할 수 없다면 방어적 복사본을 반환해야 한다.

두 방법 모두 Item15를 참고하면 된다.

방어적 복사 이전에 고려할 점

이상의 모든 작업에서 우리는 “되도록 불변 객체들을 조합해 객체를 구성해야 방어적 복사를 할 일이 줄어든다”는 교훈을 얻을 수 있다.

방어적 복사의 주의할 점

방어적 복사에는 성능 저하가 따르고, 또 항상 쓸 수 있는 것도 아니다. 호출자가 컴포넌트 내부를 수정하지 않으리라 확신하면 방어적 복사를 생략 할 수 있다.

이러한 상황이라도 호출자에서 해당 매개변수나 반환값을 수정하지 말아야 함을 명확히 문서화 해야 한다.