티스토리 뷰

728x90
반응형

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


  • Cloneable 인터페이스는 복제해도 되는 클래스임을 명시하는 용도의 마커 인터페이스입니다.
  • 객체를 복사하고 싶다면 Cloneable 인터페이스를 구현하여 clone 메서드를 재정의하는 방법이 일반적입니다.
  • 그러나 clone 메서드가 선언된 곳은 Cloneable 인터페이스가 아닌 Object 클래스에 선언되어 있고 접근 제한자가 protected 입니다. 그래서 Cloneable 인터페이스를 구현한 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없고 리플렉션을 사용하면 가능하지만 100% 성공하는것도 아닙니다. 접근할려는 객체가 접근이 허용된 clone 메서드를 제공하지 않는다면 성공을 할 수 없습니다.
public class Object {

    @HotSpotIntrinsicCandidate
    protected native Object clone() throws CloneNotSupportedException;
	
    ...생략
}

 

Cloneable 인터페이스의 용도


  • Object 클래스에 선언된 clone 메서드의 동작 방식을 결정할 수 있습니다.
  • Cloneable 인터페이스를 구현한 클래스의 인스턴스에서 clone 메서드를 호출하면 그 객체의 필드를 하나하나 복사한 객체를 반환하며, 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 발생시키게 됩니다.
  • 일반적으로 인터페이스를 구현한다는 것은 구현 클래스가 그 인터페이스에 정의한 기능을 제공한다고 선언하는 행위 입니다. 그런데 Cloneable의 경우에는 상위 클래스(Object)에 정의된 clone 메서드의 동작 방식을 변경한 것입니다.
@ToString
public class Product {

    private String name;
    private int price;

    public Product(String name, int price) {
        this.name = name;
        this.price = price;
    }

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

public class EffectiveJavaApplication {

    public static void main(String[] args) throws CloneNotSupportedException {

        Product macbook = new Product("맥북", 1000000);
        Product copyMacbook = (Product) macbook.clone();

        System.out.println(copyMacbook.toString());
    }
}

Cloneable 인터페이스를 구현하지 않아 예외 발생

 

clone 메서드의 규약


  • x.clone() != x 은 참이어야 합니다.
    • 복사된 객체가 원본객체랑 같은 주소를 가지면 안됩니다.
  • x.clone().getClass() == x.getClass() 식도 참이어야 합니다.
    • 복사된 객체가 같은 클래스여야 합니다.
  • (optional) x.clone().equals(x) 는 참이어야 하지만, 필수는 아닙니다.
    • 복사된 객체가 논리적 동치는 일치해야하지만 필수는 아닙니다.
  • 위 규약을 보면 clone 메서드의 반환값이 복사될 객체를 가르키기에 생성자 연쇄와 유사한데 이 말인즉슨 clone 내부로직이 생성자를 호출해 얻은 인스턴스를 반환해도 문제가 없다는 것입니다. 하지만 이렇게 되면 해당 클래스의 하위 클래스에서 super.clone() 메서드로 호출할 때 상위 객체에서 잘못된 클래스가 생성될 수 있기에 위험합니다.

 

문제점


  • 클래스의 모든 필드가 기본 타입이거나 불변 객체를 참조한다면 super.clone() 메서드만으로도 문제없이 작동합니다.
  • 아래 예제 코드와 같이 객체의 필드가 String, int로 기본 타입인 경우 clone 메서드에서 super.clone 메서드 호출만으로 정상적인 
    동작을 할 수 있습니다. 그리고 자바는 공변 반환 타이핑을 지원하므로 반환 타입을 상위 클래스의 하위 타입으로 형변환해 줄 수 있습니다. 그리고 try-catch 블록으로 예외를 대응했지만 해당 예외는 발생하지 않을 것입니다. 그렇기에 Unchecked Exception이였어야 한다는 신호가 됩니다.
public class Product implements Cloneable {

    private String name;
    private int price;

    public Product(String name, int price) {
        this.name = name;
        this.price = price;
    }

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

 

🧨 하지만 가변 객체를 참조하는 클래스의 clone을 재정의 하는경우에는 위의 예제는 문제가 발생할 수 있습니다.

  • clone 메서드를 통해 복사된 객체는 Products 클래스의 products 필드의 주소를 참조하게 됩니다. 그렇기 때문에 원본과 복제본의 
    products 필드가 서로에게 영향을 주게 되면서 불변식을 해치고 데이터 오염이 발생할 수 있습니다.
public class Product {

    private String name;
    private int price;

    public Product(String name, int price) {
        this.name = name;
        this.price = price;
    }
}

public class Products implements Cloneable {

    private static final int BUFFER_SIZE = 16;
    private Product[] products = new Product[BUFFER_SIZE];
    private int size;

    public void push(Product product) {
        ensureCapacity();
        products[size++] = product;
    }

    public Object pop(int index) {
        if (size == 0) {
            throw new IllegalArgumentException();
        }

        Object result = products[--size];
        products[size] = null;
        return result;
    }

    private void ensureCapacity() {
        if (products.length == size) {
            products = Arrays.copyOf(products, 2 * size + 1);
        }
    }

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

public class EffectiveJavaApplication {

    public static void main(String[] args) throws CloneNotSupportedException {

        Products products = new Products();
        products.push(new Product("그램", 100000));
        products.push(new Product("맥북", 100000));

        Products copyProducts = products.clone();
        System.out.println("product: " + products);
        System.out.println("copyProducts: " + copyProducts);

        copyProducts.push(new Product("삼성", 500000));
        System.out.println("product: " + products);
        System.out.println("copyProducts: " + copyProducts);

    }
}

 

대안책


