4914 단어
25 분
[ Effective Java ] 객체의 복사

들어가며#

자바 애플리케이션을 개발하면서 동일한 객체의 상태를 보존하거나, 기존 객체를 기반으로 새로운 인스턴스를 생성해야 하는 상황이 자주 발생한다. 예를 들어, 현재 객체의 데이터를 유지하면서 일부 속성을 변경하거나, 객체를 안전하게 공유할 때 복제 기능이 필요하다.

이러한 요구에 대응하기 위해 자바는 clone() 메서드를 제공하며, 이 메서드는 객체의 복사본을 생성하는 데 사용된다. 이번 포스팅에서는 clone() 메서드를 제대로 사용하는 방법과 이 방법 외에 대안으로는 무엇이 있는지 알아보려 한다.

clone() 메서드#

clone() 메서드는 자신을 호출한 객체의 복사본을 만들어 반환한다. 여기서 “복사”의 의미는 해당 객체의 클래스에 따라 달라질 수 있다. 복제된 객체가 원본 객체와 얼마나 독립적인지를 각 클래스가 어떻게 정의하느냐에 따라 달라진다는 뜻이다.

clone() 메서드 사용 시 주의사항#

클래스 Object 자체는 Cloneable 인터페이스를 구현하지 않기 때문에, Object 클래스의 인스턴스에서 clone() 메서드를 호출하면 실행 중 예외가 발생한다.

그래서 만약 해당 객체의 클래스가 Cloneable 인터페이스를 구현하지 않았다면 CloneNotSupportedException이 발생한다. 또한, 모든 배열은 Cloneable 인터페이스를 구현하는 것으로 간주되며, 배열 타입 T[]clone() 메서드는 T가 참조형이거나 기본형일 때 T[]를 반환한다.

clone 규약#

clone도 equals와 같이 공식 문서에서 언급한 규약이 존재한다. 같이 규약을 코드와 함께 살펴보자.

예시 코드 살펴보기#

// PhoneNumber에 clone 메서드 추가 (79쪽)  
public final class PhoneNumber implements Cloneable {  
    private final short areaCode, prefix, lineNum;  
  
    public PhoneNumber(int areaCode, int prefix, int lineNum) {  
        this.areaCode = rangeCheck(areaCode, 999, "지역코드");  
        this.prefix   = rangeCheck(prefix,   999, "프리픽스");  
        this.lineNum  = rangeCheck(lineNum, 9999, "가입자 번호");  
        System.out.println("constructor is called");  
    }
  
    private static short rangeCheck(int val, int max, String arg) {  
        if (val < 0 || val > max)  
            throw new IllegalArgumentException(arg + ": " + val);  
        return (short) val;  
    }  
  
    // 코드 13-1 가변 상태를 참조하지 않는 클래스용 clone 메서드 (79쪽)  
    @Override  
    public PhoneNumber clone() {  
        try {  
            return (PhoneNumber) super.clone();  
        } catch (CloneNotSupportedException e) {  
            throw new AssertionError();  // 일어날 수 없는 일이다.  
        }  
    }  
  
    @Override 
    public boolean equals(Object o) {  
        if (o == this)  
            return true;  
        if (!(o instanceof PhoneNumber))  
            return false;  
        PhoneNumber pn = (PhoneNumber)o;  
        return pn.lineNum == lineNum && pn.prefix == prefix  
                && pn.areaCode == areaCode;  
    }  
  
    @Override 
    public int hashCode() {  
        int result = Short.hashCode(areaCode);  
        result = 31 * result + Short.hashCode(prefix);  
        result = 31 * result + Short.hashCode(lineNum);  
        return result;  
    }  

     @Override 
    public String toString() {  
        return String.format("%03d-%03d-%04d",  
                areaCode, prefix, lineNum);  
    }  
}

위 코드는 clone()을 구현한 PhoneNumber 클래스이다. clone() 메서드를 보면, 생성자를 통해 생성하는 것이 아니라 super.clone()으로 Object의 clone()을 실행시키는 것을 볼 수 있다.

