ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 모든 객체의 공통 메서드 - 아이템 13. clone 재정의는 주의해서 진행하라
    Study/Effective Java 2022. 12. 15. 13:55

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

    개요

    Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스지만, 의도한 목적을 제대로 이루지 못했다. 가장 큰 문제는 clone() 메서드가 선언된 곳이 Cloneable이 아닌 Object이고, 그마저도 protected라는 데 있다. 따라서, Cloneable을 구현하는 것만으로는 외부 객체에서 clone() 메서드를 호출할 수 없다.

    리플렉션을 사용하면 가능하지만, 100% 성공하는 것도 아니다. 해당 객체가 접근이 허용된 clone() 메서드를 제공한다는 보장이 없기 때문이다.

    하지만, 이를 포함한 여러 문제점에도 불구하고 Cloneable 방식은 널리 쓰이고 있어서 잘 알아두는 것이 좋다. 이번 게시글에서는 clone() 메서드를 잘 동작하게끔 해주는 구현 방법과 언제 그렇게 해야 하는지를 알려주고, 가능한 다른 선택지에 관해 논의해보도록 하겠다.

     

    Cloneable 인터페이스

    Cloneable 인터페이스는 Object의 protected 메서드인 clone()의 동작 방식을 결정한다. Cloneable을 구현한 클래스의 인스턴스에서 clone() 메서드를 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며, 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다.

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

     

    clone 메서드

    실무에서 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()

    관례상, 반환된 객체와 원본 객체는 독립적이어야 한다. 이를 만족하려면 super.clone() 메서드로 얻은 객체의
    필드 중 하나 이상을 반환 전에 수정해야 할 수도 있다.

     

    super.clone

    강제성이 없다는 점만 빼면 생성자 연쇄와 비슷한 메커니즘이다. 즉, clone() 메서드가 super.clone() 메서드가 아닌, 생성자를 호출해 얻은 인스턴스를 반환해도 같은 결과를 얻을 수 있다.

    하지만, super.clone() 메서드를 연쇄적으로 호출하도록 구현해두면 상위 클래스의 객체가 만들어져 제대로 동작하지 않게 된다. clone() 메서드를 재정의한 클래스가 final이라면 하위 클래스가 없기 때문에 이 관례는 무시해도 안전하다. 하지만, final 클래스의 clone() 메서드가 super.clone() 메서드를 호출하지 않는다면, Object에 구현된 clone() 메서드의 동작 방식에 기댈 필요가 없기 때문에 Cloneable을 구현할 이유도 없다.

    제대로 작동하는 clone() 메서드를 가진 상위 클래스를 상속해 Cloneable을 구현한다면, super.clone() 메서드를 호출한다.

     

    public class User implements Cloneable {
        private String name;
    
        public User(String name) {
            this.name = name;
        }
    
        @Override
        public User clone() {
            try {
                return (User) super.clone();
            } catch(CloneNotSupportedException e) {
                throw new AssertionError();
            }
        }
    }

    위 소스코드는 User 클래스에 clone() 메서드를 구현한 코드이다.

    clone() 메서드가 제대로 동작하기 위해서는 User 클래스가 Cloneable 인터페이스를 구현하고 있는 형태여야 한다. 클라이언트가 형 변환하지 않도록 자바가 제공하는 공변 반환 타이핑을 통해 clone() 메서드는 User를 반환하게 한다,

    super.clone() 메서드 호출을 try-catch 블록으로 감싼 이유는 Object의 clone() 메서드가 검사 예외(checked exception)인 CloneNotSupportedException을 던지도록 선언되었기 때문이다.


    가변 객체를 복제하는 방법

    가변 상태를 참조하는 클론용 clone() 메서드

    위에서 보았던 간단한 clone() 메서드의 구현이 클래스가 가변 객체를 참조하는 순간 재앙으로 돌변한다.

     

    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;
        }
    
        private void ensureCapacity() {
            if (elements.length == size) {
                elements = Arrays.copyOf(elements, 2 * size + 1);
            }
        }
    
        public Object pop() {
            if (size == 0) {
                throw new EmptyStackException();
            }
    
            Object result = elements[--size];
            elements[size] = null;
            return result;
        }
    }

    위와 같은 Stack 클래스 내 clone() 메서드가 단순히 super.clone() 메서드의 결과를 반환한다면 Stack 인스턴스의 size 필드는 올바른 값을 갖겠지만, elements 필드는 원본 Stack 인스턴스와 같은 배열을 참조할 것이다. Stack 클래스의 생성자를 호출한다면 위와 같은 문제는 일어나지 않을 것이다.

    clone() 메서드는 사실상 생성자와 같은 효과를 내야 한다. 즉, clone() 메서드는 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다.

     

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

    위 소스코드는 Stack 클래스 내 clone() 메서드가 제대로 동작하도록 재정의한 코드이다. Stack 클래스의 clone() 메서드가 제대로 동작하기 위해서는 스택 내부 정보(elements)를 복사해야 하는데, 가장 쉬운 방법은 elements 배열의 clone() 메서드를 재귀적으로 호출해주는 것이다.

     

    깊은 복사(deep copy)를 활용한 복제

    하지만, clone() 메서드를 재귀적으로 호출하는 것만으로는 충분하지 않을 때도 있다. 이번에는 해시 테이블용 clone() 메서드를 생각해보자. 해시 테이블 내부는 버킷들의 배열이고, 각 버킷은 키-값 쌍을 담는 연결 리스트의 첫 번째 엔트리를 참조한다.

     

    public class HashTable implements Cloneable {
        private Map.Entry[] buckets = ...;
        
        private static class Entry {
            final Object key;
            Object value;
            Enrty next;
            
            public Entry(Object key, Object value, Entry next) {
                this.key = key;
                this.value = value;
                this.next= next;
            }
        }
        
        ...
    }

    위 소스코드는 java.util.LinkedList 대신 직접 구현한 경량 연결 리스트이다

     

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

    위 소스코드는 Stack 클래스에서처럼 단순히 버킷 배열의 clone() 메서드를 재귀적으로 호출한 clone() 메서드이다.

    위 clone() 메서드를 사용해 만든 복제본은 자신만의 버킷 배열을 갖지만, 이 배열은 원본과 동일한 연결 리스트를 참조하기 때문에, 원본과 복제본 모두 예기치 않게 동작할 가능성이 생긴다.

    이와 같은 문제를 해결하기 위해서는 각 버킷을 구성하는 연결 리스트를 복사해야 한다.

     

    public class HashTable implements Cloneable {
        private Map.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
        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();
            }
        }
        
    }

    private 클래스인 HashTable.Entry는 연결 리스트 전체를 복사하기 위해 자신을 재귀적으로 호출하는 깊은 복사(deep copy)를 지원하도록 보강했다.

    위 clone() 메서드는 먼저 적절한 크기의 새로운 버킷 배열을 할당한 다음 원래의 버킷 배열을 순회하며, 비지 않은 각 버킷에 대해 깊은 복사를 수행한다. 이때, Entry의 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;
    }

    앞에서 언급한 스택 오버플로우의 문제를 피하기 위해서는 위와 같이 deepCopy() 메서드를 재귀 호출 대신 반복자를 써서 순회하는 방향으로 수정해야 한다.

     

    고수준 API를 활용한 복제

    이제, 복잡한 가변 객체를 복제하는 마지막 방법을 살펴보겠다. 먼저, super.clone() 메서드를 호출하여 얻은 객체의 모든 필드를 초기 상태로 설정한 다음, 원본 객체의 상태를 다시 생성하는 고수준 메서드들을 호출한다.

    예를 들어, HashTable 클래스의 buckets 필드를 새로운 버킷 배열로 초기화한 다음 원본 테이블에 담신 모든 키/값 쌍 각각에 대해 복제본 테이블의 put(key, value) 메서드를 호출해 둘의 내용이 똑같게 해 주면 된다.

    이처럼, 고수준 API를 활용해 복제하면 보통은 간단하고 제법 우아한 코드를 얻게 되지만, 아무래도 저수준에서 바로 처리할 때보다는 느리다는 단점이 있다. 또한, Cloneable 아키텍처의 기초가 되는 필드 단위 객체 복사를 우회하는 방식이기 때문에, 전체 Cloneable 아키텍처와는 어울리지 않는 방식이기도 하다.


    clone 메서드 재정의 시 주의사항

    public인 clone 메서드에서는 throws 절을 없애야 한다.

    Object의 clone() 메서드

    public class Object {
        ...
       
        @HotSpotIntrinsicCandidate
        protected native Object clone() throws CloneNotSupportedException;
        
        ...
    }

     

    public인 clone() 메서드

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

    Object의 clone() 메서드는 CloneNotSupportedException을 던진다고 선언했지만 재정의한 메서드는 그렇지 않다. public인 clone() 메서드에서는 throws 절을 없애야 한다. 검사 예외를 던지지 않아야 그 메서드를 사용하기 편하기 때문이다.

     

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

    상속해서 쓰기 위한 클래스 설계 방식 두 가지 중 어느 쪽에서든, 상속용 클래스는 Cloneable을 구현해서는 안 된다.

    프로그래머들은 Object의 방식을 모방할 수도 있다. 제대로 작동하는 clone() 메서드를 구현해 protected로 두고 CloneNotSupportedException도 던질 수 있다고 선언하는 것이다. 이 방식은 마치 Object를 바로 상속할 때처럼 Cloneable 구현 여부를 하위 클래스에서 선택하도록 해준다.

     

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

    다른 방법으로는, clone() 메서드를 동작하지 않게 구현해놓고 하위 클래스에서 재정의하지 못하게 할 수도 있다. 위와 같이 하위 클래스에서 Cloneable을 지원하지 못하게 하는 clone() 메서드를 퇴화시켜놓으면 된다.

     

    clone 메서드의 동기화

    Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 clone() 메서드 역시 적절히 동기화해줘야 한다. Object의 clone() 메서드는 동기화를 신경 쓰지 않았다. 따라서, super.clone() 메서드 호출 외에 다른 할 일이 없더라도 clone() 메서드를 재정의하고 동기화해줘야 한다.


    복사 생성자와 복사 팩토리

    Cloneable을 이미 구현한 클래스를 확장한다면 어쩔 수 없이, clone() 메서드를 잘 작동하도록 해야 한다. 그렇지 않은 상황에서는 복사 생성자와 복사 팩토리라는 더 나은 객체 복사 방식을 제공할 수 있다.

     

    복사 생성자
    public Yum(Yum yum) {
        ...
    };

    복사 생성자란 위와 같이 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자를 의미한다.

     

    복사 팩토리
    public static Yum newInstance(Yum yum) {
        ...
    };

    복사 팩토리란 복사 생성자를 모방한 정적 팩토리다.

     

    Cloneable/clone 방식보다 나은 이유

    1. 언어 모순적이고 위험천만한 객체 생성 메커니즘(생성자를 쓰지 않는 방식)을 사용하지 않는다.

    2. 엉성하게 문서화된 규약에 기대지 않는다.

    3. 정상적인 final 필드 용법과도 충돌하지 않는다.

    4. 불필요한 검사 예외를 던지지 않는다.

    5. 형 변환도 필요하지 않다.

     

    또한, 복사 생성자와 복사 팩토리는 해당 클래스가 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있다. 관례상 모든 범용 컬렉션 구현체는 Collection이나 Map 타입을 받는 생성자를 제공한다. 이러한 인터페이스 기반 복사 생성자와 복사 팩토리의 더 정확한 명칭은 변환 생성자와 변환 팩토리다.

    이들을 이용하면 클라이언트는 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있다. 예를 들어, HashSet 객체 s를 TreeSet 타입으로 복제할 수 있다. clone() 메서드로는 불가능한 이 기능을 변환 생성자로는 간단히 new TreeSet<>(s)로 처리할 수 있다.

     

    최종 정리

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

    Cloneable을 이미 구현한 클래스를 확장한다면 어쩔 수 없이 clone() 메서드를 잘 작동하도록 구현해야 한다. 그렇지 않다면, 복사 생성자와 복사 팩토리라는 더 나은 객체 복사 방식을 제공할 수 있다.


    출처

     이펙티브 자바 Effective Java 3/E. 조슈아 블로크 저자(글) · 개앞맵시(이복연) 번역

     

    728x90

    댓글

Designed by Tistory.