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

다람·2025년 4월 29일
0

Effective Java

목록 보기
13/13
post-thumbnail

1. Cloneable과 clone() 메서드 개요

자바에서 객체 복사를 지원하기 위해 Cloneable 인터페이스와 clone() 메서드가 존재한다.

  • Cloneable은 메서드가 하나도 없는 인터페이스다.
    - 메서드가 하나도 없이 단순하게 복사할 수 있는 객체라고 표시만 한다.
    • 이렇게 표시만 하는 인터페이스를 마커 인터페이스(Marker Interface)라고 부른다.
      업로드중..
  • clone() 메서드는 Object클래스에 protected로 선언되어 있고, 직접 호출이 불가능하다.
  • Cloneable 인터페이스를 구현하지 않은 객체에서 clone()을 호출하면 CloneNotSupportedException 예외가 발생한다.
  • 즉, Cloneable을 구현해야만 clone() 메서드를 정상적으로 호출할 수 있다.
  • Cloneable 인터페이스를 구현한 객체에서 clone()을 호출하면 필드별 폭사(얕은 복사)를 수행하게 된다.

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

  • Cloneable을 구현했다고 해서 외부에서 바로 clone()을 호출할 수는 없다.
    • clone() 메서드는 기본적으로 protected 접근 제어자이기 때문이다.
    • 따라서 clone을 외부에서 사용할 수 있도록 열어주려면 public으로 재정의해줘야 한다.
  • super.clone() 호출은 필수다.
    • Object의 clone()은 단순한 메모리 복사를 수행하기 때문이다.
    • super.clone()을 호출하면 객체를 복사할 수 있다.
public class PhoneNumber implements Cloneable {
    @Override
    public PhoneNumber clone() {
        try {
            return (PhoneNumber) super.clone(); // ①
        } catch (CloneNotSupportedException e) {
            throw new AssertionError(); // ② 발생할 수 없음
        }
    }
}

① super.clone()은 Object의 protected Object clone() 메서드를 호출한다.
② AssertionError()를 던진다.

처음에 읽을 때 왜 catch문 내부에서 AssertionError()를 던지는지 이해가 안됐었다.
이 클래스는 이미 Cloneable을 구현했기 때문에 예외가 발생할 일이 없기 때문이다.
catch 블록은 사실상 절대 실행되지 않는 코드다. 그래서 AssertionError를 던져 "일어날 수 없는 일이다."라는 것을 명시해주기 위해서 작성된 것이였다.

3. 얕은 복사와 깊은 복사

구분얕은 복사(Shallow Copy)깊은 복사(Deep Copy)
정의필드의 참조값만 복사참조 대상 객체 자체도 복사
특징원본과 복사본이 같은 객체를 가리킴독립적인 새로운 객체 생성
예시배열 필드 복사 시, 배열 객체 자체는 공유배열 필드 복사 시, 새로운 배열도 생성해서 복사
위험성한 쪽을 수정하면 다른 쪽에도 영향서로 완전히 독립적

clone() 메서드는 기본적으로 얕은 복사를 한다.
만약 깊은 복사를 해야된다면 clone()메서드 안에 별도로 복사 로직을 추가해줘야한다.

4. clone() 사용은 신중히

  • clone()를 사용할 때 원본 객체에 아무 해를 끼치지 않으면서 동시에 복제된 객체의 불변식을 보장해줘야한다.
  • clone()은 생성자와 비슷한 책임을 가지지만, 명확한 초기화 과정을 보장하지 않는다.
  • 얕은 복사만 수행하므로 복잡한 객체 그래프(예: 배열, 다른 객체를 참조하는 경우)에서는 문제를 일으킬 수 있다.
  • 동기화 문제(멀티스레드 환경)도 발생할 수 있다.

4.1. 가변 객체는 주의가 필요하다

  • 가변 객체를 복사하게 되면 clone()은 얕은 복사를 수행하기 때문에 복사한 객체도 같은 객체를 가리키게되어서 복사한 객체를 수정하는 경우 원본도 수정되어 문제가 발생할 수 있다.
    • 내부 필드가 가변 객체(예: 배열, 리스트 등) 일 경우 복제된 객체와 원본이 같은 객체를 공유하게 된다.
  • 이런 문제를 피하려면 clone 내부에서 해당 가변 필드도 복사해줘야 한다(깊은 복사).

