자바라 해도 다른 클래스로부터 침범을 다 막을 수 있는 것은 아니다.
악의적 의도로 시스템 보안을 뚫으려할 수도 있지만, 프로그래머의 실수로 클래스를 오작동하게 만드는 경우가 흔하다.
따라서, 클라이언트가 불변식을 깨뜨리려 한다는 것을 가정하고 방어적으로 프로그래밍해야한다.
불변식을 지키지 못한 기간 표현 클래스
객체의 허락 없인 외부에서 내부를 수정하는 일은 불가능하다. 하지만 가능한 경우의 예를 살펴보자.
아래 예는 java.time 패키지의 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;
}
...
}
생성자에서만 start, end의 값을 지정할 수 있어 불변처럼 보이고 시작 시각이 종료 시각보다 늦을 수 없으니 불변식같다.
하지만 Date가 가변이라는 것을 이용해 다음과 같이 객체 내부의 값을 변경할 수 있다.
// new Date()를 호출하면, 시스템 호출시간을 기준으로 날짜가 지정됨
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
// 객체 생성 후 종료일의 년도를 78년으로 수정이 허용된다.
end.setYear(78);
// 객체 내부의 값이 바뀌었다.
// 출력: Wed Nov 10 21:04:53 KST 2021 - Fri Nov 10 21:04:53 KST 1978
System.out.println(p);
자바 8부터는 Date 대신 불변인 Instant, LocalDateTime, ZonedDateTime을 사용하면 된다.
따라서 Date는 낡은 API니 새로운 코드를 작성할 때 더 이상 사용하면 안되지만, Date처럼 가변인 낡은 값 타입을 사용하던 시절이 길어 많은 API와 내부 구현에 남아있다. 따라서 이런 낡은 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 + "보다 늦다.");
}
이상한 점이 있다. 매개변수의 유효성을 검사하기 전에 방어적 복사본을 만들었고 이 복사본으로 유효성을 검사한다.
멀티스레딩 환경이라면 원본 객체의 윻성을 검사한 후 복사본을 만드는 찰나에 다른 스레드가 원본 객체를 수정할 수 있다.
어떻게 가능한지 테스트해보기 위해 자바봄의 코드를 참고했다.
public final class Period {
public Period(Date start, Date end, CountDownLatch cd1, CountDownLatch cd2) {
if (start.compareTo(end) > 0)
throw new IllegalArgumentException(start + "가 " + end + "보다 늦다.");
cd1.countDown();
try {
// end의 기간이 1970년으로 초기화되기 전까지 대기
cd2.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
}
...
}
//테스트코드
class PeriodTest {
Period period = null;
@Test
public void attacksMultiThread() throws Exception {
Date start = new Date();
Date end = new Date();
CountDownLatch cd1 = new CountDownLatch(1);
CountDownLatch cd2 = new CountDownLatch(1);
CountDownLatch cd3 = new CountDownLatch(1);
new Thread(() -> {
period = new Period(start, end, cd1,cd2);
cd3.countDown();
}).start();
// Period 생성자에서 검증이 끝나면 통과
cd1.await();
// 검증이 끝나고 기간 변경
end.setTime(1);
//객체가 만들어지기까지 대기
cd2.countDown();
cd3.await();
// 시작일은 종료일 이전이 아님
assertFalse(period.start().before(period.end()));
}
}
다음과 같이 공격이 가능했고, 이를 검사시점/사용시점(time-of-check / time-of-use) 공격, 혹은 TOCTOU 공격이라고 한다.
참고:
[아이템 50] 멀티스레드 방어복사 · Issue #121 · Java-Bom/ReadingRecord
304p 첫번째 예제에서 유효성 검사 먼저할때 멀티스레드 환경에서 취약할 수 있는데, 해당 예제를 짜서 보여주면 좋을거같아
github.com
방어적 복사에 Date의 clone 메소드를 사용하지 않은 이유는 뭘까?
Date는 final이 아니기 때문에 상속이 가능하다. 즉 clone이 악의를 가진 하위 클래스의 인스턴스를 반환할 수도 있다.
아이템 13에 따르면 정상적으로 호출했다면 x != x.clone() 이지만, 하위 클래스에서 상위 클래스의 clone을 호출하지 않고 엉뚱한 인스턴스를 반환할 수 있다. 하위클래스가 start와 end 필드 참조를 private static 리스트에 담아둘 경우 공격자가 리스트에 접근하게할 수 있다.
따라서 참조가 static에 저장돼있기 때문에 언제든 객체 내부 값에 접근을 할 수 있다. getter에서 방어적 복사를 했더라도 참조객체를
물론 clone에서 이미 올바르지 않은 객체를 반환해서 static 리스트에 담아둘 필요가 있을까 생각했지만, getter에서 방어적으로 복사를 했더라도 이 static 리스트의 값이 곧 Period 내부 Date 객체이니까 이 값을 변경시키면 전부 변경된다.
// Date의 하위 클래스
public class DateChild extends Date implements Cloneable{
private static List<Date> dates = new ArrayList<>();
// 상위 클래스의 clone 메소드를 호출하지 않고 악의적으로 자신을 반환
@Override
public DateChild clone() {
return this;
}
public void addDates(Date start, Date end){
dates.add(start);
dates.add(end);
}
public static List<Date> getAttackDates() {
return dates;
}
}
// Period
public final class Period {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
this.start = (Date) start.clone();
this.end = (Date)end.clone();
if (start.compareTo(end) > 0)
throw new IllegalArgumentException(start + "가 " + end + "보다 늦다.");
}
...
}
// 테스트코드
@Test
public void attacks() {
DateChild startChild = new DateChild();
Date end = new Date();
// 시작일을 하위클래스로 지정하고, static 리스트에 값 저장
startChild.addDates(startChild,end);
Period period = new Period(startChild, end);
List<Date> attackDates = DateChild.getAttackDates();
//static 리스트의 값을 변경
attackDates.get(0).setTime(1);
// 객체가 변경되어 참조하고 있는 모든 date가 시간이 변경
// 전부 1970년을 반환
System.out.println("period.start() = " + period.start());
System.out.println("start = " + startChild);
System.out.println("attackDates = " + attackDates.get(0));
}
따라서 매개변수가 제3자에 의해 확장될 수 있는 타입이라면 방어적 복사본을 만들 때 clone을 사용하면 안된다.
이렇게 생성자를 수정하면 공격은 막아낼 수 있지만 Period 인스턴스는 아직 변경 가능하다.
여전히 반환되는 Date 객체에 setter를 통해서 내부 값을 변경할 수 있기 때문이다.
이 공격은 단순히 가변 필드의 방어적 본사본을 반환하면 된다.
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
네이티브 메소드나 리플렉션 같이 언어 외적인 수단을 동원하지 않고는 Period 자신 말고 가변 필드에 접근할 방법이 없어졌고 모든 필드가 객체 안에 완벽히 캡슐화됐고 불변이다. 생성자에서 신뢰할 수 없는 하위 클래스가 아닌 Date 생성자로 생성했으니 접근자 메소드에 clone을 사용해도 되지만 인스턴스 복사에는 생성자나 정적팩토리가 낫다.[아이템 13참조]
매개변수 방어적 복사 목적
매개변수 방어적 복사 목적이 불변 객체를 만들기 위함만은 아니다.
1. 외부 객체가 내부 자료구조에 저장될 때 불변 유지
클라이언트가 건네준 객체를 내부 Set, Map에 인스턴스의 참조를 저장한다면 외부에서 값을 변경하면 불변이 깨질 수 있다.
따라서 객체가 잠재적으로 변경될 수 있는지를 생각해야하며 변경이 되더라도 클래스가 문제 없이 동작할 수 있는지 따져야한다.
// List 내부 불변이 깨짐
public class Internal {
private List<Product> lists = new ArrayList<>();
public List<Product> getLists() {
return lists;
}
public void addValue(Product value) {
this.lists.add(value);
}
public static void main(String[] args) {
Internal internal = new Internal();
Product value = new Product( "옷");
internal.addValue(value);
// 옷
System.out.println(internal.getLists().get(0).name);
value.setName("가방");
// 가방
System.out.println(internal.getLists().get(0).name);
}
private static class Product{
String name;
public Product(String name) {
this.name = name;
}
public void setName(String name) {
this.name = name;
}
}
}
2. 내부 객체를 외부로 반환했을 때 불변 유지
가변인 내부 객체가 외부에 노출할 때 주의해야한다. 길이가 1이상 배열은 무조건 가변임을 잊지 말자. 따라서 내부에서 사용하는 자료구조를 외부로 반환할 때는 항상 방어적 복사를 수행해야한다. 배열의 경우 불변뷰를 반환하는 방법도 있다[아이템 17 참조]
다음은 외부에서 변경한 List가 내부에도 참조하고 있기 때문에 0번 인덱스 삭제가 그대로 적용됨을 알 수 있다.
// List 내부 불변이 외부로 나오며 깨짐
public class External {
private List<Integer> lists = new ArrayList<>();
public void init(){
lists.addAll(IntStream.rangeClosed(0,3).boxed().collect(Collectors.toList()));
}
public List<Integer> getLists() {
return lists;
}
public static void main(String[] args) {
External external = new External();
external.init();
// [0, 1, 2, 3]
System.out.println("external = " + external.getLists());
List<Integer> lists = external.getLists();
lists.remove(0);
// [1, 2, 3]
System.out.println("external = " + external.getLists());
}
}
되도록 불변 객체들을 조합해 객체를 구성해야 방어적 복사를 할 일이 줄어든다라는 아이템 17의 교훈을 알 수 있다.
Date 참조는 자바 8 이후부터 쓸 일이 없지만, 쓰인다면 Date.getTime()이 반환하는 long 정수를 사용하는 것도 방법이다.
방어적 복사 생략 가능 시점
방어적 복사는 성능 저하가 따르고 항상 쓸 수 있는 것은 아니다. 따라서 다음과 같은 상황에서 사용하자.
클래스와 클라이언트가 상호 신뢰할 수 있을 때, 불변식이 깨지더라도 영향이 호출한 클라이언트로 국한될 때
메소드나 생성자의 매개변수로 복사해 넘기는 행위는 객체 통제권을 이전한다는 뜻이기도 하다. 같은 패키지 내에서 호출해 컴포넌트 내부를 수정하지 않는다고 확신하거나, 다른 패키지에서 해당 객체를 수정하지 않는다는 약속이 있다면 문서화를 통해서 방어적 복사를 생략할 수 있다. 문서화는 가변 객체의 통제권을 넘겨받는 것을 기대하는 메소드나 생성자에도 붙어야한다.
불변식이 깨져도 영향이 호출한 클라이언트로 국한되는 예는 래퍼 클래스 패턴을 들 수 있다. 래퍼 클래스 특성상 클라이언트는 래퍼에 넘긴 객체에 여전히 직접 접근할 수 있고, 래퍼의 불변식을 쉽게 파괴할 수 있지만 그 영향은 호출한 클라이언트에 국한된다. 래퍼클래스는 상속을 통해서 재활용할 수 있음으로 불변식이 이미 깨진 것과 마찬가지지만 선택을 클라이언트가 했기 때문에 클라이언트 자신만이 그 영향을 받는 것으로 국한되는 것 같다.
클래스가 클라이언트로부터 받는 혹은 클라이언트로 반환하는 구성요소가 가변이면 반드시 방어적 복사를 하자.
복사 비용이 너무 크거나 클라이언트가 요소를 수정할 일이 없음을 신뢰하면 책임이 클라이언트에 있음을 문서화하자.
* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.
'Reading > Effective Java' 카테고리의 다른 글
[Effective-Java] Item 52. 다중정의는 신중히 사용하라 (0) | 2021.11.08 |
---|---|
[Effective-Java] Item 51. 메소드 시그니처를 신중히 설계하라 (0) | 2021.11.08 |
[Effective-Java] Item 49. 매개변수가 유효한지 검사하라 (0) | 2021.11.08 |
[Effective-Java] Item 48. 스트림 병렬화는 주의해서 적용하라 (0) | 2021.11.08 |
[Effective-Java] Item 47. 반환 타입으로는 스트림보다 컬렉션이 낫다 (0) | 2021.11.08 |