Java로 코딩테스트 문제를 풀던 중, 2차원 배열을 clone() 메서드로 복제한 후 수정하는 로직에서 원본 배열까지 함께 변경되는 문제를 경험했다. 당시에는 "clone이 얕은 복사를 수행하기 때문"이라는 표면적인 이해만으로 넘어갔고, 2차원 배열은 무조건 2중 반복문으로 복사해야겠다는 생각만 가진 채 문제를 마무리했다.
최근 『이펙티브 자바』와 『이것이 자바다』를 읽으며 배열의 메모리 구조와 clone()의 동작 원리를 깊이 있게 학습할 기회가 있었다. 힙 메모리에서 1차원 배열과 2차원 배열이 각각 어떻게 관리되는지, 그에 따라 clone()이 어떻게 동작하는지를 이해하면서 가변 객체, 불변 객체, 깊은 복사, 얕은 복사의 개념을 명확히 정리할 수 있었다.
이 글에서는 학습한 내용을 바탕으로 clone() 메서드의 동작 방식을 정리하고, 코딩테스트에서 clone을 안전하게 사용하는 방법을 명확히 정리하고자 한다.
1. 오버라이딩이 필요한 Object 메서드들
모든 Java 클래스에서 기본적으로 상속받는 클래스가 없다면 Object를 상속받는다. 이때, 커스텀 클래스의 경우 equals(), hashCode(), toString() 등과 같은 메서드가 정상적으로 동작하도록 보장하기 위해서는 오버라이딩이 필수다. toString()을 정의하지 않고 썼을 때 아래 사진과 같이 출력되는 경우를 본 적이 있을 것이다.

따라서, 커스텀 클래스에서 만약 이런 Object가 제공하는 메서드를 사용할 상황이 있다면 정상적으로 동작하도록 규약에 맞게 재정의 해줘야 한다. clone 메서드도 이런 오버라이드를 해줘야 하는 메서드 중 하나이다.
2. Object.clone() 메서드
다른 메서드들과는 다르게 특이하게도 clone()의 경우는 오버라이딩해서 사용하려면 커스텀 클래스에 Clonable이라는 인터페이스를 implements 해야 한다. Cloneable 인터페이스를 살펴보면 아래와 같이 아무 메서드도 갖고 있지 않다.

실제로 Object의 clone()에 적혀있는 문서 내용에는 아래와 같이 적혀있다. 해석해 보면 Cloneable 인터페이스를 지원하지 않는다면 CloneNotSupportedException을 던진다고 나와있다.