// 기본적으로 Intellij에서 생성해주는 clone 메서드
@Override  
protected Object clone() throws CloneNotSupportedException {  
    return super.clone();  
}

// Object에서 정의하는 clone 메서드
@HotSpotIntrinsicCandidate  
protected native Object clone() throws CloneNotSupportedException;


// 코드 13-1 가변 상태를 참조하지 않는 클래스용 clone 메서드 (79쪽)  
@Override  
public PhoneNumber clone() {  
    try {  
        return (PhoneNumber) super.clone();  
    } catch (CloneNotSupportedException e) {  
        throw new AssertionError();  // 일어날 수 없는 일이다.  
    }  
}  

clone() 메서드는 기본적으로는 위 코드와 같이 protected 접근 지시자를 사용하고, 다른 코드 없이 super.clone()을 반환하고, CloneNotSupprotedException을 던진다. 기본적으로 Object 객체에 정의된 clone() 메서드를 보면 접근지시자와 예외를 던지는 방식이 같다는 것을 볼 수 있다. 어떤 클래스가 상속을 할 때 상위클래스에 있는 접근지시자보다 넓은 범위를 가져야 한다. 그리고 protected를 그대로 사용하는 것은 의미가 없다. 결국 객체를 복사하는 경우는 이 클래스의 범위 바깥에서 이루어져야 하는데 protected를 사용하면 그럴 수가 없다.

한가지 또 알아볼 점은 생성자를 통해 새로운 객체를 생성하지 않는다는 것이다. 이 clone()이 어떻게 실행되는지 이해하기 위해서는 자바 코드만으로는 불가능하다. 그래서 일단 “clone()을 통해서 새로운 인스턴스를 만들 수 있다.” 라고 받아들이고 넘어가는 것이 좋아보인다.(지금 어떤 방식으로 동작하는지 알기 위해 JVM 코드까지 뜯어보고 있지만, 시간이 좀 걸릴 것 같다. 추후에 다시 다뤄보려 한다.)

그리고 clone() 을 overriding 하는 과정에서 반환값이 Object가 아니라 Object의 하위 타입인 PhoneNumber인 것을 볼 수 있다. Java에서는 overriding하는 메서드의 하위타입을 선언해도 overriding으로 인정한다. 이걸 공변 리턴 타입이라고 한다.(Convariant Return Type)

clone한 객체는 반드시 원본과는 다른 인스턴스여야 한다.#

public static void main(String[] args) {  
    PhoneNumber pn = new PhoneNumber(707, 867, 5309);  
    Map<PhoneNumber, String> m = new HashMap<>();  
    m.put(pn, "제니");  
    PhoneNumber clone = pn.clone();  
    System.out.println(m.get(clone));  
  
    System.out.println(clone != pn); // 반드시 true          
}  

clone한 객체는 원본과 주소 연산자로 비교했을 때 반드시 다른 인스턴스여야 한다. 즉, 주소 연산자로 비교했을 때다른 인스턴스여야 하는데, 이는 객체의 논리적 동치성(equals())와는 별개의 문제이다. 위 코드에서는 PhoneNumber 객체를 복제한 후, HashMap에 저장된 값을 clone 객체로 조회할 수 있음을 보여준다.

clone한 객체는 원본과 클래스가 동일한 클래스여야 한다.#

public static void main(String[] args) {  
    PhoneNumber pn = new PhoneNumber(707, 867, 5309);  
    Map<PhoneNumber, String> m = new HashMap<>();  
    m.put(pn, "제니");  
    PhoneNumber clone = pn.clone();  
    System.out.println(m.get(clone));  
  
    System.out.println(clone != pn); // x.clone() != x    반드시 true 
    System.out.println(clone.getClass() == pn.getClass()); // 반드시 true        
}  

clone한 객체는 반드시 원본과 동일한 클래스여야 한다. 이는 clone된 객체의 getClass() 메서드 결과가 원본의 getClass()와 동일함을 의미한다. 이 규칙은 상속 관계에서 하위 클래스가 부모 클래스의 clone 메서드를 오버라이딩할 때도 일관성을 유지할 수 있도록 한다.

