Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스(mixin interface)지만, 아쉽게도 의도한 목적을 제대로 이루지 못했다. clone 메소드가 선언된 곳이 Coneable이 아닌 Object이고, 그마저도 protected이기 때문에 Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메소드를 호출할 수 없다. 리플렉션을 사용하면 가능하지만, 해당 객체가 접근이 허용된 clone 메소드를 제공한다는 보장이 없기 때문에 불확실하다. 그럼에도 Cloneable 방식은 널리 쓰이기 있어서 올바른 구현 방법과 필요한 시점, 대안 등을 잘 알아두어야 한다.
Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며, 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다. 인터페이스를 구현한다는 것은 일반적으로 해당 클래스가 그 인터페이스에서 정의한 기능을 제공한다고 선언하는 행위인데, Cloneable의 경우에는 상위 클래스에 정의된 protected 메소드의 동작 방식을 변경했다. 이는 인터페이스를 상당히 이례적으로 사용한 예이니 따라 하지는 말자.
실무에서 Cloneable을 구현한 클래스는 clone 메소드를 public으로 제공하며, 사용자는 당연히 복제가 제대로 이뤄지리라 기대한다. 이 기대를 만족시키면 생성자를 호출하지 않고도 객체를 생성할 수 있게 된다. clone 메소드의 일반 규약은 생성자 연쇄(constructor chaining)와 살짝 비슷한 메커니즘으로, clone 메소드가 super.clone이 아닌, 생성자를 호출해 얻은 인스턴스를 반환해도 문제 없다. 하지만 이 클래스의 하위 클래스에서 super.clone을 호출한다면 잘못된 클래스 객체가 만들어져, 결국 하위 클래스의 clone 메소드가 제대로 동작하지 않게 된다. clone을 재정의한 클래스가 final이라면 걱정해야 할 하위 클래스가 없으니 이 관례는 무시해도 안전하나, final 클래스의 clone 메소드가 super.clone을 호출하지 않는다면 Object의 clone 구현의 동작 방식에 기댈 필요가 없기 때문에 Cloneable을 구현할 이유도 없다.
제대로 동작하는 clone 메소드를 가진 상위 클래스를 상속해 Cloneable을 구현하고 싶다고 해보자. 모든 필드가 기본 타입이거나 불변 객체를 참조한다면 super.clone을 호출해 원본의 완벽한 복제본을 얻은 뒤, 이를 클라이언트가 형변환하지 않아도 되게끔 미리 형변환하여 반환하기만 하면 된다.
1 2 3 4 5 6 7 8 | // 가변 상태를 참조하지 않는 클래스용 clone 메소드 @Override public PhoneNumber clone() { try { return (PhoneNumber) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionError(); // 일어날 수 없는 일이다. } } | cs |
이 메소드가 동작하게 하려면 PhoneNumber의 클래스 선언에 Cloneable을 구현한다고 추가해야 한다. Object의 clone 메소드는 Object를 반환하지만 PhoneNumber의 clone 메소드는 PhoneNumber를 반환하는데, 자바가 공변 반환 타이핑(covariant return typing)을 지원하니 이렇게 하는 것이 가능하고 권장하는 방식이기도 하다.
간단했던 앞서의 구현이 클래스가 가변 객체를 참조하는 순간 재앙으로 돌변한다. Stack 클래스를 복제할 수 있도록 만들어보자.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | public class Stack { private Object[] elements; private int size = 0; private static final int DEFAULT_INITIAL_CAPACITY = 16; public Stack() { elements = new Object[DEFAULT_INITIAL_CAPACITY]; } public void push(Object e) { ensureCapacity(); elements[size++] = e; } public Object pop() { if (size == 0) throw new EmptyStackException(); Object result = elements[--size]; elements[size] = null; // 다 쓴 참조 해제 return result; } // 원소를 위한 공간을 적어도 하나 이상 확보한다. private void ensureCapacity() { if (elements.length == size) elements = Arrays.copyOf(elements, 2 * size + 1); } } | cs |
clone 메소드가 단순히 super.clone의 결과를 그대로 반환한다면, 반환된 Stack 인스턴스의 size 필드는 올바른 값을 갖겠지만, element 필드는 원본 Stack 인스턴스와 똑같은 배열을 참조하게 된다. 원본이나 복제본 중 하나를 수정하면 다른 하나도 수정되어 불변식을 해치므로, 프로그램이 이상하게 동작하거나 NullPointerException을 던질 것이다. Stack 클래스의 하나뿐인 생성자를 호출한다면 이러한 상황은 절대 일어나지 않으므로, clone 메소드는 사실상 생성자와 같은 효과를 낸다. 즉, clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다. 이를 가능케 하는 가장 쉬운 방법은 elements 배열의 clone을 재귀적으로 호출해주는 것이다.
1 2 3 4 5 6 7 8 9 10 11 | // 가변 상태를 참조하는 클래스용 clone 메소드 @Override public Stack clone() { try { Stack result = (Stack) super.clone(); result.elements = elements.clone(); return result; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } | cs |
elements.clone의 결과를 Object[]로 형변환하지 않는 이유는, 배열의 clone은 런타임 타입과 컴파일타임 타입 모두가 원본 배열과 똑같은 배열을 반환하기 때문이다. 따라서 배열을 복제할 때는 배열의 clone 메소드를 사용하는 것이 권장되며, clone 기능을 제대로 사용하는 유일한 예라 할 수 있다.
elements 필드가 final이었다면 앞서의 방식은 작동하지 않는데, final 필드에는 새로운 값을 할당할 수 없기 때문이다. 이는 근본적인 문제로, 직렬화와 마찬가지로 Cloneable 아키텍처는 '가변 객체를 참조하는 필드는 final로 선언하라'는 일반 용법과 충돌한다(단, 원본과 복제된 객체가 그 가변 객체를 공유해도 안전하다면 괜찮다). 그래서 복제를 위해 일부 필드에서 final 한정자를 제거해야 할 수도 있다.
복잡한 가변 상태를 갖는 클래스는 clone을 재귀적으로 호출하는 것만으로는 충분하지 않을 때도 있는데, 해시테이블용 clone 메소드를 생각해보자.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | public class HashTable implements Cloneable { private Entry[] buckets = ...; private static class Entry { final Object key; Object value; Entry next; Entry(Object key, Object value, Entry next) { this.key = key; this.value = value; this.next = next; } } ... // 나머지 코드 생략 } | cs |
1 2 3 4 5 6 7 8 9 10 | // 잘못된 clone 메소드 - 가변 상태를 공유한다! @Override public HashTable clone() { try { HashTable result = (HashTable) super.clone(); result.buckets = buckets.clone(); return result; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } | cs |
복제본은 자신만의 버킷 배열을 갖지만, 이 배열은 원본과 같은 연결 리스트를 참조하여 원본과 복제본 모두 예기치 않게 동작할 가능성이 생긴다. 이를 해결하기 위해 각 버킷을 구성하는 연결 리스트를 복사해야 한다. 일반적인 해법으로, 먼저 적절한 크기의 새로운 버킷 배열을 할당한 다음 원래의 버킷 배열을 순회하며 비지 않은 각 버킷에 대해 깊은 복사를 수행한다. 이때 깊은 복사를 담당하는 메소드는 자신이 가리키는 연결 리스트 전체를 복사하기 위해 자신을 재귀적으로 호출한다.
위 방식은 간단하고 버킷이 너무 길지만 않다면 잘 작동하나, 연결 리스트를 복제하는 방법으로 그다지 좋지 않다. 재귀 호출 때문에 리스트의 원소 수만큼 스택 프레임을 소비하여, 리스트가 길면 스택 오버플로를 일으킬 위험이 있기 때문이다. 이 문제를 피하기 위해 깊은 복사를 담당하는 메소드를 재귀 호출 대신 반복자를 써서 순회하는 방향으로 수정하면 된다.
복잡한 가변 객체를 복제하는 마지막 방법으로, 먼저 super.clone을 호출하여 얻은 객체의 모든 필드를 초기 상태로 설정한 다음, 원본 객체의 상태를 다시 생성하는 고수준 메소드들을 호출한다. 이 방법은 간단하고 제법 우아한 코드를 얻게 되지만, 아무래도 저수준에서 바로 처리할 때보다는 느리다. 또한 Cloneable 아키텍처의 기초가 되는 필드 단위 객체 복사를 우회하기 때문에 전체 Cloneable 아키텍처와는 어울리지 않는 방식이기도 하다.
생성자에서는 재정의될 수 있는 메소드를 호출하지 않아야 하는데 clone 메소드도 마찬가지다. 만약 clone이 하위 클래스에서 재정의한 메소드를 호출하면, 하위 클래스는 복제 과정에서 자신의 상태를 교정할 기회를 잃게 되어 원본과 복제본의 상태가 달라질 가능성이 크다.
Object의 clone 메소드는 CloneNotSupportedException을 던진다고 선언했지만 재정의한 메소드는 그렇지 않다. public인 clone 메소드에서는 throws 절을 없애야 한다. 검사 예외를 던지지 않아야 그 메소드를 사용하기 편하기 때문이다.
상속해서 쓰기 위한 클래스 설계 방식 두 가지 중 어느 쪽에서든, 상속용 클래스는 Cloneable을 구현해서는 안 된다. Object의 방식처럼 제대로 작동하는 clone 메소드를 구현해 protected로 두고 CloneNotSupportedException도 던질 수 있다고 선언하는 방법이 있다. 다른 방법으로는, clone을 동작하지 않게 구현해놓고 하위 클래스에서 재정의하지 못하게 하게 clone을 퇴화시켜놓는 방법도 있다.
Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 clone 메소드 역시 적절히 동기화해줘야 한다. Object의 clone 메소드는 동기화를 신경 쓰지 않았기 때문에, super.clone 호출 외에 다른 할 일이 없더라도 clone을 재정의하고 동기화해줘야 한다.
Cloneable을 이미 구현한 클래스를 확장한다면 어쩔 수 없이 clone을 잘 작동하도록 구현해야 한다. 그렇지 않은 상황에서는 복사 생성자와 복사 팩토리라는 더 나은 객체 복사 방식을 제공할 수 있다. 복사 생성자와 복사 팩토리는 언어 모순적이고 위험천만한 객체 생성 메커니즘(생성자를 쓰지 않는 방식)을 사용하지 않으며, 엉성하게 문서화된 규약에 기대지 않고, 정상적인 final 필드 용법과도 충돌하지 않으며, 불필요한 검사 예외를 던지지 않고, 형변환도 필요치 않다.
또한 해당 클래스가 구현한 '인터페이스' 타입의 인스턴스를 인수로 받을 수 있다. 예컨대 관례상 모든 범용 컬렉션 구현체는 Collection이나 Map 타입을 받는 생성자를 제공한다. 인터페이스 기반 복사 생성자와 복사 팩토리의 더 정확한 이름은 '변환 생성자(conversion constructor)'와 '변환 팩토리(conversion factory)'다. 이들을 이용하면 클라이언트는 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있다.
Cloneable이 몰고 온 모든 문제를 되짚어봤을 때, 새로운 인터페이스를 만들 때는 절대 Cloneable을 확장해서는 안 되며, 새로운 클래스도 이를 구현해서는 안 된다. final 클래스라면 Cloneable을 구현해도 위험이 크지 않지만, 성능 최적화 관점에서 검토한 후 별다른 문제가 없을 때만 드물게 허용해야 한다. 기본 원칙은 '복제 기능은 생성자와 팩토리를 이용하는 게 최고'라는 것이다. 단, 배열만은 clone 메소드 방식이 가장 깔끔한, 이 규칙의 합당한 예외라 할 수 있다.