Language/Java

[effective java] 아이템 50. 적시에 방어적 복사본을 만들라.

JOYERIM 2023. 7. 17. 07:15

 

 

 

 

 

 

 

 

 

 

핵심 요약

 

해당 아이템은 클라이언트로부터 받은 가변 매개변수와 클라이언트에게 반환하는 데이터를 보호하는 방법에 대해 다룬다. 이는 자바 클래스가 불변성을 유지하기 위해 필요한 기법이라고 볼 수 있다.

 

클래스가 클라이언트로부터 받은 가변 매개변수에 대해 방어적 복사를 수행하면, 클래스를 보호할 수 있다. 즉, 클래스 내부에서 사용하는 데이터는 외부에 노출되지 않도록 보호해야 한다.

 

클래스가 클라이언트에게 데이터를 반환할 때도 방어적 복사를 수행해야 한다. 클래스 내부의 데이터를 직접 반환하면 클라이언트에 의해 변경될 수 있기 때문이다.

 

또한, 방어적 복사는 성능 저하를 가져올 수 있으므로 항상 필요한 경우에만 사용해야 한다. 만약 클래스와 클라이언트 사이에 확실한 신뢰가 잇거나, 복사 없이도 불변성을 유지할 수 있는 경우에는 복사를 생략할 수 있다.

 

클라이언트에게 객체를 반환할 때 방어적 복사를 하지 않는다면, 이를 명확히 문서화해야 한다. 이를 준수하지 않을 경우 발생할 수 있는 문제에 대한 책임을 클라이언트에게 있게 하기 위함이다.

 

불변 객체를 사용하면 방어적 복사의 필요성이 줄어들게 된다. Java 8 이상에서는 Date 대신 Instant나 LocalDateTime/ZonedDateTime 같은 불변 타입을 사용할 수 있다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

자바는 안전한 언어다.

 

자바는 C/C++과 같이 메모리 접근을 직접 제어하는 언어와 달리, JVM이 메모리를 관리해주므로 버퍼 오보런, 배열 오버런, 와일드 포인터와 같은 메모리 충돌 오류를 자동으로 방지한다. 이를 통해 코드의 안전성이 보장된다.

 

네이티브 메소드
더보기
네이티브 메소드는 자바 이외의 언어(ex. C, C++)로 작성된 메소드를 말한다.

 

그러나 외부 클라이언트가 불변식을 깨트릴 수 있는 가능성이 있으므로 방어적으로 프로그래밍하는 것이 중요하다.

 

 

 

 

 

 

 

 

 

 

 

불변식을 지키지 못하는 경우
예시 : 기간을 표현하는 클래스

 

 

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;
    }
    ...
}

 

Date 클래스가 가변이므로 불변식이 깨질 수 있다.

 

// 공격 예시 1
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78);  // p의 내부를 변경

 

해결 방법 1. Date 클래스 대신 Instant 클래스(혹은 LocalDateTime/ZonedDateTime)을 사용

 

해당 방법은 Java 8 이후에만 해결 가능하다.

 

네이티브 메소드
더보기
LocalDateTime에는 불변을 해치는 set 메소드가 없다. 또한, 생성자는 private로 선언해두고 of() 정적 팩토리 메소드를 제공한다.

 

해결 방법 2. 매개변수의 방어적 복사본 생성

 

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

 

// 수정한 생성자 - 매개변수의 방어적 복사본 생성
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 + "보다 늦다.");
}

 

 

주의할 점이 두 가지 있다.

 

1. 매개변수 유효성 검사(item 49) 전에 방어적 복사본 생성

이는 특히 다중 스레드 환경에서 중요하다. 복사본을 만든 후 유효성 검사를 수행하는 이유는, 유효성 검사와 복사본 생성 사이의 시간 차이로 인해 다른 스레드가 원본 객체를 수정하는 시간 차 공격(time-of-check to time-of-use, TOCTOU)를 방지하기 위함이다. 만약 유효성 검사를 먼저 수행하고 나서 복사본을 생성한다면, 유효성 검사 후에 다른 스레드가 원본 객체를 변경하여 유효하지 않은 상태로 만들 수 있다. 그렇게 되면 복사본은 유효하지 않은 상태를 가지게 된다. 그래서 일반적으로 복사본을 먼저 생성하고, 그 복사본으로 유효성 검사를 수행한다.

2. 확장 가능 타입에 대한 clone() 사용 금지

매개변수가 확장될 수 있는 타입인 경우, 방어적 복사본을 만들 때 clone() 메서드를 사용하면 안 된다. clone() 메서드는 원본 객체와 같은 클래스의 인스턴스를 반환하기 때문이다. 이런 클래스는 상속이 가능하므로, clone()의 결과로 반환되는 객체는 원본 클래스가 아닌 하위 클래스의 인스턴스가 될 수 있다. 만약 이 하위 클래스가 악의적인 코드를 포함하고 있다면, 이를 통해 객체의 보안이 침해될 수 있다. 따라서 제 3자에 의해 확장 가능한 타입에 대해선 clone()을 사용하는 대신 다른 방식으로 복사본을 생성해야 한다. 예를 들어, 생성자나 정적 팩토리 메서드를 이용해 새로운 인스턴스를 명시적으로 생성하는 방법이 있다.

 

하지만 여전히 Period 인스턴스는 변경 가능하다. 

 

// 공격 예시 2
Date start = new Date();
Date end = new Date();
p = new Period(start, end);
p.end().setYear(78);  // p의 내부를 변경

 

해결 방법 3. 필드의 방어적 복사본을 반환

 

