값 복사와 참조 공유
대입이 항상 변수 값을 복사한다는 규칙을 기본형과 참조형에 적용하고 게시글이 뜻밖에 함께 변하는 원인을 추적합니다.
자바의 대입은 언제나 오른쪽 변수에 들어 있는 값을 복사합니다.
int 변수에는 숫자가 들어 있으므로 숫자를 복사하고, 객체 변수에는 인스턴스 위치를 가리키는 참조값이 들어 있으므로 참조값을 복사합니다.
규칙은 하나지만 복사된 값의 종류가 달라 이후 변경의 영향이 달라집니다.
복사본과 원본의 동시 변경
게시글을 임시로 복사해 분량을 조정한 뒤 원본과 비교하려고 했습니다.
변수 대입으로 복사했다고 생각했지만 두 변수는 같은 MutableEntry를 가리킵니다.
public final class ReferenceCopySurprise {
public static void main(String[] args) {
MutableEntry original = new MutableEntry("가입 인사", 40);
MutableEntry draft = original;
draft.setViewCount(90);
System.out.println("original=" + original.viewCount());
System.out.println("draft=" + draft.viewCount());
System.out.println("same=" + (original == draft));
}
private static final class MutableEntry {
private final String title;
private int viewCount;
MutableEntry(String title, int viewCount) {
this.title = title;
this.viewCount = viewCount;
}
void setViewCount(int viewCount) { this.viewCount = viewCount; }
int viewCount() { return viewCount; }
}
}original=90
draft=90
same=truedraft = original은 객체의 필드를 새 메모리에 복제하지 않습니다.
original이 가진 참조값을 draft에 복사합니다.
두 변수가 같은 객체를 가리키므로 draft를 통한 필드 변경을 original에서도 관찰할 수 있습니다.
이처럼 별칭(alias)이 생겼다는 사실은 변수 이름만 보고 알아내기 어렵습니다.
이것은 단순한 출력 차이가 아니라 “편집본만 수정한다”는 요구를 깨뜨린 공유 참조 버그입니다.
실패 원인은 복사 생성이 아니라 참조 대입을 사용한 데 있습니다.
기본형 값 복사
기본형 변수에는 값 자체가 들어 있습니다.
한 변수를 다른 변수에 대입한 뒤 두 번째 변수를 바꿔도 첫 번째 변수는 그대로입니다.
public final class PrimitiveValueCopy {
public static void main(String[] args) {
int plannedViewCount = 40;
int revisedViewCount = plannedViewCount;
revisedViewCount = 90;
System.out.println("planned=" + plannedViewCount);
System.out.println("revised=" + revisedViewCount);
}
}planned=40
revised=90두 int 변수는 각각 자기 저장 공간에 40을 가집니다.
revisedViewCount에 90을 대입하면 그 변수의 숫자만 교체됩니다.
“기본형은 공유되지 않는다”는 설명은 객체처럼 별도 인스턴스를 가리키는 참조값이 없다는 뜻입니다.
매개변수 전달도 같은 값 복사 규칙을 사용합니다.
int를 메서드에 넘기면 숫자 복사본이 전달됩니다.
객체 변수를 넘기면 참조값 복사본이 전달되므로 매개변수와 호출자 변수가 같은 인스턴스를 볼 수 있습니다.
참조 재대입과 상태 변경
참조를 복사한 뒤 새 객체를 변수에 다시 대입하는 일은 기존 객체를 수정하는 것과 다릅니다.
재대입은 그 변수만 다른 인스턴스를 가리키게 합니다.
public final class ReferenceRebinding {
public static void main(String[] args) {
Post first = new Post("가입 인사", 40);
Post second = first;
second = new Post("로그인 구현", 50);
System.out.println("first=" + first);
System.out.println("second=" + second);
System.out.println("same=" + (first == second));
}
private static final class Post {
private final String title;
private final int viewCount;
Post(String title, int viewCount) {
this.title = title;
this.viewCount = viewCount;
}
@Override
public String toString() {
return title + ":" + viewCount;
}
}
}first=가입 인사:40
second=로그인 구현:50
same=false처음에는 first와 second가 같은 객체를 봅니다.
second = new Post(...) 이후 second 변수의 참조값만 새 인스턴스로 교체됩니다.
first가 가리키던 객체는 변경되지 않았습니다.
다음 두 문장을 코드 리뷰에서 구별해야 합니다.
second.setViewCount(50): second가 가리키는 객체의 상태 변경second = new Post(...): second 변수의 참조 대상 변경
final 참조 변수는 두 번째 재대입을 막지만 첫 번째 상태 변경까지 막지는 않습니다.
객체 자체의 불변성은 클래스가 변경 메서드를 제공하지 않고 내부 가변 상태를 노출하지 않도록 별도로 설계해야 합니다.
메서드 호출의 참조값 복사
다음 예제는 메서드가 매개변수 자체를 다른 객체로 바꿔도 호출자 변수가 유지되는 한편, 같은 객체의 필드를 바꾸면 호출자에게 보인다는 차이를 드러냅니다.
public final class ParameterValueCopy {
public static void main(String[] args) {
PublishQuota quota = new PublishQuota(100);
changeObject(quota);
System.out.println("after-object-change=" + quota.targetCount());
rebindParameter(quota);
System.out.println("after-rebind=" + quota.targetCount());
}
private static void changeObject(PublishQuota quota) {
quota.correctTargetCount(120);
}
private static void rebindParameter(PublishQuota quota) {
quota = new PublishQuota(999);
System.out.println("inside=" + quota.targetCount());
}
private static final class PublishQuota {
private int targetCount;
PublishQuota(int targetCount) { this.targetCount = targetCount; }
void correctTargetCount(int targetCount) { this.targetCount = targetCount; }
int targetCount() { return targetCount; }
}
}after-object-change=120
inside=999
after-rebind=120매개변수 quota는 호출자 참조값의 복사본입니다.
correctTargetCount 호출은 두 참조가 함께 보는 객체를 수정합니다.
반면 매개변수에 새 참조를 넣는 일은 지역 변수만 바꾸므로 main의 quota에는 영향을 주지 않습니다.
자바는 참조를 “참조에 의한 전달”로 넘기는 것이 아니라 참조값도 값으로 복사해 전달합니다.
의도한 공유와 우연한 공유
같은 객체를 여러 변수가 보는 것 자체가 잘못은 아닙니다.
하나의 Board를 화면과 저장 서비스가 함께 보고 최신 상태를 공유하려는 경우에는 의도된 별칭입니다.
임시 편집본, 이력 스냅숏, 명령 실행 전후 비교처럼 독립 상태를 기대하면 공유가 결함이 됩니다.
선택 기준을 코드로 명확히 합니다.
- 같은 변경을 모든 관찰자가 즉시 봐야 하면 동일 인스턴스를 전달합니다.
- 과거 시점 값을 보존해야 하면 불변 값이나 방어적 복사본을 전달합니다.
- 재대입만 막고 싶다면 final 참조를 사용합니다.
- 객체 내부 변경도 차단하려면 불변 클래스를 설계합니다.
- 복사 비용이 크다면 전체 복사 대신 변경 이력이나 공유 불변 구조를 검토합니다.
변수 이름에 copy를 붙이는 것으로 복사가 생기지는 않습니다.
생성자, 복사 팩터리, with... 메서드처럼 새 인스턴스를 만드는 코드가 보여야 독립성을 읽을 수 있습니다.
연습 문제
MutableEntry를 인수로 받아 필드값이 같은 새 객체를 만드는 copyOf를 구현하세요.
복사본의 조회 수를 바꾼 뒤 원본 조회 수가 유지되는지 확인합니다.
해설 보기
public final class MutableEntryCopyExercise {
public static void main(String[] args) {
MutableEntry original = new MutableEntry("string", 45);
MutableEntry draft = MutableEntry.copyOf(original);
draft.setViewCount(70);
System.out.println("original=" + original);
System.out.println("draft=" + draft);
System.out.println("same=" + (original == draft));
}
private static final class MutableEntry {
private final String title;
private int viewCount;
MutableEntry(String title, int viewCount) {
this.title = title;
this.viewCount = viewCount;
}
static MutableEntry copyOf(MutableEntry source) {
return new MutableEntry(source.title, source.viewCount);
}
void setViewCount(int viewCount) { this.viewCount = viewCount; }
@Override
public String toString() { return title + ":" + viewCount; }
}
}original=string:45
draft=string:70
same=false새 인스턴스를 만들었기 때문에 draft의 상태 변경은 original에 전달되지 않습니다.
필드가 다른 가변 객체를 품고 있다면 그 내부까지 복사할지 정책을 추가로 정해야 합니다.