방어적 복사를 해야 하는 이유와 방법

sojukang·2022년 3월 3일
0

결론

parameterCollection이나 Array 같이 참조형을 받거나, 반환하는 경우 반드시 방어적 복사를 고려해야한다. 객체 외부에서 참조값을 통해 자유롭게 내부 값을 변경할 수 있기 때문이다.

참조형을 주고받으면 어떤 문제가 생기나?

public class Cars {
    private final List<Car> cars;

    public Cars(List<Car> cars) {
        this.cars = cars;
    }

    public List<Car> getCars() {
        return cars;
    }
}

Cars 객체는 List<Car>parameter로 받아 final cars 필드의 참조 값에 할당한다. 또한 getCars 메소드를 통해 List<Car>를 반환한다. 얼핏 보면 별다른 문제가 없을 듯한 간단한 클래스인데, 어떤 문제가 있을까? 아래 코드를 잠시 생각해보자.

public static void main(String[] args) {
        List<Car> carGroup = new ArrayList<>(Arrays.asList(new Car("pobi"), new Car("sojukang"))); 
        // 변경 가능한 List를 위해 ArrayList 내부에 Arrays.asList로 초기화 
        Cars cars = new Cars(carGroup);

        carGroup.add(new Car("beer"));
        System.out.println(cars.getCars().toString());
    }

코드를 실행시키면 어떻게 될까? add 메소드에서 예외를 터트릴까? 아니면 carGroupcarsList는 별개의 객체라서 두 개의 Car를 담은 리스트를 출력할까? 그것도 아니면...

[Car{name='pobi'}, Car{name='sojukang'}, Car{name='beer'}]

놀랍게도 cars 외부에 존재하는 carGroup에서 호출한 add 메소드에의해 carsList에도 영향이 미친 것을 알 수 있다. 그렇다! carGroupcars 내부의 List는 동일한 참조 값을 가진다.

carGroupcarsList의 참조 값이 같다. 심지어 원소들의 참조 값도 같다. final을 사용해서 필드를 불변으로 만드려 했고, 객체 변경을 위한 메서드를 만들지 않았음에도 객체는 완전히 변경 가능한 못 믿을 객체가 되었다. 참조 값을 주고받을 경우 변경에 취약하게 되고, 외부의 잘못된 사용에 그대로 노출될 수 있다.

생성자에서 방어적 복사를 하자.

문제 해결을 위해선 생성자에서 방어적 복사를 하고, 복사된 객체를 필드에 할당하면 된다. Cars를 다음과 같이 수정하고 동일한 테스트를 해본다.

public Cars(List<Car> cars) {
        this.cars = List.copyOf(cars); // 7 버전부터 제공하는 방법. Immutable collection을 반환한다.
        // this.cars = new ArrayList<>(cars); 와 같은 방법도 된다. 그러나 변경 가능하다.
    }

위와 동일한 테스트를 통해 객체의 주소를 관찰해보면,

carGroupcarsList가 다른 참조 값을 가지고, cars에는 변화가 없는 것을 볼 수 있다. 이로써 외부의 변경이 객체에 미치지 않게 되어 더 믿을만한 객체가 되었다. 또한 생성자의 검증 메서드를 복사 이후에 호출하면 멀티 스레드 환경에서의 복사본을 만드는 사이 일어나는 변화에도 안전하게 객체를 생성할 수 있다. getter 메소드도 동일하게 방어적 복사된 결과를 반환하면 될 것이다.

클라이언트가 여러분의 불변식을 깨뜨리려 혈안이 되어 있다고 가정하고 방어적으로 프로그래밍 해야한다. 생상자에서 받은 가변 매개변수 각각을 방어적으로 복사(defensive copy)해야 한다.
- Effective java Item 50, 개앞맵시 역

내부 객체의 참조값은 그대로인데, 어떻게 해야할까?

위의 방어적 복사엔 문제점이 있다. List의 참조 값은 복사되어 다른 참조 값을 가지지만 내부 원소 객체의 참조 값은 동일하다. carGroup의 원소인 Car를 변경하면 cars도 변경될 수밖에 없다. 이러한 상황까지 막기 위해 내부 원소까지 완벽하게 깊은 복사하려면 어떻게 해야할까? 안타깝게도 java기본 라이브러리에서는 지원하지 않는다. 개발자가 상황에 맞춰 직접 해주어야 한다. Cars를 다음과 같이 수정해보자.

public Cars(List<Car> cars) {
        List<Car> copiedCars = List.copyOf(cars);
        this.cars = elementsCopy(copiedCars);
    }

    private List<Car> elementsCopy(List<Car> carsInput) {
        return carsInput.stream()
            .map(Car::new)
            .collect(toList());
    }

Car를 복사하기 위해 Car를 받아 복사하는 생성자를 새로 정의하였다. 테스트 결과는 다음과 같다.

List의 참조 값은 물론, 내부 원소인 Car들의 참조 값까지 새롭게 생성된 것을 알 수 있다.

Unmodifiable Collection까지 더 고려해보자.

이제 Cars 객체는 외부의 접근에서 안전할까? 그렇지 않다. 아직 객체 내부에서 일어나는 변경에는 안전하지 못하며, getCars를 통해 반환된 List는 외부에서 수정할 수 있을 것이다. 이 문제를 해결하기 위해 Unmodifiable Collection 사용을 고려해봐야한다.
Unmodifiable Collection을 통해 만들어진 Collection은 읽기 용도로만 사용할 수 있으며, set, add, addAll 등을 통해 Collection에 변경을 가하는 메서드를 호출하면 UnsupportedOperationException이 발생한다. 이를 사용해서 Cars를 다음과 같이 수정해볼 수 있다.

	private List<Car> elementsCopy(ArrayList<Car> carsInput) {
        return carsInput.stream()
            .map(Car::new)
            .collect(toUnmodifiableList());
    }

    public List<Car> getCars() {
        return unmodifiableList(cars);
    }

이제 Cars 객체의 List에 내부, 외부의 변경 시도가 일어날 경우 예외를 던질 것이고, 보다 불변성을 유지하기 쉬운 객체가 되었다.

참고

방어적 복사와 Unmodifiable Collection
[JAVA] List 객체 복사 방법과 Collections.copy()에 관한 고찰
[Java] 방어적 복사(Defensive copy)
조슈아 블로크, 개앞맵시 역, 이펙티브 자바, 3판

profile
기계공학과 개발어린이

0개의 댓글