들어가기 전
우테코 사다리 타기 미션을 페어와 함께 진행하던 중에, 페어가 List.copyOf()를 쓰는 것을 보게 되었다.
나는 페어에게 List.copyOf()와 Collections.unmodifiableList()의 차이가 무엇인지 질문을 하게 되면서
Collection 객체 복사에 대해서 약 1시간 동안 페어와 함께 공부를 하게 되었다.
그래서 페어와 공부했던 내용들을 까먹기 전에 기록해보려고 한다.
⚙️ Collection 복사 상황
1. 생성자의 인자로 받은 객체의 복사본을 만들어서 내부 필드를 초기화하고자 할 때
2. getter 메소드에서 내부의 객체를 반환할 때
이러한 2가지 상황에서 어떠한 객체 복사를 사용해야 할지 살펴보도록 하겠다!
✅ 방어적 복사 / 얕은 복사 / 깊은 복사
복사 방법들을 살펴보기 전에,
'방어적 복사' / '얕은 복사' / '깊은 복사'에 대한 간략한 정의를 알아보고,
해당 방법이 어떤 복사에 해당하는지 알아 보고자 한다.
💡 1. 방어적 복사
내부의 객체를 반환할 때, 객체의 복사본을 만들어서 반환하는 것을 방어적 복사라고 한다.
방어적 복사를 하게 되면, 복사한 외부의 객체를 변경해도 원본 내부 객체가 변경되지 않는다!
💡 2. 얕은 복사
원본 객체를 복사할 때, 새로운 객체를 만들지만 원본 객체의 '주소 값'을 참조하는 복사이다.
따라서 원본이나 복사한 객체나 변경이 되면 서로 영향을 미친다.
(call-by-reference와 유사한 개념)
💡 3. 깊은 복사
원본 객체를 복사할 때, 새로운 객체를 만들고 원본 객체의 모든 값을 복사해서
원본 객체로부터 독립적인 객체를 생성한다.
따라서 원본과 복사한 객체가 독립적이므로 변경이 되어도 서로 영향을 미치지 않는다.
(call-by-values와 유사한 개념)
이렇게 3가지 복사 개념에 대해
아래 나올 복사 방법들이 어떠한 복사인지 체크하면,
해당 복사 방법들의 장단점을 더 쉽게 이해할 수 있을 것이다!
💻 복사하여 반환 시 사용할 5가지 방법
복사하여 반환 시 사용할 5가지 방법을 살펴보고,
해당 방법들이 어떠한 장단점을 가지고 있는지 살펴보자!
우선, 복사 시 사용할 5가지 방법은 다음과 같다.
1. getter로 그대로 Collcetion 반환
2. new ArrayList<>()으로 새로운 List를 생성해서 반환
3. Collections.unmodifiableList()로 Collection 반환
4. List.copyOf()로 Collection 반환
5. 복사 생성자 + Collections.unmodifiableList로 Collection 반환
1번부터 5번까지 차례대로 어떤 장단점이 있는지 살펴보도록 하겠다!
(자동차 경주 게임이 예시로 들기 편해서 자동차 경주 게임으로 예시를 들어보겠다.)
🎯 1. getter로 그대로 Collection 반환 - 방어적 복사 X, 얕은 복사
public class RacingCars {
private final List<Car> cars;
public RacingCars(final List<Car> cars) {
this.cars = cars;
}
public List<Car> getCars() {
return cars;
}
}
1번 방법은 말그대로 List<Car>를 가진 cars를 그대로 외부에 반환하는 것이다.
이렇게 외부에 그대로 List<Car>를 반환할 경우 다음과 같은 문제가 발생한다.
[복사한 리스트 관련]
1. 외부에 복사한 List<Car>는 외부에서 변경이 가능하다.
2. 외부에 복사한 List<Car>가 변경되면, 원본인 List<Car>도 변경이 된다.
3. 외부에 복사한 List<Car>의 Car가 변경되면, 원본 List<Car>의 Car도 변경이 된다.
[원본 리스트 관련]
1. 원본 List<Car>가 변경되면, 복사한 List<Car>도 변경이 된다.
2. 원본 List<Car>의 Car가 변경되면, 복사한 List<Car>의 Car도 변경이 된다.
이러한 문제는 결국 원본 List<Car>와 복사한 List<Car> 사이의 참조가 끊어지지 않아서
발생하는 문제이다!
이 방법은 얕은 복사이고, 방어적 복사도 아니기 때문에
복사한 리스트 자체 변경도 가능하고, 리스트 안의 객체 변경도 원본에 영향을 미친다.
따라서 컬렉션을 그대로 1번처럼 반환하는 것은 상당한 문제가 있기 때문에 사용하면 안된다.
🎯 2. new ArrayList<>()로 반환 - 방어적 복사 O, 얕은 복사
public class RacingCars {
private final List<Car> cars;
public RacingCars(final List<Car> cars) {
this.cars = cars;
}
public List<Car> getCars() {
return new ArrayList<>(cars);
}
}
이렇게 원본 List<Car>을 복사할 때, List<Car>에 대한 참조를 끊고
새로운 객체로 감싸서 복사하는 것이 방어적 복사에 대한 하나의 예시이다.
new ArrayList<>()로 반환할 경우 다음과 같은 문제가 발생한다.
[복사한 리스트 관련]
1. 복사한 리스트는 변경할 수 있다.
2. 복사한 리스트 안의 객체 요소는 변경이 가능하다.
3. 복사한 리스트 안의 객체 요소가 변경되면 원본 리스트 안의 객체 요소도 변경된다.
[원본 리스트 관련]
1. 원본 리스트 안의 객체 요소가 변경되면 복사한 리스트 안의 객체 요소도 변경된다.
방어적 복사이기 때문에 원본 리스트와 복사한 리스트 간의 참조는 끊어져서
리스트 변경(cars.add(), cars.remove() 등)에 대해서는 원본과 복사본 서로 영향을 받지 않는다.
하지만, 복사한 리스트가 변경은 가능하다는 단점이 있고
얕은 복사이기 때문에 리스트 안의 객체 요소가 변경되면 서로에게 영향을 끼친다.
ex - copiedCars.get(0).setName()으로 자동차의 이름을 바꾸면 원본의 자동차 이름도 바뀐다.
🎯 3. Collections.unmodifiableList - 방어적 복사 X, 얕은 복사
Collections.unmodifiableList는 이름에서도 알 수 있듯이,
복사한 리스트의 수정을 막는 것이 핵심 기능이다!
따라서, 2번의 문제점이었던 '복사한 리스트는 변경할 수 있다.'를 해결할 수 있다.
복사한 리스트 자체에 접근을 시도하면 런타임 시 'UnsupportedOperationException'이 발생하므로
복사한 리스트는 변경이 될 수가 없다.
하지만 Collections.unmodifiableList로 반환할 경우 다음과 같은 문제가 있다.
[복사한 리스트 관련]
1. 복사한 리스트 안의 객체 요소는 변경이 가능하다.
2. 복사한 리스트 안의 객체 요소가 변경되면 원본 리스트 안의 객체 요소도 변경된다.
[원본 리스트 관련]
1. 원본 리스트 변경 시 복사한 리스트도 변경된다.
2. 원본 리스트 안의 객체 요소 변경 시 복사한 리스트 안의 객체 요소도 변경된다.
Collections.unmodifiableList는 복사한 리스트의 수정을 막는 것이 핵심 기능이고,
방어적 복사는 아니기 때문에 원본 리스트를 변경하면, 복사한 리스트도 변경된다.
ex - cars.add(new Car())를 하면, copiedCars에도 새로운 자동차가 추가된다.
※ new ArrayList<>() VS Collections.unmodifiableList의 차이
new ArrayList<>()는 리스트를 복사할 때 원본 리스트와의 참조를 끊기 때문에
원본 리스트를 변경해도 복사본 리스트에 영향을 주지는 않는다는 장점이 있고,
대신 단순히 List로 복사하기 때문에 복사본 리스트에 add, remove 등의 수정이 가능하다는 단점이 있다.
Collections.unmodifiableList는 복사한 리스트의 수정을 막는 것이 핵심이기 때문에
복사본 리스트에 add, remove 등의 수정을 가하면 런타임 에러가 발생하여 수정이 불가능하다는 장점이 있고,
대신 원본 리스트와의 참조는 끊기지 않아서 원본 리스트를 변경하면 복사본 리스트도 변경되는 단점이 있다.
다음과 같은 상황에서 둘 중에 어느 것을 써야할 지 알아보자.
1. 생성자의 인자로 받은 객체의 복사본을 만들어서 내부 필드를 초기화하고자 할 때
2. getter 메소드에서 내부의 객체를 반환할 때
1번 상황에서 생성자의 인자로 받은 객체는 외부 객체일 것이므로,
외부의 원본 객체가 변경되었을 때 객체 내부의 필드가 바뀌면 안된다.
따라서 원본 리스트의 참조를 끊고 새로 생성해서 리스트를 만드는 new ArrayList<>()를 사용해서 받아야한다.
2번 상황은 내부의 반환할 당시 객체 상태를 외부로 반환하는 것이기 때문에,
외부에서 반환한 객체가 수정되면 안된다.
따라서 복사본 리스트의 수정을 막는 Collections.unmodifiableList가 적절할 것 같다.
🎯 4. List.copyOf - 방어적 복사 O, 얕은 복사
List.copyOf()의 코드를 들어가보면,
static <E> List<E> copyOf(Collection<? extends E> coll) {
return ImmutableCollections.listCopy(coll);
}
ImmutableCollections.listCopy()로 반환해주는 것을 알 수 있다.
ImmutableCollections.listCopy()의 코드는 다음과 같다.
static <E> List<E> listCopy(Collection<? extends E> coll) {
if (coll instanceof AbstractImmutableList && coll.getClass() != SubList.class) {
return (List<E>)coll;
} else {
return (List<E>)List.of(coll.toArray());
}
}
이를 통해 List.copyOf()를 분석해보면,
List.copyOf()는 복사본을 만들 때 원본 리스트와의 참조를 끊고
새로운 리스트로 만들어서 반환하기 때문에 Collections.unmodifiableList의 단점이었던
'원본 리스트를 변경하면, 복사한 리스트도 변경된다.'를 해결할 수 있다.
하지만, List.copyOf()도 Collections.unmodifiableList()와 마찬가지로 얕은 복사이기 때문에,
다음과 같은 객체 변경 문제점은 해결할 수 없다.
[복사한 리스트 관련 테스트]
1. 복사한 리스트 안의 객체 요소는 변경될 수 있다.
2. 복사한 리스트 안의 객체 요소가 변경되면 원본 리스트 안의 객체 요소도 변경된다.
[원본 리스트 관련 테스트]
1. 원본 리스트 안의 객체 요소 변경 시 복사한 리스트 안의 객체 요소도 변경된다.
예를 들면, Car 객체 내부에 상태를 변경하는 move() 메소드가 있다고 했을 때
copiedCars.get(0).move()를 진행하면
원본 cars의 0번 인덱스의 Car도 move로 상태가 변하는 것이다.
🎯 5. 복사 생성자 + toUnmodifiableList - 방어적 복사, 깊은 복사
4번째 방법은 위 방법들에서 해결하지 못했던,
'리스트 안의 객체 요소 변경'에 대해 해결할 수 있다.
사용하는 방법은 다음과 같다.
// 복사 생성자
public Car(Car car) {
this.name = car.name;
this.position = car.position;
}
---
public List<Car> getCopyConstructorCars() {
return cars.stream()
.map(Car::new) // 복사 생성자로 Car 생성
.collect(Collectors.toUnmodifiableList());
}
객체에 복사 생성자를 생성하고 stream을 돌면서 복사 생성자로 생성된 새로운 Car 객체들을 추출하고,
해당 객체들을 UnmodifiableList로 collect하여 반환하는 방법이다.
이렇게 하면, 반환된 리스트가 UnmodifiableList이기 때문에 변경이 불가능하고,
리스트 안의 객체 요소들은 모두 복사 생성자로 새롭게 생성된 Car이기 때문에
참조가 끊겨서 원본 객체 요소에 영향을 주지 않게 된다.
💡 그렇다면 복사 생성자 + toUnmodifiableList가 무조건 좋을까?
이 방법의 단점은, 복사 생성자를 생성하는 비용과 stream으로 반복하는 비용이 든다는 것이 단점으로 보인다.
만약 리스트 안의 객체가 불변 객체라면, 굳이 복사 생성자를 사용하지 않고도
List.copyOf()만으로 안전해질 것이다.
따라서, 리스트 안의 객체가 불변 객체가 아닐 때 상황에 맞게 사용하는 것이 좋아 보인다!
🛠️ 상황별 최적의 복사 방법은 뭘까?
1. 생성자의 인자로 받은 객체의 복사본을 만들어서 내부 필드를 초기화하고자 할 때
2. getter 메소드에서 내부의 객체를 반환할 때
🎯 1. 생성자의 인자로 받은 객체의 복사본을 만들어서 내부 필드를 초기화하고자 할 때
1번 상황에서는,
외부에서 넘겨줬던 객체를 변경해도 내부의 객체는 변하지 않아야 한다.
따라서 방어적 복사를 사용해야 한다.
방어적 복사를 사용하는 방법은
new ArrayList<>() / List.copyOf() / 복사 생성자 + unmodifiableList가 있다.
1. new ArrayList<>() : 복사한 리스트(내부 필드) 변경 O / 복사한 리스트(내부 필드) 객체 요소 변경 O
2. List.copyOf() : 복사한 리스트(내부 필드) 변경 X / 복사한 리스트(내부 필드) 객체 요소 변경 O
3. 복사 생성자 + unmodifiableList : 복사한 리스트(내부 필드) 변경 X / 복사한 리스트(내부 필드) 객체 요소 변경 X
이러한 특성을 알고, 알맞게 사용하면 될 것 같다.
예를 들면, 일급 컬렉션에서는 컬렉션의 값을 바꾸는 메소드 자체를 만드는 일이 없기 때문에,
단순하게 new ArrayList<>()로만 복사해도 괜찮을 것 같다.
🎯 2. getter 메소드에서 내부의 객체를 반환할 때
2번 상황에서는 외부에서 반환된 객체를 사용하는 상황에 따라 갈릴 것 같다.
우선은 그대로 반환하는 방법 빼고 모두를 사용할 수 있다.
1. new ArrayList<>() : 복사한 리스트(내부 필드) 변경 O / 원본 리스트 변경 시 복사한 리스트 변경 X / 복사한 리스트(내부 필드) 객체 요소 변경 O
2. Collections.unmodifiableList() : 복사한 리스트 변경 X / 원본 리스트 변경 시 복사한 리스트 같이 변경 / 복사한 리스트(내부 필드) 객체 요소 변경 O
3. List.copyOf() : 복사한 리스트(내부 필드) 변경 X / 원본 리스트 변경 시 복사한 리스트 변경 X / 복사한 리스트(내부 필드) 객체 요소 변경 O
4. 복사 생성자 + unmodifiableList() : 복사한 리스트(내부 필드) 변경 X / 원본 리스트 변경 시 복사한 리스트 변경 X / 복사한 리스트(내부 필드) 객체 요소 변경 X
1. 내부에서 반환한 리스트를 변경해도 된다(해야한다).
👉 new ArrayList<>() 사용
2. 내부에서 반환한 리스트를 변경하면 안 되고, 원본 리스트가 변경될 일이 없다.
(변경될 때 복사한 리스트에 영향을 줘도 된다.)
👉 Collections.unmodifiableList() 사용
3. 내부에서 반환한 리스트를 변경하면 안되고, 원본 리스트 변경 시 복사한 리스트에 영향을 주면 안된다.
👉 List.copyOf() 사용
4. 내부에서 반환한 리스트 안의 객체 요소까지 내부 원본 객체에 영향을 주지 않아야한다.
👉 복사 생성자 + unmodifiableList() 사용
끝내며
처음에 방어적 복사와 unmodifiableList의 단점에 대해서 찾아보고자 한 것에서 시작해서
이렇게 깊게 공부할 줄은 몰랐는데 하게 되었다,,
작성 과정에서도 방법들이 너무 헷갈렸는데,
작성하고 나서도 잘 정리하지 못한 것 같아 아쉽지만 시간을 뺏기는 것 같아서 이만 작성한다!!!
우선 작성하고 난 다음부터는
new ArrayList<>() / Collections.unmodifiableList() / List.copyOf()를
상황에 맞게 사용해야겠다고 생각했다.
마지막 방법이었던 복사 생성자 + unmodifiableList는 지금 단계에서는
복사 생성자를 생성하고, stream으로 반복하는 비용이 들어서
오버 엔지니어링이 아닐까? 라는 생각이 많았기 때문에 '이런 것이 있다' 정도로 알아두려고 한다!
💻 Github 테스트 코드
위의 방법들을 테스트하는 테스트 코드를 만들어 보았는데,
아래의 깃헙 주소를 타고 각 방법들의 테스트 코드를 실행하며, 더 이해하기 쉬울 것 같아 남긴다!
https://github.com/sh111-coder/collection-copy-test
Reference
https://tecoble.techcourse.co.kr/post/2021-04-26-defensive-copy-vs-unmodifiable/
https://pparksean.tistory.com/122?category=986364
'Java' 카테고리의 다른 글
AssertJ의 usingRecursiveComparison로 테스트 동등성 비교하기 (3) | 2023.06.03 |
---|