📋 자바로 이해하는 얕은 복사 & 깊은 복사
[기본 개념]
얕은 복사(Shallow Copy): 객체의 1차 레벨 필드 값만 새로 담는다. 참조 타입 필드는 주소(참조)만 복사한다.
깊은 복사(Deep Copy): 중첩 객체까지 새 인스턴스를 만들어 전체 그래프를 복제한다.
결과적으로 얕은 복사는 내부 참조가 공유되고 깊은 복사는 공유되지 않는다. 예를 들어 아래와 같은 도메인이 있을 때
class Address {
private String city;
public Address(String city) { this.city = city; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
}
class Person {
private String name; // 불변(문자열 자체는 불변)
private Address address; // 가변 참조 필드
public Person(String name, Address address) {
this.name = name;
this.address = address;
}
public String getName() { return name; }
public Address getAddress() { return address; }
}
얕은 복사는 생성자나 팩토리에서 내부 참조를 그대로 넘기는 경우, clone()의 기본 동작 등이 있다.
Person p1 = new Person("Min", new Address("Seoul"));
// 새 객체를 생성하였지만 내부 참조 공유
Person p2 = new Person(p1.getName(), p1.getAddress());
p1.getAddress().setCity("Busan");
System.out.println(p2.getAddress().getCity()); // Busan (같이 바뀜)
반면 깊은 복사는 중첩 객체까지 새로 만드는 경우이다.
Person p1 = new Person("Min", new Address("Seoul"));
// 깊은 복사
Person p4 = new Person(
p1.getName(),
new Address(p1.getAddress().getCity())
);
p1.getAddress().setCity("Busan");
System.out.println(p4.getAddress().getCity()); // Seoul (영향 없음)
자바의 clone()
자바의 Cloneable / Object#clone()은 역사적으로 설계가 난해하다.
1. 얕은 복사가 기본: super.clone()은 필드 단위 값 복사를 수행한다. 원시 타입은 값이 복사되고 참조 타입은 참조만 복사되므로 얕은 복사가 된다. 게다가 생성자를 거치지 않아 불변식이 깨질 위험이 있다.
2. 예외/가시성 이슈: 단순 마커 인터페이스인 Cloneable을 구현하지 않으면 CloneNotSupportedException을 던지며 기본적으로 protected라서 public으로 오버라이드 하지 않으면 외부 코드에서 호출할 수 없다. 또한 호출하는 측에서도 불필요하게 체크 예외를 처리해야 한다.
3. 상속 트리 모호성: 어느 레벨에서 깊게/얕게 복사할지를 강제하지 않아 협업/확장 과정에서 일관성이 쉽게 깨진다.
따라서 clone()을 사용할 때는 모든 가변 참조 필드를 직접 복제해야 깊은 복사가 된다.
// Cloneable 상속
class Address implements Cloneable {
private String city;
public Address(String city) { this.city = city; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
// public으로 오버라이드
@Override
public Address clone() {
// 예외 처리
try {
return (Address) super.clone(); // 불변/문자열 필드만 있으면 안전
} catch (CloneNotSupportedException e) {
throw new AssertionError(e);
}
}
}
class Person implements Cloneable {
private String name;
private Address address; // 가변 참조
public Person(String name, Address address) {
this.name = name;
this.address = address;
}
@Override
public Person clone() {
try {
Person copy = (Person) super.clone(); // 필드 단위 복사
copy.address = address == null ? null : address.clone(); // 깊은 복사
return copy;
} catch (CloneNotSupportedException e) {
throw new AssertionError(e);
}
}
}
위와 같은 이유로 자바에서는 객체 복사 시 더 권장되는 방법들이 존재한다. 첫 번째로는 복사 생성자/정적 팩토리를 사용하는 방식이다.
class Person {
private final String name;
private final Address address;
public Person(String name, Address address) {
this.name = name;
this.address = address; // 주의: 그대로 대입하면 얕은 복사
}
// 복사 생성자 (깊은 복사)
public Person(Person other) {
this.name = other.name; // String은 불변
this.address = other.address == null ? null : new Address(other.address.getCity());
}
public static Person copyOf(Person other) {
return new Person(other);
}
}
*가장 명시적이고 안전한 방식이다.
두 번째는 직렬화 기반의 깊은 복사이다. Jackson, Gson, Java 직렬화 등으로 객체를 바이트/트리로 바꿨다가 다시 읽으면 깊은 복사가 된다.
ObjectMapper om = new ObjectMapper();
// original 객체를 JSON 바이트로 직렬화
byte[] bytes = om.writeValueAsBytes(original);
// JSON 바이트를 다시 Person.class로 역직렬화 -> 새로운 객체 생성
Person copy = om.readValue(bytes, Person.class);
빠르게 구현이 가능하다는 점은 장점이지만 성능 비용이 크다는 단점과 순환 참조 구조의 경우 JSON 직렬화 시 StackOverflow나 무한 루프가 발생할 수 있다.
또한 필드가 transient, @JsonIgnore 등으로 직렬화 대상에서 제외되는 경우 복사 결과가 달라질 수 있어 주의해서 사용해야 한다.
세 번째로는 매핑 라이브러리를 사용하는 것이다. MapStruct, ModelMapper 등으로 DTO와 도메인 간의 복사 규칙을 컴파일/런타임에 생성한다.
@Mapper
public interface PersonMapper {
PersonMapper INSTANCE = Mappers.getMapper(PersonMapper.class);
PersonDto toDto(Person entity);
Person toEntity(PersonDto dto);
}
// 사용 시
Person p1 = new Person("Min", new Address("Seoul"));
PersonDto dto = PersonMapper.INSTANCE.toDto(p1);
// dto 안의 Address도 새 인스턴스로 매핑되어 깊은 복사
반복되는 매핑 코드를 줄여 생산성과 유지 보수성을 높일 수 있고 깊은 복사 규칙을 중앙에서 관리가 가능하다. 그러나 단순 복사만 필요한 경우에는 오버 엔지니어링이 될 수 있다.
컬렉션과 배열
new ArrayList<>(original)는 얕은 복사(요소 참조만 복사)이다. 깊은 복사를 위해서는 요소 자체를 복사해야 한다.
List<Person> src = ...; // 원본 리스트
List<Person> deep = src.stream()
.map(Person::new) // Person 복사 생성자를 이용해 새 객체 생성
.toList(); // 새 리스트로 수집
List.copyOf(…)/Set.copyOf(…)는 불변 리스트를 반환한다. 리스트 자체는 새로 만들어지지만 내부 요소는 그대로 참조만 복사된다. (얕은 복사)
List<String> list = new ArrayList<>();
list.add("Blue");
list.add("Cool");
List<String> copy = List.copyOf(list); // 불변 리스트 + 얕은 복사
copy.add("c"); // UnsupportedOperationException 발생
배열의 arr.clone()은 새 배열을 만들지만 요소가 참조 타입이라면 얕은 복사이다. 하지만 원시(primitive) 타입 배열은 값 복사이므로 결과적으로 깊은 복사처럼 동작한다.
Address[] a1 = { new Address("Seoul") };
Address[] a2 = a1.clone(); // 배열 객체는 새로 생성, 요소는 같은 참조
방어적 복사(Defensive Copy)
외부에 가변 객체를 노출할 때는 반드시 방어적 복사를 고려해야 한다.
class Order {
private final Date createdAt; // Date는 가변 클래스
public Order(Date createdAt) {
this.createdAt = new Date(createdAt.getTime()); // 입력 방어 복사
}
public Date getCreatedAt() {
return new Date(createdAt.getTime()); // 반환 방어 복사
}
}
*Instant, LocalDateTime 같은 java.time 계열은 불변이기 때문에 방어 복사가 불필요하다.
불변(Immutable)
도메인 객체를 최대한 불변으로 만들면 얕은/깊은 복사에 대한 고민 자체가 줄어든다. 내부 상태를 변경할 수 없어 참조를 공유해도 안전하기 때문이다.
자바의 record는 JDK 16부터 정식 도입된 데이터 전용 클래스이다. 모든 필드가 자동으로 final이 되며 생성자로만 값이 설정된다. 따라서 record 자체가 불변적이다.
public record PersonR(String name, AddressR address) {}
public record AddressR(String city) {}
// 사용 시
PersonR p1 = new PersonR("Min", new AddressR("Seoul"));
PersonR p2 = p1; // 같은 참조를 써도 안전하다. (불변)
주의할 점은 record의 필드 자체는 final이지만 참조 타입 필드가 가변 객체라면 내부적으로 여전히 바뀔 수 있다. 따라서 record를 불변적으로 쓰려면 필드 타입도 불변 객체가 되어야 한다.
equals/hashCode
두 객체가 같은 중첩 참조를 공유하면 그 내부 상태가 변할 때 equals()나 hashCode()의 결과가 바뀔 수 있다.
class Address {
String city;
Address(String city) { this.city = city; }
// equals/hashCode city 기준으로 구현했다고 가정
@Override
public boolean equals(Object o) { ... }
@Override
public int hashCode() { ... }
}
class Person {
String name;
Address address;
// equals/hashCode: name + address 기준으로 구현했다고 가정
...
}
Person p1 = new Person("Min", new Address("Seoul"));
Person p2 = new Person(p1.name, p1.address); // 얕은 복사
Set<Person> set = new HashSet<>();
set.add(p1);
// 중첩 객체(Address) 변경
p1.address.city = "Busan";
// p2.equals(p1)는 여전히 true (같은 참조 공유)
// set.contains(p1) -> false (hashCode가 달라짐)
특히 HashSet, HashMap 같은 해시 기반 컬렉션에서 치명적인 버그로 이어질 수 있기 때문에 깊은 복사를 사용하는 것이 좋다.