아이템 13. clone 재정의는 주의해서 진행하라. - ksw6169/effective-java GitHub Wiki

clone 메소드의 문제점

  • Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스지만 아쉽게도 의도한 목적을 제대로 이루지 못했다.
  • 가장 큰 문제는 clone 이 선언된 곳이 Cloneable이 아닌 Object이고 protected로 선언되었기 때문문에 Cloneable을 구현하는 것만으로는 외부 객체에서 clone을 호출할 수 없다. 리플렉션을 사용하면 가능하지만 해당 객체가 접근이 허용된 clone 메소드를 제공한다는 보장이 없기 때문에 100% 성공하는 것도 아니다.
  • 하지만 여러 문제점에도 불구하고 Cloneable 방식은 널리 쓰이고 있어서 잘 알아두는 것이 좋다.

Cloneable 인터페이스

  • Cloneable 인터페이스는 메소드 하나 없지만 Object의 protected 메소드인 clone의 동작 방식을 결정한다.
public interface Cloneable { }
  • Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며, 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다.
  • 이는 인터페이스를 상당히 이례적으로 사용한 예이니 따라하면 안된다. 인터페이스를 구현한다는 것은 일반적으로 해당 클래스가 그 인터페이스에서 정의한 기능을 제공한다고 선언하는 행위이기 때문이다. 그런데 Cloneable의 경우에는 상위 클래스에 정의된 protected 메소드의 동작 방식을 변경한 것이다.
@Getter
public class PhoneNumber implements Cloneable {

    private int prefix;
    private int areaCode;
    private int lineNum;

    public PhoneNumber(int areaCode, int prefix, int lineNum) {
        this.areaCode = areaCode;
        this.prefix = prefix;
        this.lineNum = lineNum;
    }

    @Override
    public Object clone() {
        try {
            return super.clone();
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }
        return null;
    }
}
@Test
public void Cloneable을_구현한_클래스는_clone이_가능하다() {
    PhoneNumber phoneNumber = new PhoneNumber(031, 123, 1234);
    PhoneNumber clonedNumber = (PhoneNumber) phoneNumber.clone();

    assertEquals(phoneNumber.getAreaCode(), clonedNumber.getAreaCode());
    assertEquals(phoneNumber.getPrefix(), clonedNumber.getPrefix());
    assertEquals(phoneNumber.getLineNum(), clonedNumber.getLineNum());
}
  • 명세에서는 이야기하지 않지만 실무에서 Cloneable을 구현한 클래스는 clone 메소드를 public으로 제공하며, 사용자는 당연히 복제가 제대로 이뤄지리라 기대한다. 이 기대를 만족시키려면 그 클래스와 모든 상위 클래스가 '복잡하고, 강제할 수 없는 허술하게 기술된 프로토콜' 을 지켜야 하는데 그 결과로 '깨지기 쉽고, 위험하고, 모순적인 메커니즘' 이 탄생하게 된다.

clone 메소드의 일반 규약

  • clone 메소드의 일반 규약은 허술하다. Object 명세에서 가져온 설명은 다음과 같다.
이 객체의 복사본을 생성해 반환한다. '복사'의 정확한 뜻은 그 객체를 구현한 클래스에 따라 
다를 수 있다. 일반적인 의도는 다음과 같다. 어떤 객체 x에 대해 다음 식은 참이다.

- x.clone() != x
- x.clone().getClass() == x.getClass()

하지만 위의 요구를 반드시 만족해야 하는 것은 아니다. 
다음 식도 일반적으로는 참이지만, 역시 필수는 아니다.

x.clone().equals(y)

관례상, 이 메소드가 반환하는 객체는 super.clone을 호출해 얻어야 한다.
이 클래스와 (Object를 제외한) 모든 상위 클래스가 이 관례를 따른다면 다음 식은 참이다.

x.clone().getClass() == x.getClass()

