최근 프로젝트를 진행하다가 clone() 메서드로 인스턴스를 복사한 후 복사된 인스턴스의 상태 값을 변경하면 원본 인스턴스의 값도 바뀌는 문제를 겪었었다. 해당 버그를 수정하면서 clone 메서드에 대해 찾아보고 테스트해본 내용을 정리해보려고 한다.
clone() 메서드의 사용법
자바 Object 클래스의 clone() 메서드는 자바 언어에서 지원하는 객체 복사 메서드이지만 간단하게(?) 사용할 수 있는 메서드는 아니다. clone() 메서드의 선언부를 보면 아래와 같이 작성되어 있다.
protected native Object clone() throws CloneNotSupportedException;
하나씩 살펴보자. 일단 접근 제한자가 protected로 되어있다. 복사하려는 객체 외부에서는 접근할 수 없고 객체 안에서 super 키워드로 접근해야 함을 알 수 있다. 또한 native 키워드가 붙은걸로 봐서 자바 언어가 아닌 JNI를 사용해서 구현되어 있음을 알 수 있다. 그리고 CloneNotSupportedException을 던지고 있는데 javadoc을 보면 알겠지만 java.lang.Cloneable 인터페이스를 구현하지 않으면 해당 예외를 던지도록 되어있다. 게다가 CloneNotSupportedException은 checked exception이기 때문에 호출하는 쪽에서 반드시 예외처리를 해주어야 한다.
위의 내용을 종합해보면 다음 코드처럼 작성해서 사용할 수 있을 것이다.
public class Member implements Cloneable {
private String name;
private int age;
@Override
public Member clone() {
try {
return (Member) super.clone();
} catch (CloneNotSupportedException e) {
// Cloneable을 구현했기 때문에 이 블록이 실행되는 일은 없다.
return null;
}
}
}
Member 라는 클래스를 예로 들어보자. 먼저 Object의 clone() 메서드를 호출했을 때 CloneNotSupportedException이 발생하지 않도록 Cloneable 인터페이스를 구현해준다. 그리고 clone 메서드를 오버라이드 하는데 외부에서도 호출할 수 있도록 접근제어자를 public으로 바꿔주었다. 또한 super.clone()의 리턴 값이 Object이기 때문에 귀찮지만 Member로 강제 형 변환도 해주고, CloneNotSupportedException 예외가 발생할 일은 없지만 checked exception이기 때문에 또 귀찮지만 try-catch 문으로 한번 감싸준다.
다음은 clone() 메서드를 사용하는 예시다.
public class CloneTest {
public static void main(String[] args) {
Member member1 = new Member("wayne", 31);
Member member2 = member1.clone();
System.out.printf("%s, name=%s, age=%d\n", member1, member1.getName(), member1.getAge());
System.out.printf("%s, name=%s, age=%d\n", member2, member2.getName(), member2.getAge());
}
}
member1 객체를 생성된뒤 clone() 메서드를 호출해서 복사된 member2 객체를 만들고 둘을 출력해보았다.
출력 결과를 보면 두 객체의 해시 코드 값이 다르고 멤버 변수의 값이 같다. 의도한 대로 복사가 잘 된 것을 확인할 수 있다.
얕은 복사 vs. 깊은 복사
clone() 메서드를 사용해서 객체의 복사를 수행할 때 주의할 점은 깊은복사(deep copy)를 하지 않는다는 점이다. 변수가 가리키는 값만 복사하는 얕은 복사(shallow copy)를 하기 때문에 모든 멤버 변수가 primitive 타입이거나 immutable 하다면 문제가 되지는 않지만, 만약 참조형 타입이면서 mutable한 변수가 포함되어 있다면 예상치 못한 문제가 발생할 수 있다.
Member 클래스에 참조형 변수인 Address가 추가된 예제를 보자.
public class Member implements Cloneable {
private String name;
private int age;
private Address address;
@Override
public Member clone() {
try {
return (Member) super.clone();
} catch (CloneNotSupportedException e) {
// Cloneable을 구현했기 때문에 이 블록이 실행되는 일은 없다.
return null;
}
}
}
위와 같이 Member 클래스에 Address라는 참조 변수를 추가한 후,
public class CloneTest {
public static void main(String[] args) {
Member member1 = new Member("wayne", 31, new Address(10101, "seoul"));
Member member2 = member1.clone();
Address address1 = member1.getAddress();
Address address2 = member2.getAddress();
System.out.printf("address1 hashcode : %s\n", address1);
System.out.printf("address2 hashcode : %s\n", address2);
}
}
member1을 생성한 후 clone() 메서드를 호출해 member2 개체를 만들고 각각의 address 멤버변수의 해시값을 출력해보았다.
address1 hashcode : playground.cloning.Address@6b884d57
address2 hashcode : playground.cloning.Address@6b884d57
address1 객체와 address2 객체가 같은 객체임을 알 수 있다. 참조변수가 가리키고 있는 객체의 주소 값만을 복사하는 얕은 복사가 일어난 것이다.
앞서 설명했듯이 만약 Address 객체가 변경 불가능한 객체, 즉 immutable 하다면 상태가 변경될 일이 없기 때문에 크게 문제가 되지는 않을 것이다. 그러나 Address 객체가 변경 가능하고 실제로 값을 변경한다면 문제가 될 수 있다.
public class CloneTest {
public static void main(String[] args) {
Member member1 = new Member("wayne", 31, new Address(10101, "seoul"));
Member member2 = member1.clone();
Address address1 = member1.getAddress();
Address address2 = member2.getAddress();
// address2 객체의 상태를 변경한다
address2.setCity("busan");
System.out.printf("address1 hashcode : %s, city : %s\n", address1, address1.getCity());
System.out.printf("address2 hashcode : %s, city : %s\n", address2, address2.getCity());
}
}
address1 hashcode : playground.cloning.Address@6b884d57, city : busan
address2 hashcode : playground.cloning.Address@6b884d57, city : busan
위의 코드를 보면 복사본인 address2 객체의 city 값을 "busan"으로 바꾸었는데, 출력된 결과를 보면 address1의 city 값도 "busan"으로 바뀐 것을 알 수 있다. address1과 address2가 가리키는 객체가 동일한 객체이기 때문이다. address2 객체의 값만 바꾸는 것을 의도했다면 버그로도 이어질 수 있는 상황이다. (내가 겪은 상황이 정확히 이런 상황이었다)
그러므로 멤버 변수에 변경 가능한 참조 변수를 포함하고 있다면 깊은 복사가 수행될 수 있도록 직접 구현해야 한다. 여러가지 방법이 있겠으나 일반적으로 아래 코드처럼 Address 클래스에도 clone 메서드를 구현한 뒤 Member 클래스의 clone 메서드 안에서 호출하는 방식으로 구현할 수 있다.
public class Member implements Cloneable {
private String name;
private int age;
private Address address;
@Override
public Member clone() {
try {
Address clonedAddress = address.clone();
Member clonedMember = (Member) super.clone();
clonedMember.setAddress(clonedAddress);
return clonedMember;
} catch (CloneNotSupportedException e) {
// Cloneable을 구현했기 때문에 이 블록이 실행되는 일은 없다.
return null;
}
}
}
public class Address implements Cloneable {
private long zipCode;
private String city;
@Override
public Address clone() throws CloneNotSupportedException {
return (Address) super.clone();
}
}
이제 Member 객체를 복사한 후 복사된 Address 객체의 상태를 변경해도 원본에는 영향을 주지 않는다. (hashcode 값을 통해 새로운 Address 객체가 생성되었음을 알 수 있다.)
address1 hashcode : playground.cloning.Address@6b884d57, city : seoul
address2 hashcode : playground.cloning.Address@38af3868, city : busan
clone이 최선일까?
지금까지 Java에서 제공하는 객체 복사 기능인 clone()의 기본적인 사용 방법과 clone이 지원하지 않는 깊은복사를 구현하는 방법에 대해 간단히 정리해 보았다. 이펙티브 자바를 읽은 사람은 알겠지만 사실 객체 복사를 위해 Cloneable 인터페이스를 구현하고 clone 메서드를 사용하는 것은 장점보다는 단점이 더 많다. 실제로 이렇게 정리를 해보니까 불필요한 예외처리나 형 변환 등 불편한 점을 실제로 느낄 수 있었다.
이펙티브 자바에서 권장하는 것 처럼, 나 역시 Cloneable 인터페이스를 구현하는 것보다는 복사 생성자나 복사 팩토리 메서드를 직접 정의해서 사용하는 게 좀 더 좋은 방법이라 생각한다.