기본형과 참조형 복사
대입은 언제나 변수에 든 값을 복사한다는 규칙 하나로 int 복사와 참조 복사의 차이를 설명하고, 참조형에서 ==가 비교하는 것과 객체를 따로 복사하는 방법을 배웁니다.
int b = a; 다음에 b를 바꾸면 a는 그대로입니다. 그런데 Member second = first; 다음에 second.age를 바꾸면 first.age도 함께 바뀝니다. 이 차이만 보고 '기본형은 복사되고 객체는 복사되지 않는다'고 외우면, 다음 글의 메서드 호출에서 다시 헷갈리게 됩니다. 두 경우 모두 무언가가 복사되기는 했기 때문입니다.
자바의 대입 규칙은 하나뿐입니다. 오른쪽에서 읽은 값을 복사해 왼쪽 변수에 저장합니다. 차이는 변수에 들어 있는 값의 종류에서 생깁니다. 기본형 변수에는 40 같은 실제 값이 들어 있고, 참조형 변수에는 객체를 가리키는 참조값이 들어 있습니다. 대입은 둘 다 똑같이 복사하지만, 참조값을 복사하면 두 변수가 같은 객체를 가리키게 됩니다. 이 글에서는 이 규칙 하나로 지금까지 본 현상들을 정리합니다.
이 글의 예제는 src/members/ch04/v6 폴더에 앞 글과 같은 세 필드의 Member.java를 두고, 실행할 클래스와 함께 컴파일합니다. 첫 예제 PrimitiveCopy는 Member를 쓰지 않으므로 그 파일 하나만 컴파일해도 됩니다.
기본형 대입은 그 순간의 값을 복사합니다
결과를 예상하기 쉬운 기본형부터 보겠습니다. 다음 코드는 copiedAge에 originalAge를 대입한 뒤, 두 변수에 각각 다른 값을 다시 넣습니다. 한 변수를 바꾼 것이 다른 변수에도 영향을 주는지 보세요.
public class PrimitiveCopy {
public static void main(String[] args) {
int originalAge = 40;
int copiedAge = originalAge;
originalAge = 50;
copiedAge = 70;
System.out.println("original=" + originalAge);
System.out.println("copied=" + copiedAge);
}
}original=50
copied=70각 문장을 실행한 직후의 값을 표로 따라가 보면 다음과 같습니다.
| 실행한 문장 | originalAge | copiedAge |
|---|---|---|
int originalAge = 40; | 40 | (아직 없음) |
int copiedAge = originalAge; | 40 | 40 |
originalAge = 50; | 50 | 40 |
copiedAge = 70; | 50 | 70 |
두 번째 줄에서 originalAge의 값 40이 copiedAge로 복사됩니다. 이 대입은 사실상 int copiedAge = 40;과 똑같습니다. 대입문 오른쪽의 변수는 '변수 자체'가 아니라 '그 순간 변수에서 읽은 값'으로 바꿔 읽으면 됩니다. 복사가 끝나면 두 변수 사이에는 아무 연결도 남지 않으므로, 그 뒤에 어느 한쪽에 새 값을 넣어도 다른 쪽은 영향을 받지 않습니다.
기본형 대입 뒤 두 변수의 독립된 값 변화
40의 값 복사 뒤 originalAge=50과 copiedAge=70은 각 변수만 바꿉니다. 대입 순간의 값과 이후 독립 변경을 추적하는 전환입니다.
PrimitiveCopy에서 originalAge의 40을 copiedAge에 복사한 뒤 originalAge는 50, copiedAge는 70으로 각각 대입됩니다. 복사 후 두 변수를 연결하는 관계는 남지 않습니다.
기본형 대입은 그 순간의 값을 복사합니다
40을 copiedAge에 복사한 뒤 두 변수는 독립적으로 바뀝니다. 각 문장 실행 직후의 값을 비교합니다.
| 방금 실행한 문장 | originalAge | copiedAge |
|---|---|---|
int originalAge = 40; | 40 | 아직 선언하지 않음 |
int copiedAge = originalAge; | 40 | 40 |
originalAge = 50; | 50 | 40 |
copiedAge = 70; | 50 | 70 |
PrimitiveCopy · 각 문장 실행 직후
int originalAge = 40;originalAge = 40,copiedAge는 아직 선언하지 않았습니다.int copiedAge = originalAge;originalAge = 40,copiedAge = 40.originalAge = 50;originalAge = 50,copiedAge = 40.copiedAge = 70;originalAge = 50,copiedAge = 70.
복사되는 것은 대입 순간에 읽은 숫자 40입니다. 이후 대입은 각 변수의 값만 바꾸며, 실행 결과는 original=50, copied=70입니다.
이 예의 조건
- 기본형 복사는 숫자 값이며 영구 공유 참조가 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | originalAge 40을 copiedAge에 숫자로 복사한 뒤 original 50/copied 70을 각각 독립적으로 대입한다. 점선은 값 복사이고 실선은 각 변수 갱신이며 영구 공유 참조가 아니다. |
| 구분 | 내용 |
|---|---|
| 설명 | originalAge 40을 copiedAge에 숫자로 복사한 뒤 original 50/copied 70을 각각 독립적으로 대입한다. 점선은 값 복사이고 실선은 각 변수 갱신이며 영구 공유 참조가 아니다. |
| 전체 읽기 정보 | 기본형 대입 뒤 두 변수의 독립된 값 변화 기본형 대입 뒤 두 변수의 독립된 값 변화 PrimitiveCopy에서 originalAge의 40을 copiedAge에 복사한 뒤 originalAge는 50, copiedAge는 70으로 각각 대입됩니다. 복사 후 두 변수를 연결하는 관계는 남지 않습니다. 기본형 대입은 그 순간의 값을 복사합니다 40을 copiedAge에 복사한 뒤 두 변수는 독립적으로 바뀝니다. 각 문장 실행 직후의 값을 비교합니다. PrimitiveCopy 에서 각 문장을 실행한 직후의 값 방금 실행한 문장 originalAge copiedAge int originalAge = 40; 40 아직 선언하지 않음 int copiedAge = originalAge; 40 40 originalAge = 50; 50 40 copiedAge = 70; 50 70 PrimitiveCopy · 각 문장 실행 직후 int originalAge = 40; originalAge = 40 , copiedAge 는 아직 선언하지 않았습니다. int copiedAge = originalAge; originalAge = 40 , copiedAge = 40 . originalAge = 50; originalAge = 50 , copiedAge = 40 . copiedAge = 70; originalAge = 50 , copiedAge = 70 . 복사되는 것은 대입 순간에 읽은 숫자 40입니다. 이후 대입은 각 변수의 값만 바꾸며, 실행 결과는 original=50 , copied=70 입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 복사 시점 originalAge 40을 copiedAge에 대입합니다. |
|
|
| 원본 변수 변경 originalAge에 50을 대입합니다. |
|
|
| 복사 변수 변경 copiedAge에 70을 대입합니다. |
|
|
기본형 대입 뒤 두 변수의 독립된 값 변화
40의 값 복사 뒤 originalAge=50과 copiedAge=70은 각 변수만 바꿉니다. 대입 순간의 값과 이후 독립 변경을 추적하는 전환입니다.
PrimitiveCopy에서 originalAge의 40을 copiedAge에 복사한 뒤 originalAge는 50, copiedAge는 70으로 각각 대입됩니다. 복사 후 두 변수를 연결하는 관계는 남지 않습니다.
기본형 대입은 그 순간의 값을 복사합니다
40을 copiedAge에 복사한 뒤 두 변수는 독립적으로 바뀝니다. 각 문장 실행 직후의 값을 비교합니다.
| 방금 실행한 문장 | originalAge | copiedAge |
|---|---|---|
int originalAge = 40; | 40 | 아직 선언하지 않음 |
int copiedAge = originalAge; | 40 | 40 |
originalAge = 50; | 50 | 40 |
copiedAge = 70; | 50 | 70 |
PrimitiveCopy · 각 문장 실행 직후
int originalAge = 40;originalAge = 40,copiedAge는 아직 선언하지 않았습니다.int copiedAge = originalAge;originalAge = 40,copiedAge = 40.originalAge = 50;originalAge = 50,copiedAge = 40.copiedAge = 70;originalAge = 50,copiedAge = 70.
복사되는 것은 대입 순간에 읽은 숫자 40입니다. 이후 대입은 각 변수의 값만 바꾸며, 실행 결과는 original=50, copied=70입니다.
이 예의 조건
- 기본형 복사는 숫자 값이며 영구 공유 참조가 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | originalAge 40을 copiedAge에 숫자로 복사한 뒤 original 50/copied 70을 각각 독립적으로 대입한다. 점선은 값 복사이고 실선은 각 변수 갱신이며 영구 공유 참조가 아니다. |
| 구분 | 내용 |
|---|---|
| 설명 | originalAge 40을 copiedAge에 숫자로 복사한 뒤 original 50/copied 70을 각각 독립적으로 대입한다. 점선은 값 복사이고 실선은 각 변수 갱신이며 영구 공유 참조가 아니다. |
| 전체 읽기 정보 | 기본형 대입 뒤 두 변수의 독립된 값 변화 기본형 대입 뒤 두 변수의 독립된 값 변화 PrimitiveCopy에서 originalAge의 40을 copiedAge에 복사한 뒤 originalAge는 50, copiedAge는 70으로 각각 대입됩니다. 복사 후 두 변수를 연결하는 관계는 남지 않습니다. 기본형 대입은 그 순간의 값을 복사합니다 40을 copiedAge에 복사한 뒤 두 변수는 독립적으로 바뀝니다. 각 문장 실행 직후의 값을 비교합니다. PrimitiveCopy 에서 각 문장을 실행한 직후의 값 방금 실행한 문장 originalAge copiedAge int originalAge = 40; 40 아직 선언하지 않음 int copiedAge = originalAge; 40 40 originalAge = 50; 50 40 copiedAge = 70; 50 70 PrimitiveCopy · 각 문장 실행 직후 int originalAge = 40; originalAge = 40 , copiedAge 는 아직 선언하지 않았습니다. int copiedAge = originalAge; originalAge = 40 , copiedAge = 40 . originalAge = 50; originalAge = 50 , copiedAge = 40 . copiedAge = 70; originalAge = 50 , copiedAge = 70 . 복사되는 것은 대입 순간에 읽은 숫자 40입니다. 이후 대입은 각 변수의 값만 바꾸며, 실행 결과는 original=50 , copied=70 입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 복사 시점 originalAge 40을 copiedAge에 대입합니다. |
|
|
| 원본 변수 변경 originalAge에 50을 대입합니다. |
|
|
| 복사 변수 변경 copiedAge에 70을 대입합니다. |
|
|
참조형 대입은 참조값을 복사합니다
이번에는 같은 구조를 Member로 바꿔 보겠습니다. 참조형 변수 first를 second에 대입한 뒤, second를 통해 나이를 바꿉니다.
public class ReferenceCopy {
public static void main(String[] args) {
Member first = new Member();
first.email = "[email protected]";
first.age = 40;
Member second = first;
second.age = 70;
System.out.println("first=" + first.age);
System.out.println("second=" + second.age);
System.out.println("same=" + (first == second));
}
}first=70
second=70
same=true대입 규칙은 기본형과 똑같이 적용됩니다. first에 참조값 x101이 들어 있다면, Member second = first;는 second에 x101을 복사하는 것이므로 Member second = x101;과 같습니다. new가 한 번밖에 실행되지 않았으니 객체는 여전히 하나이고, 그 객체의 필드가 새 공간에 복제되지도 않았습니다. 두 변수가 같은 참조값을 가지고 같은 객체를 가리킬 뿐입니다.
그래서 second.age = 70이 바꾸는 것은 second 변수에 든 참조값이 아니라, 그 참조를 따라간 객체의 age 필드입니다. 이어서 first.age를 읽으면 first도 같은 객체를 따라가므로 70이 나옵니다. 마지막 줄의 first == second가 true인 것도 두 변수에 같은 참조값이 들어 있기 때문입니다. 이처럼 같은 객체를 가리키는 또 다른 변수를 별칭(alias)이라고 부릅니다.
같은 Member를 가리키는 두 참조와 하나의 필드 변경
second에 같은 참조를 복사한 뒤 second.age=70은 객체 필드를 바꾸어 first에서도 70이 보입니다. 참조값은 그대로이고 필드만 40→70으로 변하는 실제 상태입니다.
ReferenceCopy에서 second에 first의 참조값을 복사한 뒤 second.age를 70으로 바꿉니다. 객체는 하나이므로 first.age와 second.age 모두 70이고 first == second는 true입니다.
참조값을 복사하면 같은 필드를 읽고 씁니다
first와 second는 하나의 객체에 도달합니다. second.age를 70으로 바꾸면 first.age에서도 같은 70을 읽습니다.
ReferenceCopy · Member second = first; 다음 second.age = 70; 실행 직후
- first와 second · 같은 참조값
second = first로 하나의 Member 객체에 도달합니다.
읽은 값은 first.age와 second.age 모두 70이고, first == second는 true입니다. 두 변수의 참조값을 바꾸거나 객체를 복제한 것이 아닙니다.
이 예의 조건
- Member 객체 하나의 age 필드만 변경합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 같은 Member를 가리키는 두 참조와 하나의 필드 변경 같은 Member를 가리키는 두 참조와 하나의 필드 변경 ReferenceCopy에서 second에 first의 참조값을 복사한 뒤 second.age를 70으로 바꿉니다. 객체는 하나이므로 first.age와 second.age 모두 70이고 first == second는 true입니다. 참조값을 복사하면 같은 필드를 읽고 씁니다 first와 second는 하나의 객체에 도달합니다. second.age를 70으로 바꾸면 first.age에서도 같은 70을 읽습니다. ReferenceCopy · Member second = first; 다음 second.age = 70; 실행 직후 first와 second · 같은 참조값 second = first 로 하나의 Member 객체에 도달합니다. 공유 객체의 age 변경 email = "[email protected]" second.age = 70 은 이 객체의 age를 40에서 70으로 바꿉니다. 읽은 값은 first.age 와 second.age 모두 70이고, first == second 는 true 입니다. 두 변수의 참조값을 바꾸거나 객체를 복제한 것이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 참조값 first와 second가 Kim 객체를 가리킵니다. |
|
|
| 필드 변경 second를 통해 age를 70으로 씁니다. |
|
|
| 공통 읽기 어느 변수로 읽어도 같은 age를 얻습니다. |
|
|
같은 Member를 가리키는 두 참조와 하나의 필드 변경
second에 같은 참조를 복사한 뒤 second.age=70은 객체 필드를 바꾸어 first에서도 70이 보입니다. 참조값은 그대로이고 필드만 40→70으로 변하는 실제 상태입니다.
ReferenceCopy에서 second에 first의 참조값을 복사한 뒤 second.age를 70으로 바꿉니다. 객체는 하나이므로 first.age와 second.age 모두 70이고 first == second는 true입니다.
참조값을 복사하면 같은 필드를 읽고 씁니다
first와 second는 하나의 객체에 도달합니다. second.age를 70으로 바꾸면 first.age에서도 같은 70을 읽습니다.
ReferenceCopy · Member second = first; 다음 second.age = 70; 실행 직후
- first와 second · 같은 참조값
second = first로 하나의 Member 객체에 도달합니다.
읽은 값은 first.age와 second.age 모두 70이고, first == second는 true입니다. 두 변수의 참조값을 바꾸거나 객체를 복제한 것이 아닙니다.
이 예의 조건
- Member 객체 하나의 age 필드만 변경합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 같은 Member를 가리키는 두 참조와 하나의 필드 변경 같은 Member를 가리키는 두 참조와 하나의 필드 변경 ReferenceCopy에서 second에 first의 참조값을 복사한 뒤 second.age를 70으로 바꿉니다. 객체는 하나이므로 first.age와 second.age 모두 70이고 first == second는 true입니다. 참조값을 복사하면 같은 필드를 읽고 씁니다 first와 second는 하나의 객체에 도달합니다. second.age를 70으로 바꾸면 first.age에서도 같은 70을 읽습니다. ReferenceCopy · Member second = first; 다음 second.age = 70; 실행 직후 first와 second · 같은 참조값 second = first 로 하나의 Member 객체에 도달합니다. 공유 객체의 age 변경 email = "[email protected]" second.age = 70 은 이 객체의 age를 40에서 70으로 바꿉니다. 읽은 값은 first.age 와 second.age 모두 70이고, first == second 는 true 입니다. 두 변수의 참조값을 바꾸거나 객체를 복제한 것이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 참조값 first와 second가 Kim 객체를 가리킵니다. |
|
|
| 필드 변경 second를 통해 age를 70으로 씁니다. |
|
|
| 공통 읽기 어느 변수로 읽어도 같은 age를 얻습니다. |
|
|
출력문에서 first == second를 괄호로 감싼 데는 이유가 있습니다. +가 ==보다 먼저 계산되므로, 괄호 없이 "same=" + first == second라고 쓰면 문자열 "same=" + first와 second를 비교하는 식이 됩니다. 문자열과 Member는 서로 비교할 수 없는 타입이라 incomparable types: String and Member라는 컴파일 오류가 납니다.
변수를 바꾸는 대입과 필드를 바꾸는 대입
다음 두 줄은 모양이 비슷하지만 전혀 다른 곳을 바꿉니다. 둘 다 second로 시작하는 대입문인데, 등호 왼쪽에 점과 필드 이름이 붙어 있는지가 다릅니다.
second.age = 70; // second가 가리키는 객체의 age 필드를 바꿉니다
second = new Member(); // second 변수에 든 참조값을 바꿉니다첫 줄의 대입 대상은 second.age, 즉 객체 안의 필드입니다. 둘째 줄의 대입 대상은 second 변수 자체입니다. 둘째 줄은 새 객체를 만들고 그 참조값(이를테면 x202)을 second에 넣으므로, 이제 second는 다른 객체를 가리킵니다. 이때 first에는 아무것도 대입하지 않았으므로 first는 여전히 x101을 가리킵니다. 다음 프로그램으로 확인해 보겠습니다.
public class ReassignReference {
public static void main(String[] args) {
Member first = new Member();
first.email = "[email protected]";
first.age = 40;
Member second = first;
second = new Member();
second.email = "[email protected]";
second.age = 10;
System.out.println(first.email + "=" + first.age);
System.out.println(second.email + "=" + second.age);
System.out.println("same=" + (first == second));
}
}[email protected]=40
[email protected]=10
same=falsesecond = first로 잠깐 같은 객체를 가리켰던 second는 바로 다음 줄에서 새 객체로 옮겨 갔습니다. 그 뒤의 필드 대입은 모두 새 객체에 적용되므로, first가 가리키는 원래 객체는 처음 값 그대로입니다. 두 변수가 이제 다른 객체를 가리키므로 first == second는 false입니다.
앞 글에서 향상된 for문의 반복 변수에 새 객체를 대입해도 배열 칸이 바뀌지 않았던 것도 같은 이유입니다. 공유된 객체가 뜻밖에 바뀌는 문제를 조사할 때는 '어떤 변수를 바꿨나'보다 '어느 참조를 따라가서, 어느 객체의 필드를 바꿨나'를 따져야 합니다. 대입문의 왼쪽이 변수인지 변수.필드인지만 봐도 절반은 답이 나옵니다.
==는 같은 객체인지를 비교합니다
참조형 변수끼리 ==로 비교하면 두 변수에 같은 참조값이 들어 있는지, 즉 같은 객체를 가리키는지를 비교합니다. 필드의 내용이 같은지는 보지 않습니다. 다음 프로그램은 필드 값이 똑같은 객체 두 개와 별칭 하나를 만들어 비교합니다.
public class IdentityCheck {
public static void main(String[] args) {
Member first = new Member();
first.email = "[email protected]";
first.age = 30;
Member alias = first;
Member copy = new Member();
copy.email = "[email protected]";
copy.age = 30;
System.out.println(first == alias); // true
System.out.println(first == copy); // false
}
}true
falsealias는 first의 참조값을 복사했으므로 같은 객체를 가리키고, 첫 줄은 true입니다. 반면 copy는 별도의 new로 만든 객체입니다. 이메일과 나이가 first와 똑같아도 다른 객체이므로 둘째 줄은 false입니다. 이렇게 '같은 객체인가'를 동일성(identity)이라고 하고, '내용이 같은가'를 동등성(equality)이라고 해서 구분합니다.
IdentityCheck의 first와 alias는 한 객체를 참조하지만 copy는 별도 new로 만든 객체를 참조합니다. 두 객체의 email과 age가 같아도 first == copy는 false입니다.
필드 값이 같아도 같은 객체는 아닙니다
first와 alias는 객체 A를 공유하고 copy는 별도 객체 B를 참조합니다. 참조의 ==는 필드 내용이 아닌 객체 동일성을 비교합니다.
IdentityCheck · 비교 직전의 참조 관계
- 객체 A · first와 alias
alias = first로 참조값을 복사하여 같은 객체에 도달합니다.- 객체 B · copy
- 별도
new Member()로 만든 다른 객체입니다. - 두 객체의 같은 필드 값
- 각각
email = "[email protected]",age = 30입니다.
first == alias는true· 같은 객체를 참조합니다.first == copy는false· 필드 값이 같아도 별개 객체입니다.
변수는 3개, 참조하는 객체는 2개입니다. A·B는 설명용 객체 이름이며 실제 주소가 아닙니다. ==는 필드 내용 비교가 아닙니다.
내용이 같은지 비교하는 방법은 9장에서 equals 메서드를 직접 정의하며 배웁니다. 2장과 3장에서 문자열을 command.equals("quit")처럼 비교한 것도 String이 내용을 비교하는 equals를 미리 갖추고 있기 때문입니다. 하지만 지금의 Member는 equals를 정의하지 않았으므로 first.equals(copy)를 호출해도 ==와 똑같이 동일성을 비교해 false가 나옵니다. 지금 단계에서 참조형에 쓴 ==는 '같은 객체인가'를 묻는 연산으로만 읽으세요.
객체를 독립적으로 복사하려면
대입이 참조값만 복사한다면, 원본과 따로 수정할 수 있는 사본은 어떻게 만들까요? 새 객체를 new로 만들고, 원본의 필드 값을 하나씩 옮겨 담으면 됩니다.
public class ManualCopy {
public static void main(String[] args) {
Member original = new Member();
original.email = "[email protected]";
original.age = 40;
original.active = false;
Member copy = new Member();
copy.email = original.email;
copy.age = original.age;
copy.active = original.active;
copy.age = 90;
System.out.println("original=" + original.age);
System.out.println("copy=" + copy.age);
}
}original=40
copy=90new가 두 번 실행되었으므로 객체가 두 개입니다. 필드를 옮기는 세 줄도 각각 평범한 대입이라서, age와 active에는 값 자체가 복사됩니다. 그래서 copy.age를 90으로 바꿔도 original.age는 40으로 남습니다.
email 필드는 조금 다릅니다. String은 참조형이므로 copy.email = original.email은 문자열을 복제하지 않고 같은 문자열 객체의 참조값을 복사합니다. 그래도 문제가 되지 않는 이유는 String이 불변(immutable)이기 때문입니다. 문자열 객체는 한번 만들어지면 내용을 바꿀 방법이 없으므로, 두 객체가 같은 문자열을 함께 가리켜도 한쪽에서 문자열 내용을 바꿔 다른 쪽을 놀라게 할 수 없습니다. copy.email = "[email protected]"처럼 대입하면 copy의 필드가 다른 문자열을 가리키게 될 뿐, original.email은 그대로입니다.
이렇게 필드에 든 값을 그대로 옮기는 복사를 얕은 복사(shallow copy)라고 합니다. 지금의 Member처럼 필드가 기본형이거나 불변 객체뿐이면 얕은 복사만으로도 원본과 사본이 완전히 독립적입니다. 하지만 Member에 배열처럼 내용을 바꿀 수 있는 객체가 필드로 들어오면, 얕은 복사는 그 객체의 참조값만 옮기므로 원본과 사본이 그 객체를 함께 쓰게 됩니다. 새 바깥 객체를 만들었다고 해서 안쪽 상태까지 모두 독립적인 것은 아닙니다.
더 깊이: 얕은 복사가 공유하는 것
Member에 관심 태그를 담는 String[] tags 필드가 있다고 해 봅시다. 필드를 하나씩 옮기는 방식으로 사본을 만들면 copy.tags = original.tags도 참조값 복사이므로 두 회원이 같은 배열을 가리킵니다. original.tags가 {"basic", "event"}였다면, 이 상태에서 copy.tags[0] = "vip"를 실행한 뒤 java.util.Arrays.toString(original.tags)로 원본을 출력해도 [vip, event]가 나오고, original.tags == copy.tags는 true입니다. 배열 객체는 하나이고, 두 회원 객체가 그 배열을 함께 가리키기 때문입니다.
사본이 배열까지 따로 가져야 한다면 copy.tags = java.util.Arrays.copyOf(original.tags, original.tags.length)처럼 배열도 새로 만들어 복사해야 합니다. 이렇게 안쪽의 가변 객체까지 새로 만들어 복사하는 것을 깊은 복사(deep copy)라고 부릅니다. 어디까지 복사하고 어디부터 공유할지는 언어가 정해 주지 않으므로 설계할 때 정해야 합니다.
자바의 모든 객체에는 Object에서 물려받은 clone()이라는 복사 메서드도 있지만, 기본 동작이 얕은 복사인 데다 직접 만든 클래스에서 쓰려면 Cloneable 인터페이스 구현 같은 규칙을 따로 지켜야 해서 이 교재의 클래스들은 사용하지 않습니다. 다만 배열의 clone()은 같은 길이의 새 배열을 만들어 원소를 복사해 주므로, 뒤의 장들에서 tags.clone()처럼 배열을 복사할 때 쓰입니다. 9장에서는 공유해도 안전한 불변 객체로 이 문제를 다룹니다.
별칭은 백업이 아닙니다
다음 코드는 원본을 고치기 전에 백업을 만들어 두려는 의도로 작성되었습니다. original은 앞 절처럼 나이가 40인 회원입니다.
Member backup = original;
original.age = 120;
System.out.println(backup.age); // 40을 기대했지만 120backup은 객체의 과거 상태를 저장하지 않습니다. 같은 객체로 가는 길을 하나 더 만들었을 뿐이므로, original로 바꾼 값이 backup에서도 그대로 보입니다. 되돌리기 기능이나 변경 전 상태를 보관해야 한다면, 앞 절처럼 새 객체에 값을 복사해 두거나 9장에서 배울 불변 객체처럼 상태를 바꾸는 방식 자체를 다시 설계해야 합니다.
그렇다고 공유 참조가 나쁜 것은 아닙니다. 프로그램의 여러 부분이 같은 회원을 다룰 때 모두 같은 객체를 가리키면, 한 곳에서 바꾼 내용이 모든 곳에 바로 반영되고 메모리도 아낄 수 있습니다. 문제는 공유하고 있다는 사실을 모르거나, 공유를 독립된 복사로 착각할 때 생깁니다.
정리
- 대입은 언제나 오른쪽에서 읽은 값을 복사합니다. 기본형은 실제 값이, 참조형은 참조값이 복사됩니다.
- 참조값을 복사하면 두 변수가 같은 객체를 가리킵니다. 한쪽에서 필드를 바꾸면 다른 쪽에서도 바뀐 값이 보입니다.
- 참조 변수에
변수 = ...로 대입하면 그 변수가 가리키는 객체가 바뀌고,변수.필드 = ...로 대입하면 가리키는 객체의 내용이 바뀝니다. - 참조형의
==는 같은 객체인지(동일성)를 비교합니다. 내용이 같은지(동등성)는equals로 비교하며, 9장에서 직접 정의합니다. - 독립된 사본이 필요하면 새 객체를 만들어 필드를 복사합니다. 가변 객체를 필드로 가지면 그 객체까지 복사할지 따로 정해야 합니다.
연습 문제
- 이메일
"[email protected]", 나이 25세, 비활성인original회원을 만드세요.Member alias = original;로 별칭을 만들고alias.age = 60을 실행한 뒤, 원본의 나이와original == alias의 결과를 출력하세요. - 원본의 나이를 25로 되돌린 다음, 이번에는 새
Member를 만들어 필드를 복사한edited를 만들고edited.age = 60을 실행하세요. 원본의 나이, 수정본의 나이,original == edited의 결과를 출력하세요. - 2번에서
email필드는 같은 문자열 객체를 함께 가리키게 됩니다. 이것이 이번 예제에서 문제가 되지 않는 이유를 설명하세요.
해설 보기
public class MemberDraft {
public static void main(String[] args) {
Member original = new Member();
original.email = "[email protected]";
original.age = 25;
original.active = false;
Member alias = original;
alias.age = 60;
System.out.println("별칭 원본=" + original.age);
System.out.println("별칭 동일=" + (original == alias));
original.age = 25;
Member edited = new Member();
edited.email = original.email;
edited.age = original.age;
edited.active = original.active;
edited.age = 60;
System.out.println("복사 원본=" + original.age);
System.out.println("복사 수정본=" + edited.age);
System.out.println("복사 동일=" + (original == edited));
}
}별칭 원본=60
별칭 동일=true
복사 원본=25
복사 수정본=60
복사 동일=false1번에서 alias는 original의 참조값을 복사했으므로 같은 객체를 가리킵니다. 그래서 alias.age = 60이 원본 객체의 나이를 바꾸고, original == alias는 true입니다.
2번에서 edited는 new로 만든 별도 객체이고, 필드 세 개를 옮긴 뒤 자기 age만 60으로 바꿨습니다. 원본 객체는 건드리지 않았으므로 원본의 나이는 25 그대로이고, 두 변수가 다른 객체를 가리키므로 original == edited는 false입니다.
3번의 답은 String이 불변이라는 데 있습니다. 두 객체의 email 필드가 같은 문자열 객체를 가리키더라도 그 문자열의 내용을 바꿀 방법이 없습니다. edited.email = "[email protected]"처럼 대입하면 edited의 필드가 다른 문자열 객체를 가리키게 될 뿐이고, original.email은 여전히 [email protected]입니다. 그래서 이 얕은 복사에서 문자열을 공유하는 것은 바꿀 수 있는 객체를 공유하는 것과 달리 문제를 만들지 않습니다.
다음 글
메서드 호출과 값 전달에서는 이 대입 규칙이 메서드를 호출할 때도 그대로 적용된다는 것을 확인합니다. 인수가 매개변수로 복사되는 과정을 따라가면, 메서드 안에서 필드를 바꾸면 호출한 쪽에 보이지만 매개변수에 새 객체를 대입하면 보이지 않는 이유를 설명할 수 있습니다.