  • clone 메서드는 생성자와 같은 효과를 내는데 원본과 동일한 내용을 원본에 영향을 주지 않으며 복제된 객체의 불변식을 보장해야 합니다.

💡 재귀적 호출

  • 가장 쉬운 방법은 객체의 참조 변수나 배열의 clone을 재귀적으로 호출해주는 것입니다.
  • 하지만 final 필드는 새로운 값을 할당할 수 없기 때문에 코드가 동작안할 가능성이 있습니다. 복제 가능한 클래스를 만들기 위해서는 final을 해제해야 하는 필드들도 있습니다.
  • 내부적으로 배열을 재귀적 호출로 clone 메서드를 호출해서 연결할 수 있지만, 이 배열이 객체 배열이고 연결 리스트라면 원본과 복사본은 같은 열결 리스트를 참조하여 의도치 않게 동작할 수 있습니다.

💡 Deep Copy

  • 깊은 복사를 이용하여 위의 문제를 해결할 수 있습니다.
public class Product {

    private String name;
    private int price;

    public Product(String name, int price) {
        this.name = name;
        this.price = price;
    }

    // 복사 생성자
    public Product(Product product) {
        this.name = product.name;
        this.price = product.price;
    }

    // 복사 팩토리
    public static Product copyFactory (Product product) {
        return new Product(product.name, product.price);
    }
}

public class Products implements Cloneable {

    private static final int BUFFER_SIZE = 16;
    private Product[] products = new Product[BUFFER_SIZE];
    private int size;

    public void push(Product product) {
        ensureCapacity();
        products[size++] = product;
    }

    public Object pop() {
        if (size == 0) {
            throw new IllegalArgumentException();
        }

        Object result = products[--size];
        products[size] = null;
        return result;
    }

    private void ensureCapacity() {
        if (products.length == size) {
            products = Arrays.copyOf(products, 2 * size + 1);
        }
    }

    @Override
    public Products clone() {
        try {
            Products result = (Products) super.clone();
            result.products = new Product[this.products.length];

            int count = 0;
            for (int i = 0; i < this.size; i++) {
                Product copy = Product.copyFactory(this.products[i]);
                result.products[count++] = copy;
            }

            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

public class EffectiveJavaApplication {

    public static void main(String[] args) throws CloneNotSupportedException {
        SpringApplication.run(EffectiveJavaApplication.class, args);

        Products products = new Products();
        products.push(new Product("그램", 100000));
        products.push(new Product("맥북", 200000));

        Products copyProducts = products.clone(); // 복시
        copyProducts.push(new Product("삼성", 500000));
        copyProducts.push(new Product("아이폰 미니 12", 100000));
        products.push(new Product("아이폰 맥스 12", 150000));

        products.pop();
        products.pop();
        copyProducts.pop();
    }
}

 

주의점


  • 생성자에서는 재정의될 수 있는 메서드를 호출하지 않아야 하며, 이는 clone 메서드도 동일합니다.
    • 만약 clone이 하위 클래스에서 재정의한 메서드를 호출하면 하위 클래스는 복제 과정에서 자신의 상태를 바꿀 기회가 사라지며 복제본과 원본의 상태가 달라질 수 있습니다.
  • 재정의한 clone 메서드는 throws 절을 없애야 합니다.
  • Cloneable 인터페이스를 구현한 모든 클래스는 clone 메서드를 재정의해야 합니다.
  • 상속용 클래스는 Cloneable 클래스를 구현해서는 안됩니다. 아니면 구현을 할 수 없도록 선언해야 합니다.
@Override
protected final Object clone() throws CloneNotSupportedException {
        throw new CloneNotSupportedException();
}

 

복사 생성자와 복사 팩토리


  • 이미 Cloneable 인터페이스를 구현한 클래스는 어쩔 수 없지만 그게 아니라면 복사 생성자나 복사 팩토리라는 객체 복사 방식을 고려할만 합니다.
  • 이 두 방식을 사용하면 Cloneable/clone와 비교해서 더 나은점은 아래와 같습니다.
    • 언어 모순적이고 생성자를 쓰지 않는 객체 생성 매커니즘을 사용하지 않습니다.
    • 정상적인 final 필드 용법과도 충돌하지 않습니다.
    • 불필요한 예외가 발생하지 않습니다.
    • 형변환도 필요하지 않습니다.
public class Product {

    private String name;
    private int price;

    public Product(String name, int price) {
        this.name = name;
        this.price = price;
    }

    // 복사 생성자
    public Product(Product product) {
        this.name = product.name;
        this.price = product.price;
    }

    // 복사 팩토리
    public static Product copyFactory (Product product) {
        return new Product(product.name, product.price);
    }
}

 

💡 정리

  • 복사가 필요하면 복제 생성자 및 팩토리를 사용하는게 좋습니다.
  • 오직 배열만이 clone 메서드 방식의 사용이 권장됩니다.
  • 클래스를 새로 만들때는 Cloneable 인터페이스를 확장하지 않는것이 좋습니다.

 

 

참고자료)

https://catsbi.oopy.io/313829e4-e869-48fe-9fb4-adcca06de1c5

https://daldalhanstory.tistory.com/282

https://jake-seo-dev.tistory.com/31

 

 

 

728x90
반응형