clone한 객체는 원본과 equals()로 비교했을 때 같을 수도 있고 다를 수도 있다.#

public static void main(String[] args) {  
    PhoneNumber pn = new PhoneNumber(707, 867, 5309);  
    Map<PhoneNumber, String> m = new HashMap<>();  
    m.put(pn, "제니");  
    PhoneNumber clone = pn.clone();  
    System.out.println(m.get(clone));  
  
    System.out.println(clone != pn); // x.clone() != x    반드시 true 
    System.out.println(clone.getClass() == pn.getClass()); // x.clone().getClass() == x.getClass() 반드시 true        
    System.out.println(clone.equals(pn)); // true가 아닐 수도 있다.  
}  

clone한 객체는 원본과 equals()로 비교했을 때 같을 수도 있고 다를 수도 있다. 이는 equals() 메서드가 객체의 논리적 상태를 비교하기 때문인데, clone()이 내부적으로 bitwise 복사를 수행함에 따라, 모든 필드가 동일하게 복사된다면 equals() 결과는 true가 될 수 있다. 하지만, 만약 객체가 가변 상태를 포함하거나 equals() 구현 방식에 따라 달라진다면, clone된 객체와 원본 객체가 논리적으로 동일하지 않을 수도 있다.

clone()에서 불변객체와 가변객체#

다음 코드와 같은 상황을 가정해보자.

// Stack의 복제 가능 버전 (80-81쪽)  
public class Stack implements Cloneable {  
    private Object[] elements;  
    private int size = 0;  
    private static final int DEFAULT_INITIAL_CAPACITY = 16;  
  
    public Stack() {  
        this.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;  
    }  
  
    public boolean isEmpty() {  
        return size ==0;  
    }    
}

위 코드는 스택 코드이다. 여기서 스택을 복사한다고 가정해보자.

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

우리가 전에 했던 대로 위 코드로 Stack을 복사하게 되면 문제가 없을까?

public static void main(String[] args) {  
    Object[] values = new Object[2];  
    values[0] = new PhoneNumber(123, 456, 7890);  
    values[1] = new PhoneNumber(321, 764, 2341);  
  
    Stack stack = new Stack();  
    for (Object arg : values)  
        stack.push(arg);  
  
    Stack copy = stack.clone();  
  
    System.out.println("pop from stack");  
    while (!stack.isEmpty())  
        System.out.println(stack.pop() + " ");  
  
    System.out.println("pop from copy");  
    while (!copy.isEmpty())  
        System.out.println(copy.pop() + " ");  
  
    System.out.println(stack.elements[0] == copy.elements[0]);  
}

위 테스트 코드는 전화번호를 두개 생성하고, 원본 스택에 넣는다. 이후 원본 스택을 복사한 후 결과를 볼 수 있다. 다음 코드를 실행시켜보자. clone1.png 보면 복사했다면 같은 값을 pop 했어야 하는데, null을 내보내고 있다. 왜 그렇게 된 걸까? 원본 stack과 복사된 stack이 같은 레퍼런스를 가리키고 있어서 그렇다. clone3.png 기본적으로 Object의 clone()은 객체의 각 필드를 단순히 복사한다. 이 경우, 만약 객체가 다른 객체에 대한 참조를 포함하고 있다면, 복제된 객체와 원본 객체는 그 참조를 공유하게 된다. 즉, 내부의 변경 가능한 객체들은 원본과 복제본 모두에서 동일한 객체를 가리킨다. 따라서 필드가 가변적일 경우, clone은 각 필드까지 복사를 수행해야 한다.

그래서 위 stack의 경우에, clone() 시에 변할 수 있는 element를 같이 복사해 주지 않으면, 원본 stack 도 element를 참조하고, 복사된 stack도 원본 element를 참조하게 된다.

그래서 다음과 같이 추가적인 복사를 해주어야 한다.

