Effective Java Item 13

Dong yeong Kim·2022년 7월 31일
0

EffectiveJava

목록 보기
14/14

13. clone 재정의는 주의해서 진행하라

안녕하세요, 이번 포스팅은 복제 관련 메서드인 clone입니다.

여느때와 다름없이 구질구질한 여담으로 시작하겠습니다.
개인적으로 성악설을 믿는 사람인지라, 대부분의 사람은 본능적으로 자기를 보호하기 위해 불의를 저지르는 경우가 많습니다. 하지만, 이것과 별개로 불의가 아닌데도 불의라고 생각하여 자신보다 남을 생각하는 사람들이 있습니다.
예를 들어... 상대방의 부탁을 거절을 못한다거나, 내 인생에서 자신이 1순위가 아닌 다른 것이 2순위라든가.. 여러가지 경우가 있습니다.

서울에 상경한지 이제 딱 1년이 돼가는데, 무엇보다 살아남으려면 냉정하고 철저하게 행동해야 함이 중요한 것을 깨달았습니다. 개인적으로 아주 싫어하는 행동이지만.. 어쩌겠나요 이게 살아가는 방법인데요.

너무 어두운 분위기로 시작하네요. 각설하겠습니다!


주로 clone는 어떠한 컬렉션이나, 인스턴스를 복제하는 개념입니다.
Cloneable은 복제해도 되는 클래승미을 명시하는 용도의 Mixin Interface(Item. 20)이지만, 아쉽게도 목적을 제대로 이루지 못했습니다.

가낭 큰 문제는 clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이고, 그마저도 protected입니다.

[Object에 선언된 clone]

@HotSpotIntrinsicCandidate
    protected native Object clone() throws CloneNotSupportedException;

[Clonable 인터페이스]

package java.lang;

/**
 * A class implements the <code>Cloneable</code> interface to
 * indicate to the {@link java.lang.Object#clone()} method that it
 * is legal for that method to make a
 * field-for-field copy of instances of that class.
 * <p>
 * Invoking Object's clone method on an instance that does not implement the
 * <code>Cloneable</code> interface results in the exception
 * <code>CloneNotSupportedException</code> being thrown.
 * <p>
 * By convention, classes that implement this interface should override
 * {@code Object.clone} (which is protected) with a public method.
 * See {@link java.lang.Object#clone()} for details on overriding this
 * method.
 * <p>
 * Note that this interface does <i>not</i> contain the {@code clone} method.
 * Therefore, it is not possible to clone an object merely by virtue of the
 * fact that it implements this interface.  Even if the clone method is invoked
 * reflectively, there is no guarantee that it will succeed.
 *
 * @author  unascribed
 * @see     java.lang.CloneNotSupportedException
 * @see     java.lang.Object#clone()
 * @since   1.0
 */
public interface Cloneable {
}

그리하여, Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없습니다. Reflection(Item. 65)을 사용하면 가능하지만, 100% 성공하는 것도 아닙니다. 왜냐하면 해당 객체가 접근이 허용된 clone메서드를 제공한다는 보장이 없기 때문입니다.

하지만, 이럼에도 불구하고 Cloneable 방식은 널리 쓰이고 있어 알아둘 필요가 있습니다. 책에서는, clone 메서드를 잘 동작하게끔 해주는 구현 방법과 언제 그렇게 해야하는지 알려줍니다.


위의 예시처럼, 도대체 메서드 하나 없는 Cloneable 인터페이스는 대체 어떤 일을 할까요? 놀랍게도, Object의 protected 메서드인 clone의 동작 방식을 결정합니다. Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 모두 복사한 객체를 반환하며, 그렇지 않는 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던집니다. 이는 인터페이스를 상당히 이례적으로 사용한 예이니 따라하지 않는 것이 좋다고 합니다.

인터페이스를 구현한다는 의미는, 일반적으로 해당 클래스가 그 인터페이스에서 정의한 기능을 제공한다고 선언하는 행위인데, 인터페이스인 Cloneable는 상위 클래스에 정의된 protected 메서드의 동작 방식을 변경한 것이기 때문입니다.

