-
[Effective Java] 적시에 방어적 복사본을 만들라Java 2023. 2. 3. 10:28
자바는 안전한 언어다.
- 네이티브 메서드를 사용하지 않으니, C, C++ 같이 안전하지 않은 언어에서 흔히 보는 버퍼 오버런, 배열 오버런, 와일드 포인터 같은 메모리 충돌 오류에서 안전하다.
- 자바로 작성한 클래스는 시스템의 다른 부분에서 무슨 짓을 하든 그 불변식이 지켜진다.
불변식을 깨드리는 예시
하지만 아무리 자바라 해도 다른 클래스로부터의 침범을 아무런 노력 없이 다 막을 수 있는 건 아니다.
그러니 클라이언트가 여러분의 불변식을 깨뜨리려 혈안이 되어 있다고 가정하고 방어적으로 프로그래밍해야 한다. (평범한 프로그래머도 순전히 실수로 여러분의 클래스를 오작동하게 만들 수 있다.)어떤 객체든 그 객체의 허락 없이는 외부에서 내부를 수정하는 일은 불가능 하지만, 주의를 기울이지 않으면 자기도 모르게 내부를 수정하도록 허락하는 경우가 생긴다.
예) 기간을 표현하는 클래스
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 start() { return start; } public Date end() { return end; } }
얼핏 이 클래스는 불변처럼 보이고, 시작 시각이 종료 시각보다 늦을 수 없다는 불변식이 무리 없이 지켜질 것 같다.
하지만 Date가 가변이라는 사실을 이용하면 어렵지 않게 그 불변식을 깨뜨릴 수 있다.Period 인스턴스의 내부를 공격해보자.
public final class Period { // ... 코드 생략 public static void main(String[] args) { Date start = new Date(); Date end = new Date(); Period period = new Period(start, end); end.setYear(78); // period의 내부를 수정했다! } }
- 다행히 자바 8 이후로는 쉽게 해결할 수 있다.
- Date 대신 불변인 Instant를 사용하면 된다. (혹은 LocalDateTime 이나 ZonedDateTime을 사용해도 된다.)
- Date는 낡은 API이니 새로운 코드를 작성할 때는 더 이상 사용하면 안된다.
- 외부 공격으로부터 Period 인스턴스의 내부를 보호하려면 생성자에서 받은 가변 매개변수 각각을 방어적으로 복사해야 한다.
- 그런 다음 Period 인스턴스 안에서는 원본이 아닌 복사본을 사용한다.
수정한 생성자 - 매개변수의 방어적 복사본을 만든다.
public final class Period { private final Date start; private final Date end; public Period(Date start, Date end) { this.start = new Date(start.getTime()); //객체를 새로 만들어서 대입했다. this.end = new Date(end.getTime()); if (start.compareTo(end) > 0) { throw new IllegalArgumentException(start + "가 " + end + "보다 늦다."); } } // 생략 }
- 매개변수의 유효성을 검사하기 전에 방어적 복사본을 만들고, 이 복사본으로 유효성을 검사했다.
- 순서가 부자연스러워 보일 수 있으나, 반드시 이렇게 작성해야 한다.
- 멀티스레딩 환경이라면 원본 객체의 유효성을 검사한 후 복사본을 만드는 그 찰나의 취약한 순간에 다른 스레드가 원본 객체를 수정할 위험이 있기 때문이다.
- 방어적 복사를 매개변수 유효성 검사 전에 수행하면 이런 위험에서 해방될 수 있다. 컴퓨터 보안 커뮤니티에서는 이를 검사시점/사용시점(time-of-check/time-of-use) 공격 혹은 TOCTOU 공격이라 한다.
- Date는 final이 아니므로 clone이 Date가 정의한 게 아닐 수 있다. 즉, clone이 악의를 가진 하위 클래스의 인스턴스를 반환할 수도 있다. 따라서 Date의 clone 메서드를 사용하지 않았다.
- 매개변수가 제3자에 의해 확장될 수 있는 타입이라면 방어적 복사본을 만들 때 clone을 사용해서는 안 된다.
Period 인스턴스를 향한 접근자 메서드 공격
public final class Period { public static void main(String[] args) { Date start = new Date(); Date end = new Date(); Period period = new Period(start, end); period.end().setYear(78); // period의 내부를 변경했다! } }
- 접근자 메서드가 내부의 가변 정보를 직접 드러내기 때문에 Period 인스턴스는 아직도 변경이 가능하다.
- 이 공격을 막아내려면 단순히 접근자가 가변 필드의 방어적 복사본을 반환하면 된다.
수정한 접근자 - 필드의 방어적 복사본을 반환한다.
public final class Period { public Date start() { return new Date(start.getTime()); } public Date end() { return new Date(end.getTime()); } }
새로운 접근자까지 갖추면 Period는 완벽한 불변으로 거듭난다.
- 생성자와 달리 접근자 메서드서는 방어적 복사에 clone을 사용해도 된다.
- Period가 가지고 있는 Date 객체는 java.util.Date임이 확실하기 때문이다. (신뢰할 수 없는 하위 클래스가 아니다.)
- 그렇더라도 인스턴스를 복사하는 데는 일반적으로 생성자나 정적 팩터리를 쓰는 게 좋다
- 클래스가 불변이든 가변이든, 가변인 내부 객체를 클라이언트에 반환할 때는 반드시 심사숙고해야 한다. 안심할 수 없다면 (원본을 노출하지 말고) 방어적 복사본을 반환해야 한다.
- 길이가 1이상인 배열은 무조건 가변이다. 따라서 내부에서 사용하는 배열을 클라이언트에 반환할 떄는 항상 방어적 복사를 수행해야 한다.
- 자바 8 이상에서는 Instant(혹은 LocalDateTime이나 ZonedDateTime)를 사용하도록 한다
방어적 복사의 생략
방어적 복사에는 성능 저하가 따르고, 또 항상 쓸 수 있는 것도 아니다. (같은 패키지에 속하는 등의 이유로) 호출자가 컴포넌트 내부를 수정하지 않으리라 확신하면 방어적 복사를 생략할 수 있다.
- 호출자에서 해당 매개변수나 반환값을 수정하지 말아야 함을 명확히 문서화하는 게 좋다.
- 방어적 복사를 생략해도 되는 상황은 해당 클래스와 그 클라이언트가 상호 신뢰할 수 있을 때, 혹은 불변식이 꺠지더라도 그 영향이 오직 호출한 클라이언트로 국한될 때로 한정해야 한다.
핵심 정리
- 클래스가 클라이언트로부터 받는 혹은 클라이언트로 반환하는 구성요소가 가변이라면 그 요소는 반드시 방어적으로 복사해야 한다.
- 복사 비용이 너무 크거나 클라이언트가 그 요소를 잘못 수정할 일이 없음을 신뢰한다면 방어적 복사를 수행하는 대신 해당 구성요소를 수정했을 때의 책임이 클라이언트에 있음을 문서에 명시하도록 하자.
'Java' 카테고리의 다른 글
[Effective Java] 박싱된 기본 타입보다는 기본 타입을 사용하라 (0) 2023.02.03 [Effective Java] 정확한 답이 필요하다면 float과 double은 피하라 (0) 2023.02.03 [Effective Java] 매개변수가 유효한지 검사하라 (0) 2023.02.03 [Effective Java] 스트림 병렬화는 주의해서 적용하라 (0) 2023.02.03 [Effective Java] 반환 타입으로는 스트림보다 컬렉션이 낫다 (0) 2023.02.03