// 코드 13-2 가변 상태를 참조하는 클래스용 clone 메서드
@Override 
public Stack clone() {  
    try {  
        Stack result = (Stack) super.clone();  
        result.elements = elements.clone();  
        return result;  
    } catch (CloneNotSupportedException e) {  
        throw new AssertionError();  
    }  
}

이 경우에는 elements 까지 복사를 하게 되어 깊은 복사를 수행하게 된다. 결과를 보면

clone2.png

정상적으로 복사된 것을 볼 수 있다.

가변객체의 깊은 복사#

하지만, 방금 봤던 방식도 완벽한 깊은 복사는 아니다. 객체 배열에 넣어줬던 PhoneNumber가 불변객체였기 때문에 문제가 없었던 것이지, 만약 PhoneNumber가 가변 객체였다면 똑같은 문제가 생겼을 가능성이 높다.

그래서 제대로 된 깊은 복사를 하는 방법은 다음 코드에 나와 있다.

public class HashTable implements Cloneable {  
  
    private Entry[] buckets = new Entry[10];  
  
    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;  
        }  
  
        public void add(Object key, Object value) {  
            this.next = new Entry(key, value, null);  
        }  

// 이 방법은 스택오버플로우를 발생시킬 수 있어 위험하다.
//        public Entry deepCopy() {  
//            return new Entry(key, value, next == null ? null : next.deepCopy());  
//        }  
  
        public 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;  
        }  
    }  
  

    @Override  
    public HashTable clone() {  
        HashTable result = null;  
        try {  
            result = (HashTable)super.clone();  
            result.buckets = new Entry[this.buckets.length];  // 새로운 배열 생성
  
            for (int i = 0 ; i < this.buckets.length; i++) {  
                if (buckets[i] != null) {  
                    result.buckets[i] = this.buckets[i].deepCopy(); // p83, deep copy  
                }  
            }  
            return result;  
        } catch (CloneNotSupportedException e) {  
            throw  new AssertionError();  
        }  
    }
}

위 코드는 해시 테이블의 깊은 복사를 구현하는 예제이다. 먼저, 클래스가 Cloneable을 구현하여 기본적인 복제 기능을 제공하지만, 단순히 super.clone()을 호출하면 내부에 포함된 배열과 연결 리스트 구조의 Entry 객체들까지 얕은 복사가 되어 원본과 복제본이 동일한 객체들을 참조하게 된다.

이를 방지하기 위해 clone() 메서드에서는 새로운 배열을 생성한 후, 배열의 각 요소에 대해 Entry 객체의 deepCopy() 메서드를 호출하여 깊은 복사를 수행한다. 여기서 deepCopy() 메서드는 원래 재귀 방식으로 작성할 경우 스택 오버플로우 위험이 있을 수 있어 for문을 사용해 연결 리스트를 순회하며 각 노드를 새로운 Entry 인스턴스로 복사해준다. 이와 같이 모든 내부 객체까지 새롭게 생성함으로써, 원본 객체의 변경이 복제본에 영향을 미치지 않도록 완전한 독립적인 복제본을 만들어야 한다.

clone() 사용시 주의점#

주의해야 할 점 1#

그리고 주의해야 할 점이 또 하나 있는데, clone() 메서드도 생성자와 같이 재정의될 수 있는 메서드를 호출하지 말아야 한다. 가령

@Override  
public HashTable clone() {  
    HashTable result = null;  
    try {  
        result = (HashTable)super.clone();  
        // result.buckets = new Entry[this.buckets.length];  // 새로운 배열 생성
        result = createNewBuckets(this.buckets.length);
        for (int i = 0 ; i < this.buckets.length; i++) {  
            if (buckets[i] != null) {  
                result.buckets[i] = this.buckets[i].deepCopy(); // p83, deep copy  
            }  
        }  
        return result;  
    } catch (CloneNotSupportedException e) {  
        throw  new AssertionError();  
    }  
}

public Entry[] createNewBucket(int length){
	// 이런 방식으로 재정의하지 말아야 한다. 
}