실무에서, Cloneable을 구현한 클래스는 clone 메서드를 public으로 제공하며, 사용자는 당연히 이 기능이 정상적으로 작동, 즉 복제가 되리라 예상합니다.
이것을 만족시키려면 그 클래스와 모든 상위 클래스는 복잡하고, 강제할 수 없고, 허술하게 기술된 프로토콜을 지켜야만 하는데, 그 결과로 깨지기 쉽고 모순적인 메커니즘이 탄생합니다. 바로 생성자를 호출하지 않고도 객체를 생성할 수 있게 되는 것입니다.

clone 메서드의 일반 규약은 허술합니다. Object 명세에서 가져온 다음 설명입니다.

이 객체의 복사본을 생성해 반환한다. '복사'의 정확한 뜻은 그 객체를 구현한 클래스에 따라 다를 수 있다. 일반적인 의도는 다음과 같다. 어떤 객체 x에 대해 다음 식은 참이다.

x.clone() != x

또한 다음 식도 참이다.

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

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

x.clone().equals(x)

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

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

강제성이 없다는 점만 빼면 생성자 연쇄(constructor chaning)와 비슷한 메커니즘입니다. 즉, clone 메서드가 super.clone이 아닌, 생성자를 호출해 얻은 인스턴스를 반환해도 문제가 없다는 말입니다. 하지만, 이 클래스의 하위 클래스에서 super.clone을 호출한다면 잘못된 클래스의 객체가 만들어져, 결국 하위 클래스의 clone 메서드가 제대로 동작하지 않게 됩니다.

-> 클래스 B가 클래스 A를 상속할 때, 하위 클래스인 B의 clone은 B 타입 객체를 반환해야 합니다. 그런데 A의 clone이 자신의 생성자, 즉 new(A...)로 생성한 객체를 반환한다면 B의 clone도 A 타입 객체를 반환할 수밖에 없습니다. 달리 말해 super.clone을 연쇄적으로 호출하도록 구현해두면 clone이 처음 호출된 하위 클래스의 객체가 만들어집니다.

clone을 재정의한 클래스가 final이라면, 하위 클래스가 없으니 당연히 이 관례는 무시해도 됩니다. 하지만 final 클래스의 clone 메서드가 super.clone을 호출하지 않는다면, Cloneable을 구현할 이유도 없습니다. Object의 clone 구현의 동작 방식에 기댈 필요가 없기 때문입니다.


제대로 동작하는 clone 메서드를 가진 상위 클래스를 상속해 Cloneable을 구현하고 싶다고 가정해봅시다. 먼저, super.clone을 호출합니다. 그렇게 얻은 객체는 원본의 완벽한 복제본일 것입니다. 클래스에 정의된 모든 필드는 원본 필드와 똑같은 값을 갖습니다. 모든 필드가 기본 타입이나 불변 객체를 참조한다면 이 객체는 완벽히 우리가 원하는 상태가 더 이상 손 볼 곳이 없습니다. 바로 Item. 10의 PhoneNumber 클래스가 여기 해당됩니다. 그런데 쓸데없는 복사를 지양한다는 고나점에서 보면, 불변 클래스는 굳이 clone 메서드를 제공하지 않는 게 좋습니다. 이 점을 고려해 PhoneNumber의 clone 메서드는 다음처럼 구현할 수 있습니다.

    @Override
    public PhoneNumber clone() {
        try {
            return (PhoneNumber) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

이 메서드가 동작하게 하려면, PhoneNumber 클래스 선언에 Cloneable을 구현하겠다고 추가해야 합니다. Object의 clone 메서드는 Object를 반환하지만 PhoneNumber의 clone 메서드는 PhoneNumber를 형변환하여 반환하게 했습니다. 바로 자바가 공변 반환 타이핑(covariant return typing)을 지원하여 이 방식을 권장합니다. 달리 말해, 재정의한 메서드의 반환 타입은 상위 클래스의 메서드가 반환하지 않는 타입의 하위 타입일 수 있습니다. 이 방식으로 클라이언트가 형변환을 하지 않고도 기대하는 결과를 얻을 수 있습니다.

이를 위해, 위 예시에서는 super.clone에서 얻은 객체를 반환하기 전에 PhoneNumber로 형변환하였습니다. (절대 실패하지 않습니다.)

super.clone 호출을 try-catch 블록으로 감싼 이유는, Object의 clone 메서드가 Checked Exception인 CloneNotSupportedException을 던지도록 선언되었기 때문입니다. PhoneNumber가 Cloneable을 구현하니, super.clone이 성공할 것임을 알 수 있습니다. 여기서 예상이 되지만, CloneNotSupportedException은 사실 Unchecked Exception (런타임 예외)였어야 했다는 의미입니다. (Item. 71)


이제 간단한 이 구현이 클래스가 가변 객체를 참조하는 순간, 재앙을 맞이합니다.
Item 7에서 소개한 Stack 클래스를 예로 들겠습니다.

package item7;

import java.util.Arrays;
import java.util.EmptyStackException;

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);
        }
    }
}