관례상, 반환된 객체와 원본 객체는 독립적이어야 한다. 이를 만족하려면 
super.clone으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정해야 할 수도 있다.
  • 위의 규약대로 작성한 클래스의 하위 클래스에서 super.clone을 호출하면 잘못된 객체가 생성될 수 있다. 상위 클래스의 clone 메소드에서 반환하는 객체는 상위 클래스의 객체이기 때문이다. (만약 clone을 재정의한 클래스가 final 클래스라면 상속이 불가능하기 때문에 이 문제는 고려하지 않아도 된다.)
public class Parent implements Cloneable {

    private long id;
    private String name;

    public Parent(long id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    protected Parent clone() {
        try {
            return (Parent) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}
public class Child extends Parent {

    private int age;

    public Child(long id, String name, int age) {
        super(id, name);
        this.age = age;
    }
}
Child child = new Child(1, "testname", 30);

// 하위클래스에서 clone 호출 시 Parent 타입 반환
child.clone();

부모 - 자식 관계에서 clone을 올바르게 재정의하는 방법

  • 제대로 동작하는 clone 메소드를 가진 상위 클래스를 상속해 Cloneable을 구현하고 싶다면 super.clone을 호출하면 된다. 클래스에 정의된 모든 필드는 원본 필드와 똑같은 값을 갖는다.
  • 앞의 설명에 덧붙이자면 자식이 부모 클래스의 clone을 연쇄적으로 호출되도록 구성하면 예를 들어 클라이언트에서 자식.clone 을 호출했을 때 자식의 인스턴스를 반환하고, 부모.clone을 호출하면 부모의 인스턴스를 반환하게 된다.
@Override
public Child clone() {
    // Parent의 clone()을 호출하나 Child 타입의 객체가 올바르게 복사되어 반환됨
    return (Child) super.clone();
}
Parent parent = new Parent(1, "name1");
Parent clonedParent = parent.clone();

assertEquals(parent.getId(), clonedParent.getId());
assertEquals(parent.getName(), clonedParent.getName());

Child child = new Child(1, "testname1", 30);
Child clonedChild = child.clone();

assertEquals(child.getId(), clonedChild.getId());
assertEquals(child.getName(), clonedChild.getName());
assertEquals(child.getAge(), clonedChild.getAge());
  • 쓸데없는 복사를 지양한다는 관점에서 보면 불변 클래스는 clone 메소드를 제공하지 않는 게 좋다.
  • Object의 clone 메소드는 Object를 반환하지만 Object의 하위 클래스인 PhoneNumber의 clone 메소드가 PhoneNumber를 반환할 수 있는 이유는 자바가 공변 반환 타이핑(covariant return typing) 을 지원하기 때문이다. (재정의한 메소드의 반환 타입은 상위 클래스의 메소드가 반환하는 타입의 하위 타입일 수 있다.)

가변 객체를 참조하는 경우 clone을 재정의하는 방법

  • 다음과 같은 Stack 클래스가 가변 객체인 elements를 참조하면 super.clone을 호출하도록 하였을 때, size는 올바르게 복사되겠지만, elements는 원본 Stack과 복사된 Stack이 같은 배열을 참조하게 된다. 따라서 원본이나 복제본 중 하나를 수정하면 다른 하나도 수정되어 불변식을 해치게 된다.
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;  // GC 되도록 하기 위함
        return result;
    }

    // 원소를 위한 공간을 적어도 하나 이상 확보한다.
    private void ensureCapacity() {
        if (elements.length == size)
            elements = Arrays.copyOf(elements, 2 * size + 1);
    }
}
  • clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다. 따라서 Stack의 clone 메소드가 제대로 동작하려면 스택 내부 정보를 복사해야 하는데, 가장 쉬운 방법은 elements 배열의 clone을 재귀적으로 호출해주는 것이다.
  • 배열의 clone은 런타임 타입과 컴파일타임의 타입 모두가 원본 배열과 똑같은 배열을 반환한다.
@Override
protected Stack clone() {
    try {
        Stack result = (Stack) super.clone();
        result.elements = elements.clone();  // 배열의 clone을 재귀적으로 호출
        return result;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}
  • 만약 elements 필드가 final 이라면 앞의 방식은 작동하지 않는다. final 필드에는 새로운 값을 할당할 수 없기 때문이다. (= result.elements 에 새로운 값을 추가하지 못함)
  • 이는 근본적인 문제로 직렬화와 마찬가지로 Cloneable 아키텍처는 '가변 객체를 참조하는 필드는 final로 선언하라' 는 일반 용법과 충돌한다. (단, 원본과 복제된 객체가 그 가변 객체를 공유해도 안전하다면 괜찮다. 즉, elements.clone 을 따로 해주지 않고 super.clone을 호출하면 가변 객체를 공유하게 되는데, 이 경우 elements에 새로운 요소를 추가하는 것이 아니므로 상관없다는 내용이다.) 그래서 복제할 수 있는 클래스를 만들기 위해 일부 필드에서 final 한정자를 제거해야 할 수도 있다.

참조하는 가변 객체가 또다른 가변 객체를 참조하는 경우에 clone 메소드를 재정의하는 방법

  • 배열의 clone을 재귀적으로 호출하는 것만으로는 충분하지 않을 때도 있다.
  • 다음의 HashTable은 복제본이 자신만의 버킷 배열을 갖지만, 이 배열은 원본과 같은 연결 리스트를 참조하여 원본과 복제본 모두 예기치 않게 동작할 가능성이 생긴다.
public class HashTable implements Cloneable {

    public Entry[] buckets = ...;

    private static class Entry {
        final Object key;
        Object value;
        Entry next;    // 문제가 되는 부분

        public Entry(Object key, Object value, Entry next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }

    // 잘못된 clone 메소드 - 가변 상태를 공유한다.
    @Override
    protected Object clone() {
        try {
            HashTable result = (HashTable) super.clone();
            result.buckets = buckets.clone();
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}
  • 이를 해결하기 위해서는 각 버킷을 구성하는 연결 리스트를 복사해야 한다.
  • clone 메소드에서는 적절한 크기의 새로운 버킷 배열을 할당한 다음 원래의 버킷 배열을 순회하면서 비지 않은 각 버킷에 대해 깊은 복사를 수행한다. 이 때 Entry의 deepCopy 메소드는 자신이 가리키는 연결 리스트 전체를 복사하기 위해 자신을 재귀적으로 호출한다.
public class HashTable implements Cloneable {

    public Entry[] buckets = ...;

    private static class Entry {
        final Object key;
        Object value;
        Entry next;

        public Entry(Object key, Object value, Entry next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }

        // 연결 리스트의 다음 요소를 복사한다.
        Entry deepCopy() {
            return new Entry(key, value, next == null ? null : next.deepCopy());
        }
    }

    @Override
    protected Object clone() {
        try {
            HashTable result = (HashTable) super.clone();
            result.buckets = new Entry[buckets.length];

            for (int i=0; i<buckets.length; i++) {
                if (buckets[i] != null)
                    result.buckets[i] = buckets[i].deepCopy();
            }

            return result;

        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}
  • 이 기법은 간단하고 버킷이 너무 길지 않다면 잘 작동하지만, 연결 리스트를 복제하는 방법은 그다지 좋지 않다. 재귀 호출 때문에 리스트의 원소 수만큼 스택 프레임을 소비하며, 리스트가 길면 스택 오버플로를 일으킬 위험이 있기 때문이다. (재귀의 보편적인 단점)
  • 이 문제를 해결하기 위해서는 deepCopy를 재귀 호출 대신 반복자를 써서 순회하는 방향으로 수정해야 한다.
Entry deepCopy() {
    Entry result = new Entry(key, value, next);

    // 엔트리 자신이 가리키는 연결 리스트 끝까지 순회함. next가 다음 요소를 가리키지 않는다면 종료됨
    for (Entry p = result; p.next != null; p = p.next)
        p.next = new Entry(p.next.key, p.next.value, p.next.next);

    return result;
}

주의사항

1. clone 메소드는 하위 클래스에서 재정의될 수 있는 메소드를 호출하지 않아야 한다.

  • clone에서 하위 클래스에서 재정의한 메소드를 호출하면 하위 클래스는 복제 과정에서 자신의 상태를 교정할 기회를 잃게 되어 원본과 복제본의 상태가 달라질 가능성이 크다.
  • 따라서 clone에서는 final이나 private으로 선언되어 있는 메소드를 호출해야 한다.

2. clone을 재정의할 때 CloneNotSupportedException을 던지지 않아야 한다.

  • public인 clone 메소드에서는 검사 예외를 던지지 않아야 그 메소드를 사용하기 편하기 때문이다.

3. 상속용 클래스는 Cloneable을 구현해서는 안된다.

  • Object를 바로 상속할 때처럼 Cloneable 구현 여부를 하위 클래스에서 선택하도록 하거나, clone을 동작하지 않게 구현해놓고 하위 클래스에서 재정의하지 못하도록 해야 한다.
@Override
protected Object clone() throws CloneNotSupportedException {
    return super.clone();
}
@Override
protected final Object clone() throws CloneNotSupportedException {
    throw new CloneNotSupportedException();
}

4. Thread-safe 클래스를 작성할 때는 clone 메소드 역시 적절히 동기화해줘야 한다.

  • super.clone 호출 외에 다른 할 일이 없더라도 clone을 재정의하고 동기화해줘야 한다.

정리

  • Cloneable을 구현한 모든 클래스는 clone을 재정의해야 한다.
  • 이 때 접근제한자는 public 이고 반환 타입은 클래스 자신으로 변경한다.
  • 이 메소드는 가장 먼저 super.clone을 호출한 후 필요한 필드를 전부 적절히 수정한다. (가변 객체는 복사하고, 복제본이 가진 객체 참조 모두가 복사된 객체를 가리키도록 한다.)
  • 기본 타입 필드와 불변 객체 참조만 갖는 클래스라면 아무 필드도 수정할 필요가 없다. (단, 일련번호나 고유 ID는 기본 타입이나 불변일지라도 수정해줘야 한다.)

clone을 구현하는 대신 복사 생성자와 복사 팩토리를 고려하라.

  • Cloneable을 이미 구현한 클래스를 확장하는 경우가 아니라면 복사 생성자와 복사 팩토리라는 더 나은 객체 복사 방식을 제공할 수 있다.

  • 복사 생성자와 복사 팩토리는 Cloneable/clone 방식보다 나은 면이 많다.

    • 언어 모순적이고 위험천만한 객체 생성 매커니즘(생성자를 쓰지 않는 방식)을 사용하지 않는다.
    • 엉성하게 문서화된 규약에 기대지 않는다.
    • 정상적인 final 필드 용법과도 충돌하지 않는다.
    • 불필요한 검사 예외를 던지지 않고, 형변환도 필요치 않다.
    • 복사 생성자, 복사 팩토리는 인터페이스 타입의 인스턴스를 인수로 받을 수 있다.

복사 생성자

  • 복사 생성자란 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자를 말한다.
  • 인터페이스 기반 복사 생성자의 정확한 이름은 '변환 생성자(conversion constructor)' 이다.
public Yum(Yum yum) { ... };

복사 팩토리

  • 복사 팩토리는 복사 생성자를 모방한 정적 팩토리다.
  • 인터페이스 기반 복사 팩토리의 정확한 이름은 '변환 팩토리(conversion factory)' 이다.
public static Yum newInstance(Yum yum) { ... };

참고 자료

  • Effective Java 3/E