위 코드와 같은 방식으로 제정의될 수 있는 메서드를 호출하지 말아야 한다. 만약 clone()이 재정의한 메서드를 호출하면, 하위 클래스는 복제 과정에서 자신의 상태를 교정할 기회를 잃게 되어 원본과 복제본의 상태가 달라질 가능성이 크다.

주의해야 할 점 2#

/**  
 * p84, p126 일반적으로 상속용 클래스에 Cloneable 인터페이스 사용을 권장하지 않는다.  
 * 해당 클래스를 확장하려는 프로그래머에게 많은 부담을 주기 때문이다.  
 */
 public abstract class Shape implements Cloneable {  
  
    private int area;  
  
    public abstract int getArea();  
}

상속용 클래스는 Cloneable을 구현해서는 안된다. 만약 이렇게 작성하게 된다면 이 클래스를 확장할 개발자들에게 많은 짐을 덜어주는 것과 같다. 개발자들은 Cloneable을 올바르게 구현하지 않은 경우, 지금 정리한 모든 내용을 인지하고 조심히 코드를 짜야 한다. 사실상 정말 쉽지 않다.

위 주의점들 해결하기 위해서는 두가지 방법이 있다.

/**  
 * p84, p126 일반적으로 상속용 클래스에 Cloneable 인터페이스 사용을 권장하지 않는다.  
 * 해당 클래스를 확장하려는 프로그래머에게 많은 부담을 주기 때문이다.  
 */public abstract class Shape implements Cloneable {  
  
    private int area;  
  
    public abstract int getArea();  
  
  
    /**  
     * <방법 1>
     * p84, 부담을 덜기 위해서는 기본 clone() 구현체를 제공하여,  
     * Cloenable 구현 여부를 서브 클래스가 선택할 수 있다.  
     * @return     * @throws CloneNotSupportedException  
     */  
    @Override  
    public Object clone() throws CloneNotSupportedException {  
        return super.clone();  
    }  
  
    /**  
     * <방법 2>
     * p85, Cloneable 구현을 막을 수도 있다.  
     */    
    @Override  
    protected final Object clone() throws CloneNotSupportedException {  
        throw new CloneNotSupportedException();  
    }  
	// 예시를 위한 코드이며, 실제 환경에서는 clone 메서드가 두개가 있기 때문에 동작하지 않는다.
}

하나는 Cloneable을 직접 구현해서 하위 클래스가 구현하지 않게 하는 방법이 있고, 나머지 하나는 하위 클래스에서 Cloneable을 아예 사용하지 못하게 하는 방법이 있다.

첫 번째 방법은, 서브클래스가 별도로 clone() 메서드를 구현하지 않아도 기본적인 객체 복사가 가능하도록 Shape 클래스에서 기본 clone() 메서드를 오버라이드하여 super.clone()을 호출하는 방식이다. 이 방식은 기본적으로 얕은 복사를 제공하여 서브클래스 개발자의 부담을 덜어주며, 필요에 따라 복제 방식을 변경할 수 있는 유연성을 제공한다.

반면, 두 번째 방법은 상속받은 클래스가 Cloneable 기능을 아예 사용할 수 없도록 clone() 메서드를 final로 선언하고 무조건 CloneNotSupportedException 예외를 발생시키는 형태로 구현되어 있다. 이를 통해 객체의 복제를 차단하여 복제로 인한 예기치 않은 부작용이나 오류를 방지할 수 있다.

주의해야 할 점3#

만약 clone()을 멀티스레드 환경이 안전한, 스레드 세이프한 클래스로 만들어져야 한다면 동기화 처리, synchronized를 붙여야 한다.

@Override  
public synchronized Object clone() throws CloneNotSupportedException {  
    return super.clone();  
}

대안: 생성자 copy#

위 방법을 모두 인지하고 개발하는 것도 좋지만, 현실적으로 쉽지 않다. 그래서 대안으로 사용하는 방법이 생성자 복제이다.

public final class PhoneNumber implements Cloneable {  
    private final short areaCode, prefix, lineNum;  
  
