적시에 방어적 복사본을 만들라 - Effective Java[50]

5 minute read

🔗 클라이언트가 불변식을 깨뜨리려고 혈안이 되어있다고 가정하고 방어적으로 프로그래밍 하라
  • 자바는 안전한 언어다.
    • 네이티브 메서드를 사용하지 않으니 C, C++ 같이 안전하지 않은 언어에서 흔히 보는 버퍼 오버런, 배열 오버런, 와일드 포인터 같은 메모리 충돌 오류에서 안전하다.
  • 자바로 작성한 클래스는 시스템의 다른 부분에서 무슨 짓을 하든 그 불변식이 지켜진다.
    • 메모리 전체를 하나의 거대한 배열로 다루는 언어에서는 누릴 수 없는 강점이다.
  • 하지만 아무리 자바라 해도 다른 클래스로부터의 침범을 아무런 노력 없이 다 막을 수 있는건 아니다.
    • 그러니 클라이언트가 불변식을 깨뜨리려 혈안이 되어 있다고 가정하고 항상 방어적으로 프로그래밍 해야한다.

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

    • 하지만 주의를 기울이지 않으면 자기도 모르게 내부를 수정하도록 허락하는 경우가 흔히 발생한다.

    • ex) 아래의 기간(period)을 표현하는 다음 클래스는 한번 값이 정해지면 변하지 않도록 할 생각이었다.


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

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가 가변이라는 사실을 이용하면 아래와 같이어렵지 않게 그 불변식을 깨뜨릴 수 있다.


💎 Period 인스턴스의 내부를 공격해보자.

Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78); // p의 내부를 수정했다.!!
  • 다행히 자바 8이후로는 Date 대신 불변인 Instant를 사용하여 쉽게 해결 할 수 있다.(혹은 LocalDateTime이나 ZonedDateTime)

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

    • 하지만 앞으로 쓰지 않는다고 이 문제에서 해방되는 건 아니다.

      Date처럼 가변인 낡은 값 타입을 사용하던 시절이 워낙 길었던 탓에 여전히 많은 API와 내부 구현에 그 잔재가 남아 있다.


🔗 외부 공격으로부터 인스턴스 내부를 보호하려면?
  • 외부 공격으로부터 인스턴스 내부를 보호하려면 생성자에서 받은 가변 매개변수 각각을 방어적으로 복사(depensive copy)해야 한다.

  • 그 후에는 아래와 같이 인스턴스 안에서는 원본이 아닌 복사본을 사용한다.


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

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에 위협이 되지 않는다.

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

    • 순서가 부자연스러워 보이겠지만 반드시 이렇게 작성해야 한다.

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

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

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

  • 방어적 복사에 Dateclone 메서드를 사용하지 않은 점에도 주목하자

    • Datefinal이 아니므로 cloneDate가 정의한 게 아닐 수 있다.

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

      • ex) 이 Period의 하위 클래스는 startend필드의 참조를 private 정적 리스트에 담아뒀다가 공격자에게 이 리스트에 접근하는 길을 열어줄 수도 있다.

      • 결국 공격자에게 Period 인스턴스 자체를 송두리째 맡기는 꼴이 된다.

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


💎 생성자를 수정했음에도 Period 인스턴스가 아직도 변경이 가능하다고!?

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


💎 Period 인스턴스를 향한 두 번째 공격

Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
p.end().setYear(78); // p의 내부를 변경했다.
  • 위와 같은 공격을 막아내려면 단순히 접근자가 가변 필드의 방어적 복사본을 반환하면 된다.


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

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

public Date end() {
	return new Date(end.getTime());
}
  • 이렇게 새로운 접근자까지 갖추면 Period는 완벽한 불변으로 거듭난다.
    • 아무리 악의적인 혹은 부주의한 프로그래머라도 시작 시각이 종료 시각보다 나중일 수 없다는 불변식을 위배할 방법은 없다.(네이티브 메서드나 리플렉션 같이 언어 외적인 수단을 동원하지 않고는)
  • Period 자신 말고는 가변 필드에 접근할 방법이 없으니 확실하다.
    • 모든 필드가 객체 안에 완벽하게 캡슐화 되었다.

