-
[Java] 객체 복사에 대한 고찰Language/Java 2022. 4. 15. 00:01
제목은 거창하게 객체 복사에 대한 고찰이라고 적었지만 사실 개발자라면 흔히 들어본 shallow copy, deep copy 부분에 대한 포스팅이다.
shallow copy는 얕은 복사라는 뜻으로 보통 주소값(참조값)을 복사한다는 뜻이고, deep copy는 깊는 복사라는 뜻으로 주소값이 아닌 값 자체를 복사한다는 뜻이다.
이러한 부분을 상세히 이해하려면 stack, heap에 대하여 좀 더 공부를 하여야한다. 또 이 부분을 자세히 공부하려면 메모리에 대하여 공부를 하여야 한다. (역시 공부는 끝이 없..)
그래도 이번 포스팅이 이러한 객체를 복사하는 것에 대한 고찰 글이므로 간략히라도 적어본다.
1번 예제)
int a = 1;
-> 이런식으로 코드를 작성하게 되는 경우 변수 a와 1은 각자 다른 곳에 저장된다. 변수 a는 지역변수이므로 스택에 저장되며, 숫자 1은 상수이므로 상수풀이라고 하는 곳에 저장이 된다.
2번 예제)
String test1 = "abc";
String test2 = new String("abc");
-> 2번 예제는 String이 조금 특별히 취급되는 부분이다. 우선 자바에서 new 라고 생성하는 부분은 동적으로 생성되는 부분인 heap영역에서 생성된다. 그렇기 때문에 new String()방식으로 생성된 부분은 객체 방식으로 생성된다.
그러면 처음처럼 바로 할당된 경우는 어떻게 될까? 정답은 리터럴로 생성되게 된다. 이럴경우 String test3 = "abc"라고 다른 부분에서 선언할 경우에도 "abc"라는 값이 새로 생성되는 것이 아니고 메모리에는 딱 하나만 올라가게 된다.
설명이 어렵다면 역시 그림으로 쉽게 적어둔 부분을 보도록 하자.
https://www.journaldev.com/wp-content/uploads/2012/11/String-Pool-Java1.png 해당 그림은 Cat이라는 문자열로 예시를 들고 있는 데 s1, s2변수는 모두 같은 것을 가리키는 것을 알 수 있다.
우선 가볍게 주소값에 대한 이해를 바탕으로 이제는 이 글에 본격적인 주제인 클래스 객체에 대한 복사에 대해서 알아보도록 하자.
우선 개발자답게 코드를 바로 보도록 하자.
public class Developia { public String name; public Integer money; public Developia() { } public Developia(String name, Integer money) { this.name = name; this.money = money; } public String getName() { return name; } public Integer getMoney() { return money; } public void setName(String name) { this.name = name; } public void setMoney(Integer money) { this.money = money; } @Override public String toString() { return "Developia{" + "money=" + money + '}'; } // 금액 차감 public void spendMoney(Integer money) { this.money -= money; } }
우선 돈이 연관되면 누구나 이해를 쉽게 할 수 있으니 통장과 유사하게 멤버변수로 name, money를 가지는 클래스를 작성해보았다.
그리고 기본적인 getter, setter와 편의를 위해 toString()를 오버라이드하였고, spendMoney메서드를 만들었다.
이 때 누군가가 내 통장을 복사기로 복사하는 상황을 떠올려보자.
Developia developiaMain = new Developia("A", 10000); Developia developia1 = developiaMain;
우선 developiaMain이라는 A라는 사람의 통장에는 10000원을 가지고 있다. (생성자로 생성)
그리고 = 연산자로 developia1이라는 객체에 할당하였다. (값을 복사)
그럼 이때 A가 1000원을 썼다면 복사한 developi1은 어떻게 될까?
developia1.spendMoney(1000); System.out.println("developia1 = " + developia1); System.out.println("developiaMain = " + developiaMain);
실행 결과 같은 값을 바라보니 모두 돈이 차감된 것을 알 수 있다!
이런 경우를 얕은 복사라고 한다. 얕은 복사는 주소값을 복사하여 서로 같은 부분을 바라본다. 그렇기 때문에 A가 돈을 1000원을 썼다하더라도 같은 주소를 바라보고 있기 때문에 developia1에 값도 1000원이 차감된 것을 알 수 있다.
이런 경우를 해결하고자 깊은 복사라는 방식을 4가지 알아보도록 하겠다.
다음 코드를 Developia class에 추가한다. 첫번째 방법은 직접 객체를 생성하여 복사하는 방법이다.
public Developia(Developia developia) { this.name = developia.name; this.money = developia.money; }
역시 동일하게 테스트를 진행해본다.
Developia developiaMain2 = new Developia("A", 10000); Developia developia2 = new Developia(); developia2.setName(developiaMain2.getName()); developia2.setMoney(developiaMain2.getMoney()); developia2.spendMoney(1000); System.out.println("developia2 = " + developia2); System.out.println("developiaMain2 = " + developiaMain2);
실행결과 이번에는 developia2가 1000원을 소비했지만 main은 돈이 그대로인것을 알 수 있다. 자세히 코드를 살펴보면 main에서 name과 money를 가져와 setter메소드를 이용하여 값을 넣는 것을 알 수 있다.
이런 식으로 할 경우 값 자체만을 가져와서 넣기 때문에 다른 참조를 가리키기 때문에 원하는 대로 복사가 잘 된 것을 알 수 있다.
단 이러한 방식은 실무에서 쓰일 수 없을 수도 있다. 이러한 방식 중에서 setMoney와 같은 메서드를 외부에 공개하지 않을 수 있기 때문이다. 일부러 money와 같은 필드를 만든 이유도 이 때문이다. 돈과 관련된 setter를 외부에 퍼블릭하게 쓰게 될 경우 다른 개발자들이 의도와 다르게 잘못 사용할 가능성이 높기 때문이다.
다음으로는 복사 생성자와 복사 팩토리 메서드를 사용한 방법을 알아보자. 다음 코드를 Developia class에 추가한다.
// 복사 생성자를 이용한 방법 public Developia(Developia developia) { this.name = developia.name; this.money = developia.money; } // 복사 팩토리 메소드를 이용한 방법 public static Developia newObject(Developia developia) { // 기본 생성자 필요 Developia d = new Developia(); d.name = developia.name; d.money = developia.money; return d; }
// 복사 생성자를 이용한 방법 Developia developiaMain2 = new Developia("A", 10000); Developia developia2 = new Developia(developiaMain2); developia2.spendMoney(1000); System.out.println("developia2 = " + developia2); System.out.println("developiaMain2 = " + developiaMain2); // 복사 팩토리 메소드를 이용한 방법 Developia developiaMain3 = new Developia("A", 10000); Developia developia3 = Developia.newObject(developiaMain3); developia3.spendMoney(1000); System.out.println("developia3 = " + developia3); System.out.println("developiaMain3 = " + developiaMain3);
실행결과 이 역시 의도한 바대로 잘 동작한 것을 알 수 있다.
복사 생성자는 Developia 타입을 통째로 받는 생성자를 만들어서 클래스 내부에서 새로 할당한 방법이다.
그리고 복사 팩토리 메소드의 경우에는 복사 전용 메소드를 만드는 방식이다. static 메소드를 생성하기에 메모리적으로 손해가 발생할 수도 있지만 자주 객체를 복사하는 경우나 명시적으로 복사 메소드를 나타내기 위해서는 좋은 방법이다.
이 두가지 방식이 자주 사용하는 방식으로 알아두면 좋다.
마지막 방법은 Cloneable interface를 구현하는 것이다. Developia 클래스를 다음과 같이 변경한다.
public class Developia implements Cloneable { ... @Override public Developia clone() throws CloneNotSupportedException { return (Developia) super.clone(); } }
Cloneable 인터페이스는 들어가보면 사실 아무것도 없는 인터페이스이다. 이는 단순히 복제해도 되는 클래스임을 암시하는 용도로서 사용하는 것이다. 그래서 이를 믹스인 인터페이스라고도 하는 데, 이는 클래스가 자신의 타입에 추가하여 구현할 수 있는 타입을 말한다. 선택 가능한 기능을 제공하고, 그 기능을 제공받고자 하는 클래스에서 선언한다.
Cloneable 인터페이스를 처음 구현하면 clone 메소드는 접근제어자가 protected에 Object 타입을 반환하는 메소드가 생성되는데, 이를 적절히 변경해주어야 한다.
이러한 clone 메소드를 작성하여 호출하는데 만약 Cloneable 인터페이스를 구현하지 않는 경우 CloneNotSupportedException이 발생하므로 유의하도록 한다.
Developia developiaMain5 = new Developia("A", 10000); try { Developia developia5 = developiaMain5.clone(); developia5.spendMoney(1000); System.out.println("developia5 = " + developia5); System.out.println("developiaMain5 = " + developiaMain5); } catch (CloneNotSupportedException e) { e.printStackTrace(); }
실행결과 Cloneable 인터페이스를 구현한 방법도 잘 되는 것을 알 수 있다. 다만 이러한 방법은 예외를 던지기 때문에 유의해서 사용해야 한다.
(하지만 배열은 clone 기능을 제대로 사용하는 유일한 예로 사용된다)
이렇게 객체를 복사하는 방법 4가지를 알아보았는 데, 자주 사용하는 방법은 복사 생성자 방법이나 복사 팩토리 메소드 방법이다. 물론 다른 방법들도 다른 방식에서는 유용하게 사용할 수 있으므로 알아두는 편이 좋을 것 같다.
'Language > Java' 카테고리의 다른 글
Java Collection - Queue (0) 2023.02.19 JAVA JVM(자바 가상머신)에 관하여 (0) 2023.02.18 자바 진법 변환(2진법 10진법 등) (0) 2023.02.16