    public PhoneNumber(int areaCode, int prefix, int lineNum) {  
        this.areaCode = rangeCheck(areaCode, 999, "지역코드");  
        this.prefix   = rangeCheck(prefix,   999, "프리픽스");  
        this.lineNum  = rangeCheck(lineNum, 9999, "가입자 번호");  
//        System.out.println("constructor is called");  
    }  
  
    public PhoneNumber(PhoneNumber phoneNumber) {  
        this(phoneNumber.areaCode, phoneNumber.prefix, phoneNumber.lineNum);  
    }
    
    ...
}

위 코드와 같이 PhoneNumber를 매개변수로 받아 새로운 객체를 만들어주는 방법이 있다.

장점: final을 설정해 줄 수 있다.#

public class HashTable implements Cloneable {  
  
    private final Entry[] buckets = new Entry[10];  // final로 지정하게 되면

	...
	
    @Override  
    public HashTable clone() {  
        HashTable result = null;  
        try {  
            result = (HashTable)super.clone();  
            result.buckets = new Entry[this.buckets.length];  // 해당 코드 사용 불가
  
            for (int i = 0 ; i < this.buckets.length; i++) {  
                if (buckets[i] != null) {  
                    result.buckets[i] = this.buckets[i].deepCopy();
                }  
            }  
            return result;  
        } catch (CloneNotSupportedException e) {  
            throw  new AssertionError();  
        }  
    }
}

clone() 메서드는 내부적으로 새로운 배열이나 객체를 재할당해야 할 때 final 제한으로 인해 문제가 발생할 수 있다.(final로 지정시 객체 생성을 제외한 시점에 해당 필드는 변경을 하지 못한다.) 생성자 복제 방식은 새로운 객체를 생성하면서 모든 필드를 생성 시에 초기화하기 때문에 이러한 제약이 없다.

장점: 상위 클래스의 타입을 받아서 사용할 수 있다.#

clone()메서드의 경우 타입 변환이나 추가 처리를 수행하기 어렵다. 하지만 생성자 copy 방식을 활용하면 clone()과 다르게 상위 클래스의 타입을 매개변수로 받아 복제를 진행할 수 있어, 다형성을 보다 유연하게 활용할 수 있다.

public class HashSetExample {  
  
    public static void main(String[] args) {  
        Set<String> hashSet = new HashSet<>();  
        hashSet.add("speculatingwook");  
        hashSet.add("temurin");  
        System.out.println("HashSet: " + hashSet);  
  
        Set<String> treeSet = new TreeSet<>(hashSet);  
  
        System.out.println("TreeSet: " + treeSet);  
    }  
}

위 코드 예제에서는 먼저 HashSet을 생성하여 문자열 데이터를 추가한 후, 이 HashSet을 인자로 받아 TreeSet의 생성자를 호출함으로써 기존 컬렉션의 데이터를 복제하는 방식을 보여준다.

HashSetExample 클래스의 main 메서드에서 hashSet 객체에 두 개의 문자열을 추가하고 이를 출력한다. 이후, hashSet을 매개변수로 전달하여 TreeSet 객체를 생성하는데, 이 과정에서 TreeSet 생성자는 전달된 컬렉션의 모든 요소를 복사하면서 자연 정렬 순서에 따라 정렬된 새로운 컬렉션을 생성한다. 이 방식은 clone() 메서드를 사용하는 경우와 달리, 상위 클래스인 Set 인터페이스의 타입을 그대로 매개변수로 받아 복제가 가능하다는 점에서 큰 장점을 지닌다.

출처#

  • 이펙티브 자바 아이템 13: clone() 재정의는 주의해서 진행하라.
  • 백기선의 이펙티브 자바(인프런)
  • Object 공식 문서 - clone()
[ Effective Java ] 객체의 복사
https://blog-full-of-desire-v3.vercel.app/posts/java/effective-java/item13/clone-constructor-copy/
저자
SpeculatingWook
게시일
2025-03-12
라이선스
CC BY-NC-SA 4.0