🔗 접근자 메서드에서는 방어적 복사에 clone을 사용해도 된다.
  • 생성자와 달리 접근자 메서드에서는 방어적 복사에 clone을 사용해도 된다.
    • Period가 가지고 있는 Date 객체는 java.util.Date임이 확실하기 떄문이다.(신뢰 할 수 없는 하위 클래스가 아니다.)
  • 그렇더라도 인스턴스를 복사하는데는 일반적으로 생성자나 정적 팩터리르 쓰는게 좋다.

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

    • 메서드든 생성자든 클라이언트가 제공한 객체의 참조를 내부의 자료구조에 보관해야 할 때면 항시 그 객체가 잠재적으로 변경될 수 있는지를 생각해야 한다.

    • 변경될 수 있는 객체라면 그 객체가 클래스에 넘겨진 뒤 임의로 변경되어도 그 클래스가 문제없이 동작할지를 따져보라.

    • 확신할 수 없다면 복사본을 만들어 저장해야 한다.

      • ex) 클라이언트가 건네준 객체를 내부의 Set 인스턴스에 저장하거나 Map 인스턴스의 키로 사용한다면, 추후 그 객체가 변경될 경우 객체를 담고 있는 Set 혹은 Map의 불변식이 깨질 것이다.
  • 내부 객체를 클라이언트에 건네주기 전에 방어적 복사본을 만드는 이유도 마찬가지다.

    • 클래스가 불변이든 가변이든, 가변인 내부 객체를 클라이언트에 반환할 때는 반드시 심사수고해야 한다.

      • 안심 할 수 없다면 (원본을 노출하지 말고) 방어적 복사본을 반환해야 한다.
    • 길이가 1이상인 배열은 무조건 가변임을 잊지 말자.

    • 그러니 내부에서 사용하는 배열을 클라이언트에 반환할 때는 항상 방어적 복사를 수행해야 한다.

    • 혹은 배열의 불변 뷰를 반환하는 대안도 있다.


💎 되도록 불변 객체들을 조합해 객체를 구성해야 방어적 복사를 할 일이 줄어든다
  • Period의 예제의 경우, 자바 8 이상으로 개발해도 된다면 Instant(혹은 LocalDateTime이나 ZonedDateTime)를 사용하라.
    • 이전 버전의 자바를 사용한다면 Date 참조 대신 Date.getTime()이 반환하는 long 정수를 사용하는 방법을 써도 된다.

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

💎 가변 매개변수를 항상 방어적으로 복사해 저장해야 하는 것은 아니다.
  • 다른 패키지에서 사용한다고 해서 넘겨받은 가변 매개변수를 항상 방어적으로 복사해 저장해야 하는 것은 아니다.

    • 때로는 메서드나 생성자의 매개변수로 넘기는 행위그 객체의 통제권을 명백히 이전함을 뜻하기도 한다.

    • 이처럼 통제권을 이전하는 메서드를 호출하는 클라이언트는 해당 객체를 더 이상 직접 수정하는 일이 없다고 약속해야 한다.

    • 클라이언트가 건네주는 가변 객체의 통제권을 넘겨받는다고 기대하는 메서드나 생성자에서도 그 사실을 확실히 문서에 기재해야 한다.

  • 통제권을 넘겨받기로 한 메서드나 생성자를 가진 클래스들은 악의적인 클라이언트의 공격에 취약하다.

  • 따라서 방어적 복사를 생략해도 되는 상황은 해당 클래스와 그 클라이언트가 상호 신뢰할 수 있을 때, 혹은 불면식이 깨지더라도 그 영향이 오직 호출한 클라이언트로 국한될 때로 한정해야 한다.

    • ex) 래퍼 클래스 패턴의 특성상 클라이언트는 래퍼에 넘긴 객체에 여전히 접근할 수 있다.

    • 따라서 래퍼의 불변식을 쉽게 파괴할 수 있지만 그 영향을 오직 클라이언트 자신만 받게 된다.


클래스가 클라이언트로부터 받는 혹은 클라이언트로 반환하는 구성요소가 가변이라면 그 요소는 반드시 방어적으로 복사해야 한다.

복사 비용이 너무 크거나 클라이언트가 그 요소를 잘못 수정할 일이 없음을 신뢰한다면 방어적 복사를 수행하는 대신 해당 구성요소를 수정했을 때의 책임이 클라이언트에 있음을 문서에 명시하도록 하자.

참조 - 이펙티브 자바 3/E - 조슈아 블로크때

Categories:

Updated:

Comments