[Java] 얕은 복사(Shallow Copy) vs 깊은 복사(Deep Copy)
"복사했는데 원본이 바뀌었습니다"
분명 객체를 복사해서 복사본만 수정했는데, 원본 객체의 값까지 함께 바뀌는 현상입니다. 디버깅이 어렵고, 원인을 모르면 반복적으로 발생합니다. 문제의 근본 원인은 자바가 객체를 다루는 방식 — 참조(Reference)에 대한 이해 부족입니다.
이 글에서는 자바 메모리 모델에서 참조가 어떻게 동작하는지부터 시작하여, 얕은 복사와 깊은 복사의 차이를 명확하게 설명하겠습니다.
1. 자바의 참조(Reference) 이해하기
복사 이야기를 시작하기 전에, 자바에서 변수가 객체를 어떻게 가리키는지 반드시 알아야 합니다. 이 개념을 건너뛰면 얕은 복사와 깊은 복사의 차이를 직관적으로 이해하기 어렵습니다.
1-1. 기본 타입 vs 참조 타입
자바의 변수는 두 종류로 나뉩니다.
기본 타입은 값 자체를 변수에 저장합니다. 참조 타입은 객체의 메모리 주소를 변수에 저장합니다.
📄 기본 타입과 참조 타입의 대입 차이
// 기본 타입: 값 자체를 복사
int a = 10;
int b = a; // b에 10이라는 "값"이 복사됨
b = 20;
System.out.println(a); // 10 ← a는 영향 없음
// 참조 타입: 주소를 복사
int[] arrA = {1, 2, 3};
int[] arrB = arrA; // arrB에 arrA의 "주소"가 복사됨
arrB[0] = 99;
System.out.println(arrA[0]); // 99 ← arrA도 같은 배열을 가리키므로 변경됨
기본 타입의 b = a는 값을 복사하므로 이후 서로 독립적입니다.
하지만 참조 타입의 arrB = arrA는 같은 객체를 가리키는 주소를 복사한 것입니다. 두 변수가 동일한 객체를 공유하므로, 한쪽에서 변경하면 다른 쪽에서도 변경이 보입니다.
1-2. 대입은 복사가 아니다
이 점을 명확히 해야 합니다. 참조 타입에서 = 연산자는 객체를 복사하지 않습니다. 단지 "같은 객체를 가리키는 리모컨을 하나 더 만드는 것"입니다. TV 리모컨이 두 개 있어도 TV는 하나인 것과 같습니다.
이 사실을 이해하면, 다음 질문이 자연스럽게 떠오릅니다. "그러면 진짜로 독립된 복사본을 만들려면 어떻게 해야 하는가?" 이것이 바로 얕은 복사와 깊은 복사의 영역입니다.
2. 얕은 복사(Shallow Copy): 한 꺼풀만 복사한다
얕은 복사는 객체의 최상위 필드 값만 새 객체에 복사합니다. 필드가 기본 타입이면 값이 복사되고, 필드가 참조 타입이면 주소가 복사됩니다. 즉, 내부에 포함된 객체는 원본과 복사본이 여전히 공유합니다.
2-1. 얕은 복사의 메모리 구조
이해를 돕기 위해 구체적인 예시를 설정하겠습니다.
Team 객체가 List<String> members를 필드로 가지고 있는 상황입니다.
📄 얕은 복사 후 원본이 영향받는 예시
public class Team {
private String name;
private List<String> members;
public Team(String name, List<String> members) {
this.name = name;
this.members = members;
}
// 얕은 복사 메서드
public Team shallowCopy() {
return new Team(this.name, this.members); // members의 "주소"만 복사
}
// getter/setter 생략
}
Team original = new Team("Backend", new ArrayList<>(List.of("Alice", "Bob")));
Team copied = original.shallowCopy();
// 복사본에 멤버를 추가
copied.getMembers().add("Charlie");
// 원본에도 Charlie가 나타남!
System.out.println(original.getMembers());
// 출력: [Alice, Bob, Charlie] ← 의도하지 않은 변경
copied는 새로운 Team 객체이지만, 내부의 members 리스트는 original과 동일한 리스트 객체를 가리킵니다.
그래서 한쪽에서 리스트를 수정하면 다른 쪽에서도 변경이 보입니다.
2-2. 왜 이런 일이 발생하는가
String name 필드는 문제가 없습니다. String은 불변이므로 값이 변경될 수 없기 때문입니다. 하지만 List<String> members는 가변(mutable) 객체입니다. 얕은 복사는 이 리스트의 주소만 복사했기 때문에, 원본과 복사본이 같은 리스트를 공유합니다.
정리하면, 얕은 복사가 안전한 조건은 모든 참조 타입 필드가 불변일 때뿐입니다. 하나라도 가변 객체가 필드에 있다면, 얕은 복사는 의도치 않은 상태 공유 버그를 만들 수 있습니다.
3. 깊은 복사(Deep Copy): 참조 사슬을 완전히 끊는다
깊은 복사는 객체 내부의 참조 타입 필드까지 재귀적으로 새 인스턴스를 생성하여, 원본과 완전히 독립된 복사본을 만듭니다. 원본을 어떻게 수정하든 복사본에 영향을 주지 않습니다.
3-1. 깊은 복사의 핵심 원리
깊은 복사의 원리는 단순합니다. "참조가 가리키는 실체를 새로 만들어라." 이 과정을 참조 타입 필드가 있을 때마다 반복합니다.
📄 깊은 복사 — 독립된 복사본 생성
public class Team {
private String name;
private List<String> members;
public Team(String name, List<String> members) {
this.name = name;
this.members = members;
}
// 깊은 복사 메서드
public Team deepCopy() {
// members 리스트를 새로 생성하여 참조 사슬을 끊음
List<String> copiedMembers = new ArrayList<>(this.members);
return new Team(this.name, copiedMembers);
}
}
Team original = new Team("Backend", new ArrayList<>(List.of("Alice", "Bob")));
Team copied = original.deepCopy();
copied.getMembers().add("Charlie");
System.out.println(original.getMembers());
// 출력: [Alice, Bob] ← 원본은 영향 없음!
System.out.println(copied.getMembers());
// 출력: [Alice, Bob, Charlie] ← 복사본만 변경됨
deepCopy()에서 new ArrayList<>(this.members)로 리스트 자체를 새로 생성했기 때문에, 원본과 복사본의 리스트는 별도의 메모리 공간에 존재합니다. 이제 한쪽을 수정해도 다른 쪽에 영향이 없습니다.
참고: 이 예시에서 리스트 내부의 String 요소는 불변이므로 리스트만 새로 만들면 됩니다. 만약 리스트 안에 가변 객체가 들어 있다면, 그 객체들까지 각각 복사해야 진정한 깊은 복사가 완성됩니다.
4. 깊은 복사 구현 방법: 세 가지 선택지
자바에서 깊은 복사를 구현하는 방법은 여러 가지가 있습니다. 각 방법의 특성을 이해하고 상황에 맞게 선택하는 것이 중요합니다.
4-1. 복사 생성자(Copy Constructor)
가장 명시적이고 안전한 방법입니다. 복사할 필드를 개발자가 직접 제어하므로 의도를 코드에 명확히 표현할 수 있습니다.
📄 Good Practice — 복사 생성자를 사용한 깊은 복사
public class Team {
private String name;
private List<String> members;
// 일반 생성자
public Team(String name, List<String> members) {
this.name = name;
this.members = new ArrayList<>(members); // 방어적 복사까지 적용
}
// 복사 생성자: 기존 객체를 받아 깊은 복사
public Team(Team other) {
this.name = other.name;
this.members = new ArrayList<>(other.members);
}
}
// 사용
Team original = new Team("Backend", List.of("Alice", "Bob"));
Team copied = new Team(original); // 명시적이고 읽기 쉬움
복사 생성자의 장점은 컴파일 타임에 타입 안전성이 보장되고, 어떤 필드가 어떻게 복사되는지 코드를 보면 즉시 알 수 있다는 것입니다.
4-2. Cloneable + clone()
Object.clone()은 자바에서 제공하는 기본 복사 메커니즘입니다.
📄 Bad Practice — clone()의 함정
public class Team implements Cloneable {
private String name;
private List<String> members;
@Override
public Team clone() {
try {
Team cloned = (Team) super.clone(); // 얕은 복사만 수행!
// ⚠️ 여기서 멈추면 members는 여전히 공유됨
cloned.members = new ArrayList<>(this.members); // 직접 깊은 복사 추가
return cloned;
} catch (CloneNotSupportedException e) {
throw new AssertionError(); // 일어날 수 없지만 강제됨
}
}
}
super.clone()은 기본적으로 얕은 복사만 수행합니다. 깊은 복사를 원하면 참조 타입 필드를 개발자가 수동으로 다시 복사해야 합니다. Cloneable 인터페이스에는 메서드가 없고, CloneNotSupportedException이라는 checked exception을 강제로 처리해야 하는 등 설계상의 문제가 많습니다.
4-3. 직렬화(Serialization)를 이용한 복사
객체를 바이트 스트림으로 변환(직렬화)한 후 다시 객체로 복원(역직렬화)하면, 참조 사슬이 완전히 끊어진 깊은 복사가 됩니다. 그러나 성능 비용이 크고, 모든 필드의 클래스가 Serializable을 구현해야 한다는 제약이 있어 범용적이지 않습니다.
4-4. 방법별 비교
| 구현 방법 | 복사 깊이 제어 | 타입 안전성 | 성능 | 권장도 |
| 복사 생성자 | 개발자가 직접 제어 | 컴파일 타임 보장 | 빠름 | ✅ 강력 권장 |
| clone() | super.clone() 후 수동 보완 | 런타임 캐스팅 필요 | 빠름 | ⚠️ 비권장 |
| 직렬화 | 자동 깊은 복사 | Serializable 필수 | 느림 | ❌ 특수 상황만 |
5. 방어적 복사(Defensive Copy)
얕은 복사와 깊은 복사를 이해했다면, 실무에서 이 지식이 가장 직접적으로 적용되는 패턴이 바로 방어적 복사입니다. 이것은 "외부에서 받은 가변 객체, 외부로 내보내는 가변 객체를 반드시 복사하라"는 원칙입니다.
5-1. 방어적 복사가 필요한 이유
외부에서 전달받은 가변 객체를 그대로 필드에 저장하면, 외부 코드가 원본 참조를 통해 객체 내부 상태를 마음대로 변경할 수 있습니다. 캡슐화가 무너지는 것입니다.
📄 Bad Practice — 외부 참조가 내부를 침범
public class Roster {
private final List<String> players;
public Roster(List<String> players) {
this.players = players; // ❌ 외부 리스트의 주소를 그대로 저장
}
public List<String> getPlayers() {
return this.players; // ❌ 내부 리스트의 주소를 그대로 노출
}
}
List<String> names = new ArrayList<>(List.of("Alice", "Bob"));
Roster roster = new Roster(names);
// Roster 외부에서 names를 수정
names.add("Hacker"); // ← Roster는 모르는 사이에 내부 상태가 변경됨
System.out.println(roster.getPlayers());
// 출력: [Alice, Bob, Hacker] ← 캡슐화 파괴!
📄 Good Practice — 들어올 때 복사, 나갈 때 복사
public class Roster {
private final List<String> players;
public Roster(List<String> players) {
// ✅ 들어올 때 복사: 외부 참조와 내부 참조를 분리
this.players = new ArrayList<>(players);
}
public List<String> getPlayers() {
// ✅ 나갈 때 복사: 외부에 내부 리스트 원본을 노출하지 않음
return new ArrayList<>(this.players);
// 또는 Collections.unmodifiableList(this.players)로 읽기 전용 뷰 제공
}
}
List<String> names = new ArrayList<>(List.of("Alice", "Bob"));
Roster roster = new Roster(names);
names.add("Hacker");
System.out.println(roster.getPlayers());
// 출력: [Alice, Bob] ← 내부 상태가 보호됨!
roster.getPlayers().add("Outsider");
System.out.println(roster.getPlayers());
// 출력: [Alice, Bob] ← getter로 받은 리스트를 수정해도 원본 불변!
방어적 복사의 원칙은 간단합니다. "가변 객체가 클래스 경계를 넘을 때는 반드시 복사하라." 생성자에서 받을 때 복사하고, getter로 내보낼 때도 복사합니다.
6. 복사 문제를 근본적으로 회피하는 방법: 불변 설계
지금까지 얕은 복사의 위험, 깊은 복사의 구현, 방어적 복사의 패턴을 다뤘습니다. 하지만 한 발 더 나아가면, 복사 문제 자체를 발생시키지 않는 설계가 가능합니다. 바로 불변 객체(Immutable Object) 설계입니다.
6-1. 불변 객체는 왜 복사가 필요 없는가
객체의 상태가 생성 이후 절대 변경되지 않는다면, 여러 곳에서 같은 객체를 참조해도 아무 문제가 없습니다. 아무도 그 상태를 바꿀 수 없기 때문입니다. String이 대표적인 불변 객체이며, 이것이 String을 얕은 복사해도 안전한 이유입니다.
📄 불변 클래스 설계 패턴
public final class ImmutableTeam {
private final String name;
private final List<String> members;
public ImmutableTeam(String name, List<String> members) {
this.name = name;
// 생성 시점에 방어적 복사 + 불변 리스트로 변환
this.members = List.copyOf(members);
}
public String getName() { return name; }
public List<String> getMembers() {
return members; // List.copyOf()의 결과는 이미 불변 → 그대로 반환 가능
}
// 상태 변경이 필요하면 "새 객체를 반환"
public ImmutableTeam addMember(String newMember) {
List<String> newMembers = new ArrayList<>(this.members);
newMembers.add(newMember);
return new ImmutableTeam(this.name, newMembers);
}
}
불변 객체는 얕은 복사, 깊은 복사, 방어적 복사의 필요성을 근본적으로 제거합니다. 참조를 아무리 공유해도 상태가 변하지 않기 때문입니다. 물론 상태 변경이 빈번한 경우에는 매번 새 객체를 생성하는 비용이 발생하므로, 상황에 따라 가변/불변을 적절히 선택해야 합니다.
7. 결론: 복사의 본질은 "참조의 독립성"입니다
자바에서의 복사 문제는 결국 참조라는 개념에서 출발합니다. 변수에 객체가 저장되는 것이 아니라 객체의 주소가 저장되기 때문에, 단순 대입은 같은 객체를 공유하게 만듭니다. 얕은 복사는 최상위 객체는 분리하지만 내부 참조 객체는 여전히 공유하고, 깊은 복사는 참조 사슬을 완전히 끊어 독립된 복사본을 만듭니다.
- 자바에서 =(대입)은 참조 타입의 경우 객체를 복사하지 않습니다. 같은 객체를 가리키는 참조가 하나 더 생길 뿐입니다.
- 얕은 복사는 최상위 필드만 복사하므로, 내부 가변 객체가 있으면 원본과 복사본이 상태를 공유합니다.
- 깊은 복사는 내부 참조 객체까지 재귀적으로 새로 생성하여 완전한 독립성을 보장합니다.
- 깊은 복사 구현은 복사 생성자를 우선 사용하세요. clone()은 설계 결함이 있어 권장되지 않습니다.
- 가능하다면 불변 객체를 설계하여 복사 문제를 근본적으로 회피하는 것이 가장 안전한 전략입니다.