이 클래스를 복제할 수 있도록 만들어 보겠습니다. clone 메서드가 단순히 super.clone의 결과를 그대로 반환한다면 어떻게 될까요? 반환된 Stack 인스턴스의 size 필드는 올바른 값을 갖겠지만, elements 필드는 원본 Stack 인스턴스와 똑같은 배열을 참조할 것입니다. 즉, 원본이나 복제본 중 하나를 수정하면 다른 하나도 수정되어 불변을 해친다는 이야기입니다. 따라서 프로그램이 이상하게 동작하거나, NPE을 던질 수 있습니다.

Stack 클래스의 하나뿐인 생성자를 호출한다면, 이러한 상황은 절대 일어나지 않습니다. clone 메서드는 사실상 생성자와 같은 효과를 냅니다. 즉, clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 합니다. 그래서 Stack의 clone메서드는 제대로 동작하려면 스택 내부 정보를 복사해야 하는데, 가장 쉬운 방법은 elements 배열의 clone을 재귀적으로 호출해 주는 것입니다.

    @Override
    public Stack clone() {
        try {
            Stack result = (Stack) super.clone();
            result.elements = elements.clone();
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

의문을 가졌는데, elements.clone의 결과를 Object[]로 형변환해야 올바르게 동작할까 싶었는데 그럴 필요는 없다고 합니다. 배열의 clone은 런탄임 타입과 컴파일타입 모두가 원본 배여과 똑같은 배열을 반환합니다. 따라서 배열을 복제할 때는 배열의 clone 메서드를 사용하라고 권장합니다. 사실, 배별이 clone 기능을 제대로 사용하는 유일한 예시일 수 있습니다.

한편, elements 필드가 final이었다면, 당연히 앞의 방식은 동작하지 않습니다. 이는 근본적인 문제로, 직렬화와 마찬가지로 Cloneable 아키텍처는 '가변 객체를 참조하는 필드는 final로 선언하라' 는 일반 용법과 충돌합니다. (단, 원본과 복제된 객체가 그 가변 객체를 공유해도 안전다면 괜찮습니다.)
그래서, 복제할 수 있는 클래스를 마늗ㄹ기 위해 일부 필드에서 final 한정자를 제거해야 할 수도 있습니다.


clone을 재귀적으로 호출하는 것만으로는 충분하지 않을 경우도 있습니다. hashCode의 예시처럼 해시테이블용 clone 메서드를 생각해봅시다. 해시테이블 내부는 버킷들의 배열이고, 각 버킷은 key-value를 담는 연결 리스트의 첫 번째 엔트리를 참조합니다. 책의 예시는, 성능을 위해 java.util.LinkedList 대신 직접 구현한 경량 연결 리스트를 사용합니다.

package item13;

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;
        }
    }
}

Stack 처럼 단순히 버킷 배열의 clone을 재귀적으로 호출해봅시다.

    @Override
    public HashTable clone() {
        try {
            HashTable result = (HashTable) super.clone();
            result.buckets = buckets.clone();
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

복제본은 자신만의 버킷 배열을 갖지만, 이 배열은 원본과 같은 연결 리스트를 참조하기 때문에 원본과 복제본 모두 예상치 않게 동작할 수 있습니다. 이를 해결하기 위해 각 버킷을 구성하는 연결 리스트를 복사해야 합니다. 다음은 일반적인 해법입니다.

package item13;

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;
        }
        // 이 엔트리가 가리키는 연결 리스트를 재귀적으로 복사
        Entry deepCopy() {
            return new Entry(key, value, next == null ? null : next.deepCopy());
        }
    }

    @Override
    public HashTable 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();
        }
    }
}