Stack 클래스

public class Stack {
    private Object[] elements;

    // 생성자 생략

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

    // 메서드 생략
}

위의 코드는 잘못된 clone() 메서드 재정의 방식이다.
원본 객체에 영향을 끼치지 않도록 하려면 아래와 같이 elements 배열의 clone을 재귀적으로 호출해줘야한다.

    @Override
    public Stack clone() {
        try {
			Stack result = (Stack) super.cloneO;
            result.elements = elements.clone(); // 배열까지 복제(깊은 복사)
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
  • 배열은 clone 기능을 유일하게 제대로 사용하는 예시이기 때문에 배열을 복제할 때는 clone 메서드 사용을 권장한다.

위의 예제에서 만약 elements 필드가 final이였다면 위의 코드는 작동하지 않을 것이다. final 키워드로 작성된 필드에는 새로운 값을 할당할 수 없기 때문이다.

  • Cloneable 아키텍처는 가변 객체를 참조하는 필드는 final로 선언하라는 용법과 충돌한다.
    • clone()을 사용할 때 복사 대상 객체의 불변성을 유지하고 오류 가능성을 줄이기 위해서 final로 선언하라는 뜻이다.
  • 복제할 수 있는 클래스를 만들기 위해 일부 필드에서 final 키워드를 제거해야 할 수도 있다.

deepCopy()

자바의 HashTable처럼 내부에 가변 객체(예: 배열, 리스트, 키-값 쌍 등)를 가지고 있는 객체는 clone()을 재정의할 때 깊은 복사를 해주어야 한다.

public class MyHashTable implements Cloneable {
    private Entry[] buckets;

    static class Entry {
        final String key;
        String value;
        Entry next;

        Entry(String key, String 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 MyHashTable clone() {
        try {
            MyHashTable result = (MyHashTable) 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();
        }
    }
}
  • 위의 코드는 buckets 배열 안에 있는 Entry 객체들을 재귀적으로 깊은 복사 하는 방식이다.
  • 이처럼 복잡한 객체 그래프가 있는 경우 clone() 구현은 매우 주의해서 작성해야 한다.
  • 추가로 buckets 배열이 너무 길지 않을 때는 잘 작동하겠지만 재귀 호출을 하기 때문에 stack overflow가 발생할 수 있어서 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
        }

4.2. 복사 생성자와 복사 팩터리

  • 복잡하고 위험한 clone 방식 대신 아래처럼 복사 생성자를 사용하는 것도 좋은 대안이다.
    • 복사 생성자 : 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자
    • 복사 팩터리 : 복사 생성자를 모방한 정적 팩터리
public class PhoneNumber {
    private final int areaCode, prefix, lineNumber;

    public PhoneNumber(PhoneNumber original) { // 복사 생성자
        this.areaCode = original.areaCode;
        this.prefix = original.prefix;
        this.lineNumber = original.lineNumber;
    }

    public static PhoneNumber copyOf(PhoneNumber original) { // 복사 팩터리
        return new PhoneNumber(original);
    }
}
  • 명확하고 안전하다.
  • 클래스를 상속하지 않아도 된다.
  • final 필드 용법과 출돌하지 않는다.
  • 불필요한 예외처리가 필요하지 않다.
  • 형변환이 필요없다.

6. 결론

  • Cloneable을 구현하면 반드시 clone()을 재정의해야 하며, super.clone()을 호출해야 한다.
  • 하지만 많은 경우 복사 생성자나 복사 팩터리를 사용하는게 더 좋은 방법이다.
  • 깊은 복사를 구현할 경우, 내부 객체 구조를 꼼꼼히 관리해야 한다.
  • 새로운 클래스나 라이브러리를 설계할 때는 Cloneable를 확장하면 안되고, 구현해서는 안된다.
  • 배열만은 clone() 사용이 간단하고 효율적이므로 예외로 둘 수 있다.
profile
개발하는 다람쥐

0개의 댓글