실제 동작도 궁금해 아래와 같이 코드를 작성해 실험해 보았는데 문서대로 동작하는 것을 확인할 수 있었다.
public class Food {
private String name;
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
// 실행 코드
Food food = new Food();
food.clone();

이펙티브 자바에서는 이런 clone()을 강제할 수 없고, 허술하게 기술된 프로토콜이라고 말한다.
`a. 강제할 수 없는 규약`
Cloneable는 메서드가 하나도 없는 마커 인터페이스다. 즉 clone() 메서드를 구현하도록 강제할 수 없다. 인터페이스를 사용하는 이유는 추상화, 다형성도 있지만 컴파일러 입장에서는 구현해야 할 메서드를 강제한다는 관점을 갖고 있다. 이런 관점에서 잘못되었다고 비판한다.
`b. 허술하게 기술된 프로토콜`
clone을 오버라이딩할 때 super.clone()과 new로 생성하는 것이 동작상 차이가 없기 때문에 컴파일 타임에 이를 감지할 수 없으며 런타임에 실제로 잘못 구현된 clone이 있으면 문제가 발생한다.
class Parent implements Cloneable {
String name;
@Override
public Parent clone() {
//super.clone()을 호출하지 않고 생성자로 반환
Parent p = new Parent();
p.name = this.name;
return p;
}
}
class Child extends Parent {
int age;
@Override
public Child clone() {
// super.clone()을 호출
Child c = (Child) super.clone();
c.age = this.age;
return c;
}
}
// 실행 코드
Child child = new Child();
child.name = "Alice";
child.age = 10;
Child cloned = child.clone();
이 코드를 실제로 실행시켜 보면 아래와 같이 동작한다.

왜 이런 문제가 발생하는 걸까?
핵심은 인스턴스로 메서드 호출 시 저장되는 this에 있었다. child.clone()을 호출하면 스택프레임에 this는 child를 참조한다.
이후 child의 clone에서 super.clone()을 호출할 때에도, parent.clone()의 스택 프레임에 this는 child이다. Object.clone()은 네이티브 메서드로, 내부적으로 this.getClass()를 통해 실제 객체의 타입을 확인하고 B 타입의 새로운 인스턴스를 생성한다. (반환 타입은 Object)
이를 간단하게나마 확인할 수 있는 코드를 아래처럼 작성해 보았다.
class A implements Cloneable{
int a;
@Override
public A clone() throws CloneNotSupportedException {
System.out.println("A method call : "+this.getClass());
A a =(A)super.clone();
System.out.println("Object.clone() return instance : "+a.getClass());
return a;
}
}
class B extends A implements Cloneable{
int b;
@Override
public B clone() throws CloneNotSupportedException {
System.out.println("B method call : "+ this.getClass());
return (B)super.clone();
}
}
//실행 코드
B b = new B();
b.clone();

이때, 만약 A의 clone에서 super.clone()을 호출하지 않고 A 인스턴스를 new 생성자를 통해 생성하고 이를 return 하도록 구현했다면? return 받은 A 객체를 (B)로 형변환이 불가능하기 때문에 ClassCastException이 발생하게 된다.
이처럼 실제로 이펙티브 자바에서 말한 내용에 대해 코드를 작성해 보며 느낀 점은 clone은 정말 잘못 사용하면 끝도 없이 문제가 발생할 수 있겠다는 생각이 들었다.
3. 불변 객체와 가변 객체, 그리고 clone()
clone() 메서드는 가변 객체를 다룰 때 특히 주의가 필요하다. 이를 이해하기 위해 먼저 불변 객체와 가변 객체의 차이를 살펴보자.
먼저 불변 객체란?
말 그대로 불변 객체는 생성 후 상태가 변경되지 않는 객체를 말한다. 대표적인 예가 String이다.
String a = "Hello";
a = "Bye"; // 기존 객체를 수정하는 것이 아니라 새로운 객체를 생성
위 코드에서 a = "Bye"는 기존 "Hello" 객체를 수정하는 것이 아니라, 내부적으로 new String("Bye") 객체를 생성하고 그 참조를 a에 할당한다. 불변 객체는 모든 필드가 final로 선언되어 있으며, String의 경우 문자열 상수 풀을 통해 동일한 문자열을 재사용한다.
가변 객체는 반대로 생성 후에도 상태가 변경 가능한 객체를 말한다. 대표적으로 아래와 같이 작성된 클래스에서 List 객체가 있다.
class Person implements Cloneable {
private String name;
private List<String> hobbies = new ArrayList<>();
...
}
Person 객체를 생성하고 해당 객체를 이용해 clone()을 호출하면 String의 경우 불변객체라 새로운 person 객체의 name을 생성할 때 같은 ref를 사용해도 문제가 되지 않는다. 실제로 문자열 상수풀을 이용하여 String의 경우 한번 사용된 문자열의 경우 새로 생성하지 않고 재사용한다.
하지만, hobbies의 경우 같은 ref를 복사해 생성할 경우, element를 하나 추가할 때, 그것이 원본 Person 객체에도 영향이 가기 때문에 문제가 된다. 이는 clone을 아래와 같이 사용할 때 얕은 복사로 동작하기 때문에 발생하는 문제이다(hobbies 객체의 ref만 복사하고 내부의 element는 신경 쓰지 않음).
아래와 같이 코드를 작성하고 확인해 보면 명확하다.
class Person implements Cloneable {
private String name;
private List<String> hobbies = new ArrayList<>();
...
@Override
public Person clone() {
try {
return (Person) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
...
}
// 실행 코드
public static void mutableObjectClone(){
Person person = new Person("Lee");
person.addHobby("Game");
person.addHobby("Book");
Person clonePerson = person.clone();
// 1. 동일성 검사시 같은 레퍼런스를 참조하고 있을 것이기 때문에 true여야 한다.
System.out.println("name reference same : "+(person.getName() == clonePerson.getName()));
person.setName("Kim");
//2. String은 불변객체 따라서, reference값이 변경되어서 동일성은 더이상 같지 않다.
System.out.println("name reference same : "+ (person.getName() == clonePerson.getName()));
//3. 가변 객체(List)의 경우 해당 객체의 레퍼런스를 복사한다.
System.out.println("person hobbies reference : "+(person.getHobbies() == clonePerson.getHobbies()));
}

이 문제를 해결하려면 clone을 재정의할 때 hobbies에 대해 새로 ArrayList를 생성하고 내부의 값들도 새로 생성해서 넣어줘야 한다. 이를 보통 깊은 복사라고 말한다.
cloned.addresses = new ArrayList<>();
for (Address addr : this.addresses) {
cloned.addresses.add(new Address(addr.city)); // 각 요소도 복사
}
이를 통해 clone 메서드를 사용할 때, 불변 객체와 가변객체인지에 따라 얕은 복사만으로 충분한지 판단할 수 있고, 왜 불변 객체를 사용할 수 있다면 사용해야 하는지 알 수 있었다.
4. 문제의 상황 : 원시형 2차원 배열 clone()
코딩 테스트 문제를 풀며 경험했던 상황은 2차원 원시형 배열을 clone()할 때였다. 이펙티브 자바에서 배열은 clone 기능을 제대로 사용하는 유일한 예라고 말한다. 아래와 같이 1차원 원시형 배열일 경우 clone()을 호출하면 새로운 배열 객체가 생성되고 모든 값이 복사되는 깊은 복사로 동작한다.
public class Music implements Cloneable{
public int [] arr = {1,2,3,4,5};
public int [][] arr2 = {
{1,2,3},
{4,5,6}
};
}
// 실행 코드
Music music = new Music();
Music clone = music.clone();
System.out.println("arr: " + System.identityHashCode(music.arr));
System.out.println("cloned: " + System.identityHashCode(clone.arr));
clone.arr[0] = 100;
System.out.println(clone.arr[0] + " " + music.arr[0]);

실제 내부 스택과 힙 구조를 간략하게 보면 아래와 같다. 1차원 배열은 새로운 배열 객체가 생성되고 모든 값이 복사되므로, clone 배열을 수정해도 원본 music 배열에 영향을 주지 않는다.

하지만, 2차원 원시형 배열에서는 어떻게 동작할까?
int[][] original = {{10, 20}, {30, 40}};
int[][] cloned = original.clone();

2차원 배열의 메모리 구조를 이해하면 답을 알 수 있다:
- 2차원 배열 변수는 실제로 "1차원 배열들의 배열"이다
- 최상위 배열의 각 요소는 내부 1차원 배열에 대한 참조값을 저장한다
clone()을 호출하면:
- 최상위 1차원 배열은 새로 생성된다
- 하지만 내부 배열들은 복사되지 않고 참조만 복사된다 (얕은 복사)
결과적으로 cloned와 original의 최상위 배열은 다르지만, 내부 배열들은 동일한 객체를 가리키게 된다.
위와 같이 동작하기 때문에, 코딩 테스트 문제 풀이에서 cloned를 변경할 때 original에도 영향이 가는 상황이 발생했던 것이다. 이를 해결하려면 위에서 가변객체를 위한 clone을 재정의했던 방식처럼 동일하게 복사 로직을 작성해줘야 한다. 배열 변수가 가리키는 1차원 배열의 경우 새로 생성된다는 점을 이용해 복사를 아래처럼 작성하면 서로 영향을 주는 상황을 해결할 수 있다.
int [][] original = {{10, 20}, {30, 40}};
int [][] clone = new int[2][];
for(int i = 0; i<2;i++){
clone[i] = original[i].clone();
}
5. 마무리
정리하면, clone()을 안전하게 사용하려면 다음을 확인해야 한다:
- 객체의 타입 확인: 복사하려는 객체가 가변 객체인가?
- 복사 깊이 결정: 내부 요소까지 복사가 필요한가?
- 적절한 구현: 필요하다면 내부 요소를 직접 복사하는 로직 작성
특히 원시형 2차원 배열이나 ArrayList 같은 가변 객체는 clone()만으로는 내부 요소의 참조만 복사된다. 원본을 보존해야 하는 경우, 반드시 내부 요소까지 값을 복사하는 로직을 구현해야 한다.
이펙티브 자바에서는 복사 생성자와 복사 팩토리라는 더 나은 객체 방식을 제공하는 것이 Best Practice라고 소개한다. 따라서, 이미 Cloneable을 구현한 게 아니라면 복사 생성자를 고려해 보자.
`코딩테스트에서 적용`
코딩테스트에서는 원시형 2차원 배열과 ArrayList를 자주 사용한다. 원본 데이터를 유지하면서 변경을 가해야 하는 상황에서는 아래와 같이 사용하자.
// 2차원 배열 - 각 행을 개별적으로 복사
int[][] cloned = new int[original.length][];
for (int i = 0; i < original.length; i++) {
cloned[i] = original[i].clone();
}
// ArrayList - 새 객체 생성 후 깊은 복사
List<List<Integer>> cloned = new ArrayList<>();
for (List<Integer> list : original) {
cloned.add(new ArrayList<>(list));
}
`배운 점`
clone() 메서드를 깊이 있게 공부하면서 그동안 명확히 설명하지 못했던 개념들을 정리할 수 있는 시간이었다.
- 불변 객체 vs 가변 객체의 차이와 clone() 시 주의사항
- Java에서 얕은 복사 vs 깊은 복사의 개념
- 메모리 구조를 통한 배열 복사의 동작 원리
'Java' 카테고리의 다른 글
| 디자인 패턴 : 빌더 & 싱글톤 패턴 구현해보기 (with. 이펙티브 자바) (0) | 2025.10.28 |
|---|---|
| 객체지향 실습 - 음식 주문 시스템 구현하기 (1) (0) | 2025.10.14 |
| 빠트렸던 Java 개념 복습하기 (0) | 2025.09.30 |
| 객체지향 학습 (1) (0) | 2025.09.09 |