ㅔrivate 클래스인 HashTable.Entry는 deep copy를 지원합니다. 먼저 HashTable의 clone 메서드는 적절한 크기의 새로운 버킷 배열을 할당한 다음, 원래의 버킷 배열을 순회하며 비지 않는 각 버킷에 대해 deep copy를 수행합니다. 이때, Entry의 deepCopy 메서드는 자신이 가리키는 연결 리스트 전체를 복사하기 위해 자신을 재귀적으로 호출합니다. 하지만 연결 리스트를 복제하는 방법으로는 그다지 좋은 예시가 아닐 수 있습니다. 재귀 호출 때문에 리스트의 원소 수만큼 스택 프레임을 소비하여, 리스트가 길면 스택 오버플로를 일으킬 위험이 있기 때문입니다. 이 문제를 피하려면 deepCopy를 재귀 호출 대신 반복자를 써서 순회하는 방향으로 수정해야 합니다.

        Entry deepCopy() {
            Entry result = new Entry(key, value, 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;
        }

(재귀 호출을 쓰면 오버플로를 일으킨다고는 하는데, 굳이 이것을 써야하는 이유는 잘 모르겠습니다..)


복잡한 가변 객체를 복제하는 마지막 방법을 알아보겠습니다.

먼저 super.clone을 호출하여 얻은 객체의 모든 필드를 초기 상태로 설정한 다음, 원본 객체의 상태를 다시 생성하는 고수준 메서드를 호출합니다. 위의 HashTable 예에서라면, buckets 필드를 새로운 버킷 배열로 초기화한 다음, 원본 테이블에 담긴 모든 key-value 쌍 각각에 대해 복제본 테이블의 put(key, value) 메서드를 호출해 둘의 내용이 똑같게 해주면 됩니다. 어떻게 보면, 이 방법이 가장 단순하게 생각한 방법이라고 할 수 있습니다. 이처럼 고수준 API를 활용해 복제하면 보통은 간단하고 제법 우아한 코드를 얻게 되지만, 아무래도 저수준에서 바로 처리할 때보다는 느립니다. 또한 Cloneable 아키텍처의 기초가 되는 필드 단위 객체 복사를 우회하기 때문에, 전체 Cloneable 아키텍쳐와는 어색한 방식이기도 합니다.

생성자에서는 재정의될 수 있는 메서드를 호출하지 않아야 하는데, (Item. 19) clone 메서드도 마찬가지입니다. 만약 clone이 하위 클래스에서 재정의한 메서드를 호출하면, 하위 클래스는 복제 과정에서 자신의 상태를 체크할 기회를 잃을 수 있어 원본과 복제본의 상태가 달라질 가능성이 큽니다.

따라서, 앞 문단에서 얘기한 put(key, value) 메서드는 final이거나 private이어야 합니다.
Object의 clone 메서드는 CloneNotSupportedException을 던진다고 선언했지만 재정의한 메서드는 그렇지 않습니다. public인 clone 메서드에서는 throws 절을 없애야 한다는 이야기입니다.Checked Exception 예외를 던지지 않아야 그 메서드를 사용하기 편하기 때문입니다. (Item. 71)

상속해서 쓰기 위한 클래스 설계 방식 두 가지(Item. 19)중 어느 쪽에서든, 상속용 클래스는 강조했듯이 Cloneable을 구현해서는 안됩니다. 하지만 제대로 작동하는 clone 메서드를 구현해 protected로 두고, CloneNotSupportedException을 던지는 Object의 방식을 모방할 수도 있습니다. 이 방식은 마치 Object를 바로 상속할 때처럼 Cloneable 구현 여부를 하위 클래스에서 선택하도록 해줍니다. 다른 방법으로는, clone을 동작하기 않게 구현해놓고 하위 클래스에서 재정의하지 못하게 할 수도 있습니다. 다음과 같이 clone을 지정하면 됩니다.

@Override
protected final Object clone() throws CloneNotSupportedException {
	throw new CloneNotSupportedException();
}

아직 유의해야 할 것이 한개가 남았습니다. Cloneable을 구현한 Thread-safe 클래스를 작성할 때는 clone 메서드 역시 적절히 동기화해줘야 합니다.(Item. 78) 그러니 super.clone 호출 외에 다른 할 일이 없더라도 clone을 재정의하고 동기화해줘야 합니다.

요약하자면, Clonable을 구현하는 모든 클래스는 clone을 재정의해야 합니다.
이때 접근 제한자는 public으로, 반환 타입은 클래스 자신으로 변경합니다. 이 메서드는 가장 먼저 super.clone을 호출한 후 필요한 필드를 전부 '적절히' 수정합니다.
일반적으로 이 의미는 그 객체의 내부 '깊은 구조'에 숨어 있는 모든 가변 객체를 복사하고, 복제본이 가진 객체 참조 모두가 복사된 객체들을 가리키게 함을 뜻합니다. 이러한 내부 복사는 주로 clone을 재귀적으로 호출해 구현하지만, 앞서 든 예시처럼 항상 베스트인 것은 아닙니다. 기본 타입 필드와 불변 객체참조만 갖는다면 수정할 필요가 없겠지만, 일련번호나 고유 ID는 비록 기본 타입이나 불변일지라도 수정해줘야 합니다.


쭉 읽어보고, 실습해보니 '굳이 이 방식이 필요할까?' 라는 생각이 듭니다.
위에서도 의문을 제기했지만, 이런 복잡한 경우는 드뭅니다. Clonable을 이미 구현한 클래스를 확장한다면 어쩔 수 없이 clone을 잘 작동하도록 구현해야 하지만, 그렇지 않은 상황에서는 복사 셍성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수 있습니다. 복사 생성자란, 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생선자를 말합니다. 아래 예시를 보겠습니다.

public Yum(Yum yum) {...};

복사 팩터리는 복사 생성자를 모방한 정적 팩터리(Item. 1) 입니다.

public static Yum newInstance(Yum yum) {...};

복사 생성자와 그 변형인 복사 팩터리는, Clonable/clone 방식보다 나은 면이 많습니다. 언어 모순적이로 위험천만한 객체 생성 메커니즘(생성자를 쓰지 않는 방식)을 사용하지 않고, 엉성하게 문서화된 규약에 기대지 않고, 가장 문제인 final 필드 용법과도 충돌하지 않으며, 불필요한 검사 예외를 던지지 않고, 형변환도 필요없습니다.

여기서 끝이 아닙니다. 복사 생성자와 복사 팩터리는 해당 클래스가 구현한 '인터페이스'타입의 인스턴스를 인수로 받을 수 있습니다. 가장 좋은 장점이죠. 예를 들어 관례상 모든 범용 컬렉션 구현체는 Collection이나 Map 타입을 받는 생성자를 제공합니다. 터페이스 기반 복사 생성자와 복사 팩터리의 더 정확한 이름은 '변환 생성자' (conversion constructor)와 '변환 팩터리' (conversion factroy)라고 합니다.

이들을 이용하면 클라이언트는 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있습니다. 예를 들어 HastSet 객체 s를 TreeSet 타입으로 복제하는 것 처럼 이죠.

clone으로는 불가능한 이 기능을 변환 생성자로는 간단히 new TreeSet<>(s)로 처리할 수 있습니다.

결론적으로... 배열만이 clone 메서드 방식의 가장 깔끔한 예시라고 할 수 있고.. 아무튼 clone 말고 변환 생성자와 변환 팩터리를 사용합시다.


회고

clone은 사실상 알고리즘 문제에서 자주 사용했던 문제입니다. 기능은 그저 모르고 '복제'에 개념에 유념하여 사용했는데, 개념적으로 파고 들어가니 어려운 점들이 가득했습니다. 근데... 이게 또 clone 말고 다른걸 쓰라니... 다행이긴 합니다만, 슬슬 아이템들을 보면 자바 라이브러리 자체에서 애매하게 구현한 것이 많다고 생각들었습니다.

profile
날 것의 기술 '불'로그

0개의 댓글