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

@023· February 13, 2025 · 10 min read

자바는 안전한 언어다

자바는 네이티브 메서드를 사용하지 않기 때문에 버퍼 오버런, 배열 오버런, 와일드 포인터 같은 메모리 충돌 오류에서 비교적 안전하다. 또한 자바로 작성한 클래스는 시스템의 다른 부분에서 무슨 짓을 하든 기본적으로 불변성을 유지할 수 있도록 설계되었지만, 모든 경우에 자동으로 보호되는 것은 아니다.

왜 방어적으로 프로그래밍 해야할까?

하지만 책에서는 자바가 안전한 언어라고 해도 방어적으로 프로그래밍을 해야한다고 강조하고 있다. 아무리 자바라고 해도 다른 클래스로부터의 침범을 아무런 노력없이 막을 수 있는 것은 아니다. 실제로도 악의적인 의도를 가진 사람들이 시스템의 보안을 뚫으려는 시도가 늘고 있다. 심지어 평범한 프로그래머도 순전히 실수로 클래스를 오작동하게 만들 수 있다. 그래서 책에서는 클라이언트가 불변식을 깨뜨리려 혈안이 되어 있다고 가정하고 방어적으로 프로그래밍 해야한다고 말하고 있다.

객체가 자기도 모르게 내부를 수정하도록 허락하는 경우

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

생성자를 이용한 방어 파훼

'시작 시각이 종료 시각보다 늦을 수 없다는 불변식'이 있는 다음과 같은 클래스가 있다고 하자.

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 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가 가변이라는 사실을 이용하면 어렵지 않게 불변식을 깰 수 있다.

Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78); // p의 내부를 수정했다!!

자바 8 이후로는 Date 대신 LocalDateTime 또는 ZonedDateTime 같은 불변인 Instant를 사용해서 쉽게 해결할 수 있지만, 앞으로 쓰지 않는다고 이 문제에서 해방되는 건 아니다.

실전에서는 Date 같은 낡은 API를 새로운 코드를 작성할 떄는 더 이상 사용하면 안되지만, Date 처럼 가변인 낡은 값 타입을 사용하던 시절이 길었던 탓에 여전히 많은 API와 내부 구현에 그 잔재가 남아 있다.

이 부분은 이러한 낡은 코드들을 대처하기 위한 것이다.

다시 돌아가서 외부 공격으로부터 Period 인스턴스의 내부를 보호하려면 생성자에서 받은 가변 매개변수 각각을 방어적으로 복사(defensive 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 인스턴스 안에서는 원본이 아닌 복사본을 사용하면 된다.

이때, 매개변수의 유효성을 검사하기 전에 방어적 복사본을 만들고, 이 복사본으로 유효성을 검사한 점에 주목해보면, 순서가 부자연스러워 보이겠지만 반드시 이렇게 작성해야 한다. 멀티스레딩 환경이라면 원본 객체의 유효성을 검사한 후 복사본을 만드는 그 찰나의 취약한 순간에 다른 스레드가 원본 객체를 수정할 위험이 있기 때문이다.

하나 더 주의할 점이 있는데 방어적 복사에 Dateclone 메서드를 사용하지 않은 점이다. Datefinal이 아니므로 cloneDate가 정의한 게 아닐 수 있다. 즉, clone이 악의를 가진 하위 클래스의 인스턴스를 반환할 수도 있기 때문이다.

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

접근자를 이용한 방어 파훼

이렇게 하면 앞서의 공격을 막아낼 수 있지만, 아직 안전하지 않다. 접근자 메서드가 내부의 가변 정보를 직접 드러내서 Period 인스턴스 변경이 가능하기 때문이다.

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

이를 막아내려면 단순히 접근자가 가변 필드의 방어적 복사복을 반환하면 된다.

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

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

이렇게 새로운 접근자까지 갖추면 Period는 자신 말고는 가변 필드에 접근할 방법이 없고, 모든 필드가 객체 안에 완벽히 캡슐화된 완벽한 불변이 된다. 이때, Period가 가지고 있는 Date 객체가 java.util.Date 임이 확실하기 때문에, 생성자와 달리 접근자 메서드에서는 방어적 복사에 clone을 사용해도 된다. 그렇지만 아이템 13에서 설명했듯이, 인스턴스 복사에는 이러한 방법보다는 일반적으로 생성자나 정적 팩터리를 쓰는 게 좋다.

어떻게 사용해야 할까?

앞서 살펴본 내용을 통해 방어적 복사본을 생성자와 접근자를 처리할 때만 만든다고 생각하지 않았으면 한다. 메서드든 상성자든 클라이언트가 제공한 객체의 참조를 내부의 자료구조에 보관해야 할 때면 항시 그 객체가 잠재적으로 변경될 수 있는지를 생각해야 한다. 내부 객체를 클라이언트에 건네주기 전에 방어적 복사본을 만드는 이유도 마찬가지로 클래스가 불변이든 가변이든, 가변인 내부 객체를 클라이언트에 반환할 때는 반드시 심사숙고해야 한다.

하지만 방어적 복사에는 성능 저하가 따르고, 또 항사 쓸 수 있는 것은 아니다. 같은 패키지에 속하는 등의 이유로 호출자가 컴포넌트 내부를 수정ㅇ하지 않으리라 확신하면 방어적 복사를 생략할 수 있지만, 이 경우 호출자에서 해당 매개변수나 반환값을 수정하지 말아야 함을 명확히 문서화하는 게 좋다. 또한 다른 패키지에서 사용한다고 해서 넘겨 받은 매개변수를 항상 방어적으로 복사해 저장해야한 것도 아니다. 이 경우에 대해서도 마찬가지로 문서화를 통해 사용자에게 알려주는 것이 좋다.

정리

클래스가 클라이언트로부터 받는 혹은 클라이언트로 반환하는 구성요소가 가변이라면 그 요소는 반드시 방어적으로 복사해야 한다. 복사 비용이 너무 크거나 클라이언트가 그 요소를 잘못 수정할 일이 없을을 신뢰한다면, 방어적 복사를 수행하는 대신 해당 구성 요소를 수정했을 때의 책임이 클라이언트에 있음을 문서에 명시하도록 하자.

@023
focus and hustle