객체를 복사하는 방법에는 크게 얕은 복사와 깊은 복사, 그리고 방어적 복사가 있습니다.
이러한 방법들이 각각 어떻게 동작하는지, 어떤 상황에서 사용하는 것이 적절한지 알아보도록 하겠습니다.
🧐 얕은 복사
얕은 복사(shallow copy)는 객체를 복사할 때, 객체의 주소 값만을 복사하는 방식입니다.
이 방식은 객체의 내부에 다른 객체를 참조하는 포인터가 있을 경우, 참조된 객체를 새로 복사하지 않습니다.
따라서, 복사된 객체와 원본 객체는 같은 객체를 참조하게 되어, 하나의 객체를 수정하면 다른 객체에도 영향을 미칩니다.
자바에서는 = 연산자를 쓰면 얕은 복사가 수행됩니다.
예시를 위해 다음과 같이 클래스들을 정의하겠습니다.
public class Name {
private final String value;
private Name(final String value) {
this.value = value;
}
public static Name of(final String value) {
return new Name(value);
}
public String value() {
return value;
}
}
public class Friend {
public Name name;
public Friend(final Name name) {
this.name = name;
}
public Name name() {
return name;
}
public void changeName(final Name name) {
this.name = name;
}
}
public class Person {
private Name name;
private final List<Friend> friends;
public Person(final Name name, final List<Friend> friends) {
this.name = name;
this.friends = friends;
}
public Name name() {
return name;
}
public void changeName(final Name name) {
this.name = name;
}
public List<Friend> friends() {
return friends;
}
}
얕은 복사의 예시는 다음과 같습니다.
import java.util.Collections;
public class Main {
public static void main(String[] args) {
Person 말랑 = new Person(Name.of("말랑"), Collections.emptyList());
Person 동훈 = 말랑; // 얕은 복사 수행
System.out.println(동훈 == 말랑);
System.out.println(말랑);
System.out.println(동훈);
System.out.println("=====[BEFORE]=====");
System.out.println(말랑.name().value());
System.out.println(동훈.name().value());
동훈.changeName(Name.of("동훈"));
System.out.println("=====[AFTER]=====");
System.out.println(말랑.name().value());
System.out.println(동훈.name().value());
}
}
다음과 같이 얕은 복사는 객체의 주소값만을 복사하기 때문에, 복사본이 원본과 같은 객체를 참조합니다.
따라서 복사본에서 객체 내부의 값을 변경한다면 원본 객체도 영향을 받습니다.
🧐 방어적 복사
방어적 복사(defensive copy)는 객체의 주소를 복사하지 않고 객체의 내부 값을 참조하여 복사하는 방법입니다.
이렇게 복사한 복사본은 원본과 다른 객체를 참조하게 되지만, 해당 객체 내부에 있는 객체들은 원본과 동일한 주소를 참조합니다.
이 방식은 일반적으로 다른 객체의 참조를 포함하는 객체를 복사할 때 사용됩니다.
쉽게 말하면 통을 갈아끼운다고 볼 수 있는데요, 그림으로 보면 다음과 같습니다.
즉 내부의 값들은 동일하지만, 이들을 담는 통(= 객체)의 주소가 달라졌다는 것을 알 수 있는데요, 방어적 복사는 특히 컬렉션을 복사하는 경우 유용합니다.
우선 방어적 복사를 사용하지 않았을 경우 발생하는 문제점은 다음과 같습니다.
public class Main {
public static void main(String[] args) {
List<Friend> friends = new ArrayList<>();
Person 말랑 = new Person(Name.of("말랑"), friends);
Friend 친구1 = new Friend(Name.of("친구1"));
Friend 친구2 = new Friend(Name.of("친구2"));
Friend 친구3 = new Friend(Name.of("친구3"));
System.out.println("=====[BEFORE]=====");
System.out.println("친구의 수: " + 말랑.friends().size());
말랑.friends().forEach(it -> System.out.print(it.name().value() + " "));
friends.addAll(List.of(친구1, 친구2, 친구3));
System.out.println("=====[AFTER]=====");
System.out.println("친구의 수: " + 말랑.friends().size());
말랑.friends().forEach(it -> System.out.print(it.name().value() + " "));
}
}
위 코드의 결과는 다음과 같습니다.
이는 말랑이의 생성자로 들어온 List를 말랑이의 내부에서 그대로 사용하기 때문에 List에 대한 참조가 공유되어 발생하는 현상입니다.
이제 이를 방어적 복사를 통해 해결하면 다음과 같습니다.
public class Person {
private Name name;
private final List<Friend> friends;
public Person(final Name name, final List<Friend> friends) {
this.name = name;
this.friends = new ArrayList<>(friends); // 방어적 복사
}
public Name name() {
return name;
}
public void changeName(final Name name) {
this.name = name;
}
public List<Friend> friends() {
return new ArrayList<>(friends); // 방어적 복사
}
public void addFriend(final Person person) {
this.friends.add(person);
}
}
위와 같이 변경하면 다음과 같은 결과가 나옵니다.
그러나 방어적 복사에도 다음과 같은 문제점이 있습니다.
public class Main {
public static void main(String[] args) {
Friend 친구1 = new Friend(Name.of("친구1"));
Friend 친구2 = new Friend(Name.of("친구2"));
Friend 친구3 = new Friend(Name.of("친구3"));
List<Friend> friends = new ArrayList<>(List.of(친구1, 친구2, 친구3));
Person 말랑 = new Person(Name.of("말랑"), friends);
System.out.println("=====[BEFORE]=====");
System.out.println("친구의 수: " + 말랑.friends().size());
말랑.friends().forEach(it -> System.out.print(it.name().value() + " "));
System.out.println();
friends.forEach(it -> it.changeName(Name.of(it.name().value() + " 이름 바꿔버리기~")));
System.out.println("=====[AFTER]=====");
System.out.println("친구의 수: " + 말랑.friends().size());
말랑.friends().forEach(it -> System.out.print(it.name().value() + " "));
}
}
위 코드를 실행한 결과는 다음과 같습니다.
방어적 복사는 복사본이 원본의 주소를 그대로 참조하여 사용하지는 않지만, 복사본 객체 내부에 있는 객체들은 원본과 동일한 주소를 참조하게 됩니다.
따라서 위의 예시에서는 생성자의 매개변수로 전달된 friends List 자체는 같은 주소를 참조하지 않지만, 해당 List에 포함된 각각의 Friend에 대해서는 원본과 동일한 주소를 참조하기 때문에 위와 같은 결과가 발생한 것입니다.
이러한 문제는 깊은 복사를 통해 해결할 수 있습니다.
🧐 깊은 복사
깊은 복사(deep copy)는 객체의 모든 내부 상태를 완전히 복사하여 새로운 객체를 만드는 방식입니다.
이 방식은 원본 객체 내부의 모든 객체들도 재귀적으로 복사합니다.
따라서 복사된 객체와 원본 객체는 서로 다른 객체를 참조하게 되어, 하나의 객체를 수정하더라도 다른 객체에는 영향을 미치지 않습니다.
위에서 발생한 문제를 깊은 복사를 통해 해결하면 다음과 같습니다.
public class Person {
private Name name;
private final List<Friend> friends;
public Person(final Name name, final List<Friend> friends) {
this.name = name;
this.friends = friends.stream()
.map(it -> new Friend(it.name()))
.collect(Collectors.toList()); // 깊은 복사
}
public Name name() {
return name;
}
public List<Friend> friends() {
return friends.stream()
.map(it -> new Friend(it.name()))
.collect(Collectors.toList()); // 깊은 복사
}
}
이제 기존 코드를 실행하더라도 친구의 이름이 바뀌지 않는 것을 확인할 수 있습니다.
지금까지 얕은 복사, 깊은 복사, 방어적 복사의 개념과 각각의 사용 예시를 살펴보았습니다.
객체를 올바르게 복사하지 못한다면 예기치 못한 많은 버그들이 발생할 수 있습니다.
이번 글을 통해 복사 방법을 선택할 때 적절한 방법을 선택할 수 있도록 도움이 되었기를 바랍니다. 😊
'☕️ Java > 기본' 카테고리의 다른 글
[Java] Varargs(가변인수)와 Heap Pollution(힙 오염) (2) | 2023.03.21 |
---|---|
[Java] Collectors.toMap() 의 여러 가지 사용법 (7) | 2023.03.14 |
[Java] EnumMap 에 대하여 (0) | 2023.02.18 |
[Java] groupingBy를 통해 동일한 자료의 개수 구하기 (0) | 2022.11.12 |
[Java] Thread (9) - ForkJoin 프레임워크 (0) | 2022.07.15 |