자바 객체 복사에서 얕은 복사와 깊은 복사는 무엇이 다를까
빠른 답
- 얕은 복사는 바깥 객체는 새로 만들지만, 내부의 다른 객체 참조는 원본과 공유할 수 있다.
- 깊은 복사는 복사 대상이 가진 가변 객체까지 새로 만들어, 한쪽 변경이 다른 쪽에 퍼지지 않게 한다.
String같은 불변 객체는 참조를 공유해도 상태 변경 문제가 거의 없지만,List,Author같은 가변 객체는 결과가 달라질 수 있다.- 복사 방식은 객체가 수정되는지, 어디까지 소유권을 분리해야 하는지, 복사 비용을 감당할 수 있는지에 따라 달라진다.
목차
한눈에 비교
왜 복사했는데 원본도 같이 바뀔까
자바에서 객체 변수에는 객체 자체가 들어 있지 않고, 객체를 가리키는 참조가 들어 있다. Book book2 = book1;처럼 대입하면 Book 객체가 새로 만들어지는 것이 아니라 두 변수가 같은 객체를 바라본다.
얕은 복사는 단순 대입과는 다르다. 얕은 복사에서는 바깥 객체를 새로 만들 수 있다. 예를 들어 Book 인스턴스는 새로 생성된다. 하지만 Book 안에 들어 있는 Author 같은 필드를 그대로 넘기면, 새 Book도 기존 Author 객체를 함께 바라보게 된다.
이 차이를 보려면 값, 상태, 참조를 나눠서 생각하는 편이 좋다.
- 값:
int,long같은 기본형 값이나 불변 객체가 표현하는 내용 - 상태: 객체 내부에서 시간이 지나며 바뀔 수 있는 필드
- 참조: 어떤 객체를 바라보고 있는지에 대한 연결
복사 후 원본이 함께 바뀌는 현상은 대개 값이 복사되지 않아서라기보다, 변경 가능한 객체의 참조가 공유되어서 생긴다. Author 객체 하나를 원본과 복사본이 함께 바라보는 상태에서 setName()을 호출하면, 두 Book에서 같은 변경 결과가 관찰된다.
예제로 확인하기
아래 코드는 Book과 Author를 사용해 얕은 복사와 깊은 복사를 비교한다. Book의 name은 String이므로 불변 객체이고, Author는 setName()으로 상태가 바뀔 수 있는 가변 객체다.
public class CopyExample {
static class Book {
private final String name;
private final Author author;
Book(String name, Author author) {
this.name = name;
this.author = author;
}
Book shallowCopy() {
return new Book(this.name, this.author);
}
Book deepCopy() {
return new Book(this.name, new Author(this.author.getName()));
}
void changeAuthorName(String name) {
this.author.setName(name);
}
int authorIdentity() {
return System.identityHashCode(author);
}
@Override
public String toString() {
return "Book{name='" + name + "', author=" + author + "}";
}
}
static class Author {
private String name;
Author(String name) {
this.name = name;
}
String getName() {
return name;
}
void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return "Author{name='" + name + "'}";
}
}
public static void main(String[] args) {
Book original = new Book("이펙티브 자바", new Author("조슈아 블로크"));
Book shallowCopied = original.shallowCopy();
shallowCopied.changeAuthorName("Joshua Bloch");
System.out.println("After shallow copy");
System.out.println("original: " + original);
System.out.println("copy: " + shallowCopied);
System.out.println("original author id: " + original.authorIdentity());
System.out.println("copy author id: " + shallowCopied.authorIdentity());
Book anotherOriginal = new Book("리팩터링", new Author("마틴 파울러"));
Book deepCopied = anotherOriginal.deepCopy();
deepCopied.changeAuthorName("Martin Fowler");
System.out.println();
System.out.println("After deep copy");
System.out.println("original: " + anotherOriginal);
System.out.println("copy: " + deepCopied);
System.out.println("original author id: " + anotherOriginal.authorIdentity());
System.out.println("copy author id: " + deepCopied.authorIdentity());
}
}
shallowCopy()는 new Book(this.name, this.author)를 호출한다. 여기서 Book은 새로 만들어지지만 author는 기존 참조를 그대로 넘긴다. 그래서 원본 Book과 복사본 Book은 서로 다른 객체지만, 내부의 Author는 같은 객체일 수 있다.
deepCopy()는 new Author(this.author.getName())로 Author까지 새로 만든다. 처음에는 같은 이름을 가진 것처럼 보이지만, 원본과 복사본은 서로 다른 Author 인스턴스를 바라본다. 이후 한쪽의 저자 이름을 바꿔도 다른 쪽에는 영향을 주지 않는다.
출력 결과로 참조 공유 확인하기
복사 문제를 디버깅할 때는 “복사본을 바꿨는데 원본 출력도 바뀌는지”를 먼저 확인하면 된다. 여기에 System.identityHashCode()를 함께 찍으면 두 객체가 같은 내부 객체를 공유하는지도 추적할 수 있다.
javac CopyExample.java
java CopyExample
After shallow copy
original: Book{name='이펙티브 자바', author=Author{name='Joshua Bloch'}}
copy: Book{name='이펙티브 자바', author=Author{name='Joshua Bloch'}}
original author id: 1078694789
copy author id: 1078694789
After deep copy
original: Book{name='리팩터링', author=Author{name='마틴 파울러'}}
copy: Book{name='리팩터링', author=Author{name='Martin Fowler'}}
original author id: 1831932724
copy author id: 1747585824
첫 번째 결과에서는 원본의 저자 이름도 Joshua Bloch로 바뀌었다. original과 shallowCopied가 같은 Author 객체를 공유하기 때문이다. Book은 두 개지만 Author는 하나인 상태다.
두 번째 결과에서는 원본의 저자 이름이 마틴 파울러로 남아 있다. deepCopied가 가진 Author는 복사 과정에서 새로 만들어졌기 때문이다.
identityHashCode 값은 실행할 때마다 달라질 수 있다. 숫자 자체보다 두 값이 같은지 다른지가 중요하다. 같은 값이면 같은 객체를 공유하고 있을 가능성이 높고, 다른 값이면 서로 다른 객체를 참조한다고 볼 수 있다.
불변 객체와 가변 객체를 구분해야 하는 이유
String은 불변 객체다. 여러 객체가 같은 String 참조를 공유하더라도 문자열 내부 상태를 직접 바꿀 수 없다. 그래서 Book의 name처럼 문자열 필드를 그대로 넘기는 것은 대부분의 경우 공유 변경 문제를 만들지 않는다.
반면 Author, ArrayList, HashMap, 배열처럼 내부 상태를 바꿀 수 있는 객체는 다르게 봐야 한다. 참조를 공유한 상태에서 한쪽이 setName(), add(), put() 같은 변경 메서드를 호출하면 다른 쪽에서도 바뀐 결과가 보일 수 있다.
여기서 final에 대한 오해도 자주 생긴다. final Author author는 author 필드가 다른 Author 객체를 가리키도록 재할당되는 것을 막는다. 하지만 그 Author 객체의 내부 상태 변경까지 막지는 않는다.
final Author author = new Author("조슈아 블로크");
author.setName("Joshua Bloch"); // 가능
// author = new Author("다른 저자"); // 재할당은 불가능
즉, final은 참조 변수의 재할당을 제한한다. 참조 대상 객체가 불변인지, 내부 상태가 바뀔 수 있는지는 별도로 확인해야 한다. 객체를 안전하게 공유하려면 필드가 final인지뿐 아니라, 참조 대상의 변경 가능성도 함께 봐야 한다.
컬렉션과 중첩 객체에서 생기는 함정
컬렉션에서는 얕은 복사와 깊은 복사 차이가 더 쉽게 드러난다. new ArrayList<>(oldList)는 리스트 컨테이너를 새로 만들지만, 리스트 안의 요소 객체까지 자동으로 복제하지 않는다.
예를 들어 List<Author>를 복사한다고 해보자. 새 리스트를 만들었더라도 안에 들어 있는 Author들이 같은 객체라면, 한 리스트에서 저자 이름을 바꿨을 때 다른 리스트에서도 바뀐 이름이 보인다. 리스트 자체의 복사와 리스트 요소의 복사는 다른 층위의 문제다.
- 리스트 얕은 복사: 리스트 컨테이너만 새로 만들고 요소 참조는 공유한다.
- 요소 깊은 복사: 리스트도 새로 만들고 각 요소 객체도 새로 만든다.
- 중첩 객체 복사: 요소 안에 또 다른 가변 객체가 있으면 그 깊이까지 판단해야 한다.
- 불변 요소 공유: 요소가 불변이면 참조 공유가 문제가 되지 않는 경우가 많다.
깊은 복사도 단순히 new를 한 번 더 쓰면 끝나는 문제가 아닐 수 있다. Book 안에 Author가 있고, Author 안에 Address가 있으며, Address 안에 변경 가능한 리스트가 있다면 어디까지 복사해야 하는지 정해야 한다. 전부 복사하면 격리는 강해지지만 비용과 코드 복잡도도 함께 늘어난다.
복사 정책을 코드 밖에서도 드러내기
복사 방식은 구현 세부사항처럼 보이지만, 객체 소유권과 데이터 경계에 관한 결정이기도 하다. 외부 API 응답, 비동기 작업 메시지, 캐시 값처럼 객체가 현재 코드 흐름을 벗어나는 경우에는 참조 공유가 예상치 못한 결과를 만들 수 있다.
팀 안에서 이런 기준을 맞추려면 코드 리뷰 규칙이나 설정 문서에 복사 정책을 남겨두는 방식도 도움이 된다. 아래 예시는 실제 런타임이 자동으로 읽는 설정이라기보다, 어떤 상황에서 깊은 복사를 검토할지 표현한 구성 예시다.
copy-policy:
book:
default: shallow
mutable-fields:
- author
- tags
deep-copy-when:
- external-api-response
- async-job-payload
- cache-value
이 예시에서 author, tags는 변경 가능한 필드로 분류되어 있다. 외부 API 응답이나 비동기 작업 페이로드, 캐시 값처럼 다른 흐름에서 재사용될 수 있는 데이터라면 내부 가변 객체를 공유해도 되는지 한 번 더 확인한다는 의미다.
캐시를 예로 들면, 요청 처리 중 수정한 객체가 캐시에 그대로 남아 다음 요청에 영향을 줄 수 있다. 비동기 작업에서는 큐에 넣은 객체가 실제 실행되기 전에 호출자 코드에서 바뀔 수도 있다. 이런 상황에서는 복사 비용보다 참조 공유로 생기는 추적 비용이 더 커질 수 있다.
복사 방식을 고를 때 보는 조건
얕은 복사와 깊은 복사는 어느 한쪽이 항상 더 좋은 선택이라기보다 조건에 따라 다른 비용과 안전성을 가진다. 읽기 전용 객체를 많이 다루는 코드에서 모든 객체를 깊은 복사하면 불필요한 객체 생성이 늘어난다. 반대로 독립적으로 수정되어야 하는 데이터를 얕은 복사로 넘기면 원본 상태가 예상과 다르게 바뀔 수 있다.
판단할 때는 다음 질문이 도움이 된다.
- 복사본이 원본과 독립적으로 수정되는가?
- 내부 필드 중 변경 가능한 객체가 있는가?
- 컬렉션 안의 요소 객체도 변경될 수 있는가?
- 객체가 외부 API, 캐시, 비동기 작업처럼 현재 호출 흐름 밖으로 나가는가?
- 깊은 복사 비용이 데이터 크기와 호출 빈도에 비해 감당 가능한가?
읽기 전용 데이터나 불변 객체 중심이라면 참조 공유가 단순하고 효율적일 수 있다. 복사본이 독립적으로 변경되어야 하거나, 외부 코드가 객체를 수정할 수 있는 경계로 넘어간다면 내부 가변 객체까지 분리하는 쪽이 더 안전하다.
원문 참고
https://www.maeil-mail.kr/question/62
댓글
댓글 쓰기