Java프로젝트하면서 String의 특성으로 인한 깊은 복사 설명하기
글을 시작하며
Java를 이용해 주소록(AddressBook)프로그램을 만들 때 먼저 개인의 데이터를 가지고 있는 entity역할을 하는 Personal(개인)클래스를 구현해야 합니다.
이 Personal클래스를 구현하면서 String의 특성으로 인해 얕은 복사를 하더라도 깊은 복사가 되는 이유를 설명하려고 합니다.
그냥 바로 String의 특성을 설명하려면 간단한 예시로 밖에 설명을 못하는데, 이는 이미 많은 블로그나 책에서 하고 있기 때문에 굳이 제가 또 해야할 필요는 없다고 생각합니다.
대신에 저는 간단한 예시보다는 상대적으로 조금 더 복잡한 프로그램을 구현하면서 이를 설명하려고 합니다.
이렇게 프로그램을 구현하면서 개념을 적용하면 더 깊은 이해를 할 수 있을 것입니다.
그럼 이제 Personal클래스를 구현하면서 String의 특성에 대해서 설명해보도록 하겠습니다.
Persnoal 클래스
주소록에서 개인의 정보를 저장하기 위해 Personal 클래스를 생성합니다.
즉, Personal 클래스는 데이터를 가지고 있는 entity 클래스입니다.
데이터는 필드이며 Personal은 4가지 필드(성명, 주소, 전화번호, 이메일주소)를 멤버로 가집니다.
필드
Personal클래스의 인스턴스 필드 멤버인 이름, 주소, 전호번호, 이메일주소는 필드의 특성상 문자열, 즉, String을 자료형으로 설정합니다.
혹자는 전화번호는 왜 문자열이냐, 숫자아니냐 하실 수 있지만, 전화번호로 계산을 하지 않으며, 성명과 같이 정보를 나타낼 뿐이기 때문에 문자열로 설정합니다.
특히, 숫자로 나타낼 경우 031, 02와 같은 지역번호를 앞에 붙일 때 자료형이 숫자형이면 0이 사라지는데 문자열인 경우 앞의 0이 사라지지 않기 때문에 문자열로 나타내는 게 더 좋습니다.
Perosonal 클래스의 각 필드는 외부에서 그 값을 함부로 바꿀 수 없도록 private으로 설정합니다.
//인스턴스 멤버(필드)
private String name;
private String address;
private String telephoneNumber;
private String emailAddress;
생성자
생성자는 매개변수가 없는 디폴트생성자, 필드들에 대입할 값들을 매개변수로 받는 생성자를 정의합니다.
//디폴트 생성자
public Personal()
{
this.name = "";
this.address = "";
this.telephoneNumber = "";
this.emailAddress = "";
}
//매개변수를 가지는 생성자
public Personal(String name, String address,
String telephoneNumber, String emailAddress)
{
this.name = name;
this.address = address;
this.telephoneNumber = telephoneNumber;
this.emailAddress = emailAddress;
}
getter 메소드
Personal의 모든 필드멤버가 private이기 떄문에 외부에서 접근을 할 수 없습니다.
그래서 필드에 접근하여 그 값을 읽기 위해서는 public getter 메소드들이 필요합니다.
//이름 정보 가져오기
public String getName()
{
return this.name;
}
//주소 정보 가져오기
public String getAddress()
{
return this.address;
}
//전화번호 정보 가져오기
public String getTelephoneNumber()
{
return this.telephoneNumber;
}
//이메일 정보 가져오기
public String getEmailAddress()
{
return this.emailAddress;
}
setter 메소드
이제 필드들의 값을 읽어 오는 것은 getter메소드를 통해 가능하지만 값을 변경하는 것은 현재 유일한 방법이 생성자를 이용하여 Personal객체를 새로 만드는 방법입니다.
Personal객체를 새로 만드는 방법을 제외하고, 기존의 Personal객체에서 값을 변경하기 위해서 setter메소드를 정의해줍니다.
Personal의 멤버 중에 name을 제외하고, address, telephoneNumber, emailAddress를 변경할 수 있도록 setter메소드를 구현하였습니다.
name은 왜 변경을 안하느냐고 물으신다면 name을 바꾸는 것은 address, telephoneNumber, emailAddress를 바꾸는 것과는 차원이 다르다고 생각하기 때문입니다.
개명을 하면 법적으로 새사람이 되는 것과 비슷하다고 생각하기 때문에 개명을 한 경우 해당 Personal 객체를 지우고, 새로 기재를 해야한다고 생각하여 name은 변경할 수 없게 막았습니다.
//이름은 수정할 수 없기 때문에 따로 setter 를 만들지 않음.
//주소 정보 수정하기
public void setAddress(String address)
{
this.address = address;
}
//전화번호 정보 수정하기
public void setTelephoneNumber(String telephoneNumber)
{
this.telephoneNumber = telephoneNumber;
}
//이메일주소 정보 수정하기
public void setEmailAddress(String emailAddress)
{
this.emailAddress = emailAddress;
}
clone 메소드 구현하기
clone메소드는 클래스의 객체가 자신과 똑같은 내용을 가진 객체를 복사하는 역할을 합니다.
이 때, 어떻게 구현하느냐에 따라서 얕은 복사가 될 수도 있고, 깊은 복사가 될 수도 있습니다.
Cloneable 인터페이스
Cloneable 인터페이스는 내부에 추상메소드를 포함하고 있지 않고, 단순히 Cloneable인터페이스를 구현하는 클래스는 복사하는 메소드 clone이 있다는 것을 알리는 용도의 인터페이스입니다.
모든 클래스의 부모 클래스인 최상위 클래스 Object는 clone메소드를 가지고 있습니다.
이 clone메소드를 호출하면 자신의 객체를 복사해서 이를 리턴합니다.
클래스의 특성에 따라서 Object클래스의 clone을 그대로 사용할 수도 있고, 오버라이딩(재정의)하여 사용할 수도 있습니다.
다만 이를 위해서는 clone을 사용하려는 클래스는 반드시 Cloneable 클래스를 구현하여야 합니다.
그렇지 않으면 CloneNotSupportedException이 발생합니다.
Object의 clone메소드
Object의 clone메소드의 접근제어자가 protected이기 때문에 클래스 외부에서는 호출할 수 없습니다.
그래서 추가 기능없이 Object의 clone기능을 그대로 사용하고 싶은 클래스도 반드시 clone을 오버라이딩해서 내부에서 Object의 clone메소드를 호출하여 사용해야합니다.
이 때 Personal이 clone을 오버라이딩할 때 접근제어자를 public으로 한 이유는 자손이 부모의 클래스(여기서 부모 클래스는 Object이고 Object의 clone의 접근제어자가 protected이기 때문에)를 오버라이딩할 때 접근제어가 최소 같거나 그것보다 넓어야 하기 때문입니다.(private < default < protected < public)
이를 바탕으로 Personal클래스의 clone을 구현하면 아래와 같습니다.
//Cloneable인터페이스의 clone 메소드 오버라이딩하기(구현하기)
@Override
public Personal clone() throws CloneNotSupportedException
{
return (Personal)super.clone();
}
Personal이 따로 상속받은 클래스가 없기 때문에 자동으로 Object를 상속합니다.
그래서 Personal의 clone메소드에서 super는 Object를 의미합니다.
반환값을 Personal로 설정했기 때문에 Object로 업캐스팅하여 자식인 Personal클래스로 바꿔준 뒤에 반환합니다.
따라서 Personal클래스의 clone메소드는 내부적으로 그냥 object의 clone메소드를 호출하고, Personal으로 형변환을 한 뒤에 반환하는 코드입니다.
Object의 clone메소드는 깊은 복사를 보장해주지 않지만, Personal클래스에서는 Object의 clone을 이용하여도 깊은 복사가 되는 이유는 Personal의 필드멤버들이 모두 String이기 때문입니다.
String의 특성
자바에서 String클래스는 객체의 문자열 값을 변경하면 기존의 문자열 값을 가진 String객체는 그대로 두고 변경된 문자열을 가진 새로운 String객체를 생성합니다.
예를 들어, Personal객체 one의 필드가 String name = “홍길동”, String address = “서울시 중구”, String telephoneNumber = “024498890”, String emailAddress = “Hong@naver.com”일 때 clone메소드를 호출하여 이를
복사한 값을 Personal객체 other가 반환받았다고 가정합시다.
one은 원본이고, other는 복사본입니다.
clone메소드 호출 코드
public class Main
{
public static void main(String[] args) throws CloneNotSupportedException
{
Personal one = new Personal("홍길동","서울시 중구", "024498890", "Hong@naver.com");
Personal other = one.clone();
}
}
clone메소드 호출 시 메모리맵
앞에서 Personal클래스의 clone메소드는 내부에서 Object의 clone메소드를 호출하도록 정의하였습니다.
Object의 clone을 이용하면 새로운 Personal객체를 생성해서 other가 이 주소를 가지게 됩니다.
즉, one과 other는 각각 별도의 Personal 객체를 가리키고 있습니다.
다만 Object의 clone메소드의 경우 필드의 값을 그대로 복사하기 때문에 기본자료형일 경우 깊은 복사가 됩니다.
String의 경우 역시 클래스이기 때문에 Personal의 각 필드들은 위의 메모리맵에서처럼 각 String객체들의 주소값을 저장하고 있습니다.
이 주소값을 그대로 복사하기 때문에 결국 one의 필드들과 other의 필드들은 서로 같은 String객체를 가리키게 됩니다.
clone이후 복사본 값 변경코드
public class Main
{
public static void main(String[] args) throws CloneNotSupportedException
{
other.setAddress("서울시 서초구");
other.setTelephoneNumber("027842395");
other.setEmailAddress("Hong@gmail.com");
}
}
clone이후 복사본 값 변경시 메모리맵
이렇게 setter메소드들을 호출하면 위의 메모리맵처럼 기존의 String객체들은 그대로 두고, 새로운 String객체를 생성한 뒤에 그 주소값을 other의 address, telephoneNumber, emailAddress들이 가집니다.
이것이 바로 String객체의 특성입니다.
아까 앞에서 언급했듯이 String클래스의 특성상 String객체를 가리키고 있는 참조변수를 통해 String객체의 문자열값을 바꾸면, 기존의 String클래스는 그대로 두고, 변경된 문자열을 가지는 새로운 String클래스를 생성합니다.
그래서 Personal클래스의 경우 clone메소드에서 Object의 clone메소드를 그대로 재사용하고 있지만 String클래스의 특성으로 인하여 깊은 복사가 됩니다.
만약에 Personal클래스의 필드 중에 String이 아닌 다른 클래스가 있었다면 이런식으로 복사를 했을 때 ‘얕은 복사’가 되어 원본의 값을 수정하면 복사본의 값도 수정될 수도 있습니다.
앞으로 연재될 글에서도 얕은 복사와 깊은 복사에 대한 설명이 있을 거라서 다음 글을 참고하면 될 거 같습니다.
equals메소드 오버라이딩하기
equals는 Object클래스의 메소드인데 이를 Personal객체의 특성에 맞게 재정의합니다.
매개변수로 입력되는 자료형이 Object이기 때문에 어떠한 클래스도 입력을 받을 수 있으나 비교를 하기 위해서는 형변환을 해줘야 합니다.
현재 Personal클래스에서 equals메소드를 재정의하고 있기 때문에 equals는 자신을 호출한 Personal객체와 매개변수로 입력받는 Personal객체가 같은지를 비교하는 것이기 때문에 먼저 매개변수로 입력받은 obj가 Personal로 형변환이 되는지 체크를 합니다.
형변환이 가능하면 Personal의 각 필드들을 비교할 때마다 매번 (Personal)obj 이런식으로 형변환을 한다음 Personal의 필드들인 String에 접근하여 String끼리 compareTo로 비교를 해줍니다.
Personal클래스 객체 필드들의 문자열이 모두 같으면 같은 Personal객체로 인식하도록 정의합니다.
형변환이 불가능하다면 더 비교할 것도 없이 바로 ret의 디폴트값인 false를 반환합니다.
public class Personal implements Cloneable
{
//같은 Personal 객체인지 확인하기
@Override
public boolean equals(Object obj)
{
boolean ret = false;
if(obj instanceof Personal)
{
if(this.name.compareTo(((Personal)obj).name)==0
&& this.position.compareTo(((Personal)obj).address)==0
&& this.cellularPhoneNumber.compareTo(((Personal)obj).telephoneNumber)==0
&& this.emailAddress.compareTo(((Personal)obj).emailAddress)==0)
{
ret = true;
}
}
return ret;
}
}
toString 오버라이딩하기
Personal클래스 구현이 끝나고나면 메인테스트에서 제대로 입력이 되었는지 출력하기 위한 중복코드가 많아서 이를 간편하게 하기 위해 Personal클래스에서 Object의 toString메소드를 오버라이딩하였습니다.
@Override
public String toString()
{
return new String(this.name + ", " + this.address + ", " + this.telephoneNumber + ", "
+ this.emailAddress);
}
테스트코드
Personal의 모든 메소드가 제대로 동작하는지 확인하기 위해 메인테스트 시나리오를 작성한 뒤 이를 콘솔에 구현하여 원하는대로 작동하는지 확인합니다.
참고로 println에 객체만 넣었을 경우 자동으로 toString을 붙기 때문에 성공적으로 오버라이딩된 toString이 출력됩니다.
public class Main
{
public static void main(String[] args) throws CloneNotSupportedException
{
System.out.println("Personal 메인테스트");
//1. 기본생성자 호출
Personal original = new Personal();
System.out.printf("1. 기본생성자 호출: ").println(original);
//2. 매개변수를 가지는 생성자 호출
original = new Personal("홍길동", "서울시 서초구",
"022345678", "Hong@naver.com");
System.out.printf("2. 매개변수생성자 호출: ").println(original);
//3. 복사생성자 호출
Personal copyFromConstructor = new Personal(original);
System.out.printf("3. 복사생성자 호출: ").println(copyFromConstructor);
//4. 서로 다른지 확인
boolean answer = original.isNotEqual(copyFromConstructor);
System.out.printf("4. 복사본과 서로 다른지 확인: %s\n", answer);
//5. clone 호출
Personal copyFromClone = original.clone();
System.out.printf("5. clone 호출: ").println(copyFromClone);
//6. 서로 같은지 확인
answer = original.isEqual(copyFromClone);
System.out.printf("6. 복사본과 서로 같은지 확인: %s\n", answer);
//7. 원본 내용 변경 후 복사생성자로 생성한 copy와 clone으로 생성한 copy의 내용이 바뀌는지 확인
System.out.println("7. 원본 내용 변경 후 복사생성자로 생성한 copy와" +
" clone으로 생성한 copy의 내용이 바뀌는지 확인");
original.setAddress("서울시 중구");
original.setTelephoneNumber("029998888");
original.setEmailAddress("Hong@gmail.com");
System.out.printf("7.1 원본내용: ").println(original);
System.out.printf("7.2 복사생성자 복사본내용: ").println(copyFromConstructor);
System.out.printf("7.3 clone 복사본 내용: ").println(copyFromClone);
}
}
메인테스트 결과
모든 메소드가 정상적으로 작동함을 알 수 있습니다.
특히 7번을 보면 setter를 통해 원본의 내용을 변경하였으나 복사생성자로 생성한 복사본과 clone으로 생성한 복사본에는 아무런 영향이 없음을 알 수 있습니다.
마치며
이번 시간을 통해 Personal클래스를 직접 구현하면서 String클래스의 특성과 이로 인해 복사가 되었을 때, 복사후에 내용을 변경할 때 등을 자세하게 배웠습니다.
이 과정에서 틀린 내용이나 미흡한 내용이 있을 수 있습니다.
잘못된 또는 궁굼한 사항은 언제든지 댓글 또는 이메일을 남겨주시면 답변을 드리도록 하겠습니다.
다음 글은 Personal클래스를 관리하는 control역할을 하는 AddressBook클래스를 구현하면서 ArrayList의 얕은 복사, 깊은 복사에 대해서 설명하도록 하겠습니다.
긴 글 읽어주셔서 감사합니다.
댓글남기기