이를 해결하기 위해 getter 메소드와 같은 접근자가 가변 필드의 방어적 복사본을 반환하도록 변환하면 된다.

 

public final class Period {
    ...
    public Date start() {
        return new Date(start.getTime());
    }

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

 

이제 Period  클래스는 완전한 불변이 되었다.

 

추가적으로 주의해야 할 내용을 알아보자.

 

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

생성자와 달리, 접근자 메서드는 클래스 내부의 상태를 외부로 반환한다. 외부에서 이 상태를 변경하더라도 클래스 내부 상태에는 영향을 끼치지 않도록 하기 위해, 반환할 상태의 복사본을 생성해서 반환하는 것이 일반적이다. 이때, 반환하려는 객체가 신뢰할 수 없는 하위 클래스의 인스턴스가 아니라는 것이 확실하다면, clone()을 사용해 복사본을 생성해도 안전하다. 그러나 생성자나 정적 팩토리 메서드를 사용하는 것이 더 안전하며, 코드의 의도를 명확히 표현하는 데도 더 도움이 된다.
(ex. Period가 가지고 있는 Date 객체는 신뢰할 수 없는 하위 클래스가 아니므로 clone()을 사용해도 된다.)

2. 방어적 복사의 목적

방어적 복사의 주요 목적은 불변 객체를 만드는 것 이외에도 다른 중요한 이유가 있다. 클래스가 클라이언트로부터 제공받은 객체의 참조를 내부 자료구조에 저장해야 할 경우, 그 객체가 변할 가능성이 있는지 확인해야 한다. 만약 그 객체가 변경될 수 있다면, 객체가 클래스로 넘겨진 후에 변경되더라도 클래스가 올바르게 작동할 수 있는지 확인해야 한다.

예를 들어, 클라이언트가 제공한 객체를 내부의 Set 인스턴스에 저장하거나 Map 인스턴스의 키로 사용한다고 가정해보자. 만약 이 객체가 나중에 변경된다면, 그 객체를 담고 있는 Set이나 Map의 불변성이 깨질 수 있다. 이런 경우, 객체를 저장하기 전에 방어적 복사를 수행하여 클래스 내부에 저장된 객체가 외부에서 변경되는 것을 방지해야 한다. 이렇게 하면 클래스의 불변성을 유지하면서도 클라이언트로부터 제공받은 객체를 안전하게 사용할 수 있다.


3. 방어적 복사의 필요성 감소

객체가 불변 객체들로만 구성되어 있다면, 그 객체의 복사본을 만드는 방어적 복사의 필요성이 줄어든다. 예를 들어, Java 8 이상에서는 Date 대신 Instant나 LocalDateTime/ZonedDateTime같은 불변 타입을 사용할 수 있다. 또는 Date.getTime() 메소드를 이용해 long형 정수를 반환하여 사용할 수 있다. 이런 식으로 불변 타입을 사용하면, 내부 상태가 변경될 위험을 피할 수 있으므로 방어적 복사의 필요성이 감소한다.

4. 문서화의 중요성

만약 클래스가 클라이언트로부터 받은 객체를 방어적 복사하지 않고, 그대로 사용한다면 이 사실을 문서화해야 한다. 이 경우, 클라이언트는 해당 객체를 수정하면 안된다는 것을 알게 되며, 이를 준수하지 않을 경우 발생할 수 있는 문제에 대한 책임이 클라이언트에게 있게 된다.

5. 클라이언트와의 신뢰가 있을 경우 방어적 복사를 생략할 수 있다.

만약 클래스와 클라이언트가 서로 신뢰할 수 있다면, 혹은 클래스의 불변성이 깨지더라도 그 영향이 클라이언트에게만 국한될 경우에는 방어적 복사를 생략할 수 있다. 예를 들어, 래퍼 클래스 패턴을 사용한 경우, 클라이언트는 래퍼에게 전달한 객체에 직접 접근할 수 있다. 만약 클라이언트가 이 객체를 변경하면 래퍼 클래스의 불변성이 깨질 수 있지만, 이로 인한 문제는 클라이언트에게만 국한된다. 그러나 클래스의 불변성을 깨뜨리는 것은 절대로 허용되지 않는다. 이러한 주의사항들을 이해하고 지키는 것은, 클래스의 안정성과 불변성을 유지하면서 클라이언트와의 상호작용을 효율적으로 관리하는데 매우 중요하다.

 

❔ 래퍼 클래스 패턴(item 18)
더보기
한 클래스의 기능을 확장하거나 수정하기 위해 해당 클래스를 감싸는 새로운 클래스를 생성하는 디자인 패턴을 말한다. 이 패턴은 래퍼 클래스가 기존 클래스를 포함하고, 추가적인 기능을 제공하거나 기존 기능을 변경하여 사용한다.

래퍼 클래스 패턴에서는 주의해야 할 점이 있다. 래퍼 클래스의 클라이언트가 래퍼에게 전달한 객체에 대한 참조를 가지고 있다면, 그 클라이언트는 여전히 전달한 객체를 직접 변경할 수 있다. 만약 클라이언트가 이를 변경하면, 래퍼 클래스가 가지고 있는 객체의 상태가 변경되어 래퍼 클래스의 불변성이 깨질 수 있다.

따라서, 클라이언트가 전달한 객체를 변경하더라도 래퍼 클래스의 불변성이 깨지지 않도록 방어적 복사를 통해 객체의 복사본을 저장하거나, 불변 객체를 사용해야 한다. 만약 클라이언트가 전달한 객체를 변경하여 문제가 생겨도, 그 문제는 클라이언트에게만 국한되며 래퍼 클래스나 다른 클라이언트에는 영향을 미치지 않아야 한다.