본문 바로가기

값 복사와 참조 공유

대입과 메서드 호출이 언제나 변수의 값을 복사한다는 규칙으로, 복사했다고 생각한 게시글이 원본과 함께 바뀌는 이유를 찾고 독립된 복사본을 만드는 방법을 배웁니다.

게시글을 고치기 전에 편집용 사본을 하나 만들어 두고, 사본에서 조회 수를 조정한 뒤 원본과 비교하려고 합니다. MutableEntry draft = original;로 사본을 만들고 draft의 조회 수를 90으로 바꿨는데, 원본을 출력해 보니 원본의 조회 수도 90이 되어 있습니다. 사본만 고쳤는데 원본까지 바뀐 것입니다.

기본형과 참조형 복사와 메서드 호출과 값 전달에서 이 현상의 원인을 이미 배웠습니다. 자바의 대입은 언제나 오른쪽 변수에 들어 있는 값을 복사합니다. int 변수에는 숫자가 들어 있으니 숫자가 복사되고, 객체를 가리키는 변수에는 참조값이 들어 있으니 참조값이 복사됩니다. 규칙은 하나지만 복사된 값의 종류가 달라서, 복사한 뒤 무언가를 바꿨을 때 그 영향이 미치는 범위가 달라집니다.

이 글에서는 이 규칙을 게시글 예제에 다시 적용해, 원본이 함께 바뀌는 이유를 한 줄씩 따라갑니다. 그리고 변수를 바꾸는 것과 객체를 바꾸는 것을 구별한 뒤, 정말로 독립된 사본이 필요할 때 어떻게 만드는지 살펴봅니다.

사본을 고쳤는데 원본도 바뀝니다

처음 상황을 코드로 옮겨 보겠습니다. MutableEntry는 제목과 조회 수를 가진 게시글이고, setViewCount로 조회 수를 바꿀 수 있는 가변(mutable) 객체입니다.

lab/ReferenceCopySurprise.java
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=true

프로그램은 오류 없이 끝나지만 '사본만 고친다'는 의도는 지켜지지 않았습니다. 실행 과정을 한 줄씩 따라가 보겠습니다. 표에서 x101은 설명을 위해 붙인 참조값 이름이고, 실제 메모리 주소가 아닙니다.

실행한 문장originaldraftx101 객체의 조회 수
MutableEntry original = new MutableEntry("가입 인사", 40);x101(아직 없음)40
MutableEntry draft = original;x101x10140
draft.setViewCount(90);x101x10190

new는 첫 줄에서 한 번만 실행되었으므로 객체는 하나뿐입니다. 둘째 줄의 draft = original은 객체의 필드를 새 객체에 복제하는 것이 아니라, original에 든 참조값 x101을 draft에 복사합니다. 그래서 셋째 줄의 draft.setViewCount(90)은 draft의 참조를 따라가 그 하나뿐인 객체의 조회 수를 바꿉니다. original도 같은 객체를 가리키므로 original.viewCount()도 90을 돌려주고, 두 변수에 같은 참조값이 들어 있으니 original == draft는 true입니다.

이처럼 같은 객체를 가리키는 또 다른 변수를 별칭(alias)이라고 합니다. 별칭의 문제는 변수 이름만 봐서는 드러나지 않는다는 점입니다. draft라는 이름은 사본처럼 보이지만, 실제로는 원본으로 가는 길을 하나 더 만들었을 뿐입니다. 이 버그의 원인은 사본을 만드는 코드 대신 참조를 대입하는 코드를 쓴 데 있습니다.

기본형 변수는 값 자체를 복사합니다

같은 구조를 int로 바꾸면 결과가 다릅니다. 앞의 ReferenceCopySurprise처럼 한 변수를 다른 변수에 대입한 뒤 둘째 변수만 90으로 바꿉니다. 이번에는 첫째 변수의 값이 따라 바뀌는지 보세요.

src/PrimitiveValueCopy.java
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 변수에는 숫자 자체가 들어 있습니다. 둘째 줄의 대입은 plannedViewCount에서 읽은 40을 revisedViewCount에 복사하므로, 사실상 int revisedViewCount = 40;과 같습니다. 복사가 끝나면 두 변수 사이에는 아무 연결도 남지 않습니다. 그래서 revisedViewCount에 90을 대입해도 그 변수의 값만 바뀌고 plannedViewCount는 40 그대로입니다.

흔히 '기본형은 공유되지 않는다'고 말하는데, 정확히는 기본형 변수에는 다른 곳에 있는 객체를 가리키는 참조값이 없다는 뜻입니다. 대입 규칙 자체는 앞의 MutableEntry와 똑같습니다. 두 경우 모두 변수에 든 값을 복사했고, 그 값이 숫자냐 참조값이냐만 달랐습니다.

변수를 바꾸는 대입과 객체를 바꾸는 호출

참조를 복사한 뒤에 변수에 새 객체를 대입하는 것은 기존 객체를 고치는 것과 전혀 다른 일입니다. 다음 프로그램에서 second는 처음에 first와 같은 객체를 가리키다가, 다음 줄에서 새 객체를 대입받습니다.

src/ReferenceRebinding.java
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

Post second = first; 직후에는 두 변수가 같은 객체를 가리킵니다. 하지만 second = new Post(...)는 새 객체를 만들고 그 참조값을 second 변수에 넣을 뿐, first가 가리키는 객체에는 손대지 않습니다. 그래서 first는 처음 객체를 그대로 가리키고, 두 변수가 이제 다른 객체를 가리키므로 first == second는 false입니다. 출력에 가입 인사:40 같은 문자열이 나오는 것은 Post가 Object 클래스와 equals·hashCode·toString에서 본 방법으로 toString()을 재정의했기 때문입니다.

코드를 읽을 때는 다음 두 모양을 구별해야 합니다.

second.setViewCount(50);       // second가 가리키는 객체의 상태를 바꿉니다
second = new Post("새 글", 0);  // second 변수가 가리킬 객체를 바꿉니다

첫 줄은 참조를 따라가 객체의 필드를 바꾸므로, 같은 객체를 가리키는 다른 변수에서도 바뀐 값이 보입니다. 둘째 줄은 변수 하나의 값만 바꾸므로 다른 변수에는 아무 영향이 없습니다. 첫 줄은 MutableEntry처럼 상태를 바꾸는 메서드가 있는 클래스에서만 쓸 수 있습니다. 위 예제의 Post는 필드가 모두 final이고 setViewCount가 없으므로, 이 클래스에 첫 줄을 그대로 쓰면 컴파일되지 않습니다.

final은 이 가운데 둘째 줄만 막습니다. final MutableEntry fixed = original;처럼 선언한 변수에 다른 객체를 대입하면 cannot assign a value to final variable fixed라는 컴파일 오류가 나지만, fixed.setViewCount(10)은 그대로 허용됩니다. final은 변수에 다른 값을 넣지 못하게 할 뿐 그 변수가 가리키는 객체의 상태까지 고정하지 않기 때문이고, 자세한 내용은 final 참조와 객체 변경에서 다뤘습니다. 객체 자체를 바꿀 수 없게 하려면 클래스가 상태를 바꾸는 메서드를 제공하지 않도록 따로 설계해야 합니다.

아래 그림은 이 글의 세 프로그램을 나란히 비교합니다. 세 행은 서로 다른 프로그램이므로 이어지는 하나의 실행으로 읽지 않습니다. 단계를 넘기며 기본형 대입, 공유 객체의 필드 변경, 참조 재대입이 각각 무엇을 바꾸는지 확인해 보세요.

대입 뒤 바뀌는 값과 관찰 범위를 세 프로그램에서 비교한다

값 복사 후 재대입·참조 공유 후 필드 변경·새 객체로 참조 재대입의 세 독립 사례입니다. 초기와 마지막 상태를 대응시켜 무엇이 변하는지 보여 주되 사례 사이 연결을 만들지 않습니다.

대입 뒤 바뀌는 값과 관찰 범위를 세 프로그램에서 비교한다

기본형 재대입은 두 번째 숫자만 바꾸고, 공유 참조를 통한 필드 변경은 두 변수에서 보이며, 새 객체 참조 재대입은 해당 변수의 대상만 바꾼다. 세 행은 별도 원문 프로그램이다.

숫자 대입·객체 변경·참조 재대입은 관찰 결과가 다릅니다

아래 행은 서로 다른 세 프로그램입니다. 위에서 아래로 이어지는 하나의 실행이 아닙니다.

숫자 대입·객체 변경·참조 재대입은 관찰 결과가 다릅니다 세 독립 실행을 비교한다. 기본형은40값복사뒤 revised에90대입, 참조복사에서는original과draft가같은Entry90, 재대입에서는first의가입인사40과second의로그인구현50이서로다른Post이다. 기본형 예제 · 값 복사 이후 대입 planned 변수 int 값 40 revised 변수 int 값 90 40 복사 뒤 90 대입 참조 복사 예제 · 같은 객체 original 참조 draft 참조 같은 Entry 가입 인사 / 90 참조 재대입 예제 · 서로 다른 Post first 참조 second 참조 기존 Post 가입 인사 / 40 새 Post 로그인 구현 / 50 각 영역은 독립 프로그램의 마지막 상태 · 영역 사이 실행 연결 없음
숫자 대입·객체 변경·참조 재대입은 관찰 결과가 다릅니다 세 독립 실행을 비교한다. 기본형은40값복사뒤 revised에90대입, 참조복사에서는original과draft가같은Entry90, 재대입에서는first의가입인사40과second의로그인구현50이서로다른Post이다. 기본형 예제 · 별도 실행 planned 변수 int 값 40 revised 변수 int 값 90 40 복사 뒤 90 대입 참조 복사 · 별도 실행 original 참조 draft 참조 같은 Entry 조회 수 90 참조 재대입 · 별도 실행 first 참조: 기존 Post 기존 Post 가입 인사 / 40 second 새 참조 새 Post 50 실선: 현재 참조 · 점선: 값 복사
별도 프로그램의 숫자 대입, 필드 변경, 참조 재대입 비교
원문 프로그램무엇을 바꿨는가실제 출력
PrimitiveValueCopyrevisedViewCount = plannedViewCount로 40을 복사한 뒤 revisedViewCount = 90으로 숫자만 재대입합니다.
planned=40
revised=90
ReferenceCopySurprisedraft = original 뒤 draft.setViewCount(90)이 두 변수가 함께 보는 MutableEntry를 수정합니다.
original=90
draft=90
same=true
ReferenceRebindingsecond = first 뒤 second = new Post("로그인 구현", 50)이 second의 대상만 새 객체로 바꿉니다.
first=가입 인사:40
second=로그인 구현:50
same=false
PrimitiveValueCopy
무엇을 바꿨는가: revisedViewCount = plannedViewCount로 40을 복사한 뒤 revisedViewCount = 90으로 숫자만 재대입합니다.
실제 출력:
planned=40
revised=90
ReferenceCopySurprise
무엇을 바꿨는가: draft = original 뒤 draft.setViewCount(90)이 두 변수가 함께 보는 MutableEntry를 수정합니다.
실제 출력:
original=90
draft=90
same=true
ReferenceRebinding
무엇을 바꿨는가: second = first 뒤 second = new Post("로그인 구현", 50)이 second의 대상만 새 객체로 바꿉니다.
실제 출력:
first=가입 인사:40
second=로그인 구현:50
same=false

ReferenceCopySurprise는 정상 종료하지만 “편집본만 수정한다”는 요구를 깨뜨립니다. 다른 행의 Post와 MutableEntry를 같은 실행의 한 객체로 섞지 않습니다.

이 예의 조건

  • 세 영역은 PrimitiveValueCopy, ReferenceCopySurprise, ReferenceRebinding의 독립 프로그램입니다. 복사 준비→변경 동작→각 관찰의 공통 단계마다 비교합니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2세 독립 실행을 비교한다. 기본형은 40 값 복사 뒤 revised에 90을 대입하고, 참조 복사에서는 original과 draft가 같은 Entry 90을 가리키며, 재대입에서는 first의 가입 인사 40과 second의 로그인 구현 50이 서로 다른 Post이다.
15가입 인사
1690
23가입 인사
26로그인 구현
원 도표의 설명과 주석
구분내용
설명세 독립 실행을 비교한다. 기본형은 40 값 복사 뒤 revised에 90을 대입하고, 참조 복사에서는 original과 draft가 같은 Entry 90을 가리키며, 재대입에서는 first의 가입 인사 40과 second의 로그인 구현 50이 서로 다른 Post이다.
전체 읽기 정보대입 뒤 바뀌는 값과 관찰 범위를 세 프로그램에서 비교한다 대입 뒤 바뀌는 값과 관찰 범위를 세 프로그램에서 비교한다 기본형 재대입은 두 번째 숫자만 바꾸고, 공유 참조를 통한 필드 변경은 두 변수에서 보이며, 새 객체 참조 재대입은 해당 변수의 대상만 바꾼다. 세 행은 별도 원문 프로그램이다. 숫자 대입·객체 변경·참조 재대입은 관찰 결과가 다릅니다 아래 행은 서로 다른 세 프로그램입니다. 위에서 아래로 이어지는 하나의 실행이 아닙니다. 별도 프로그램의 숫자 대입, 필드 변경, 참조 재대입 비교 원문 프로그램 무엇을 바꿨는가 실제 출력 PrimitiveValueCopy revisedViewCount = plannedViewCount 로 40 을 복사한 뒤 revisedViewCount = 90 으로 숫자만 재대입합니다. planned=40 revised=90 ReferenceCopySurprise draft = original 뒤 draft.setViewCount(90) 이 두 변수가 함께 보는 MutableEntry 를 수정합니다. original=90 draft=90 same=true ReferenceRebinding second = first 뒤 second = new Post("로그인 구현", 50) 이 second 의 대상만 새 객체로 바꿉니다. first=가입 인사:40 second=로그인 구현:50 same=false PrimitiveValueCopy 무엇을 바꿨는가: revisedViewCount = plannedViewCount 로 40 을 복사한 뒤 revisedViewCount = 90 으로 숫자만 재대입합니다. 실제 출력: planned=40 revised=90 ReferenceCopySurprise 무엇을 바꿨는가: draft = original 뒤 draft.setViewCount(90) 이 두 변수가 함께 보는 MutableEntry 를 수정합니다. 실제 출력: original=90 draft=90 same=true ReferenceRebinding 무엇을 바꿨는가: second = first 뒤 second = new Post("로그인 구현", 50) 이 second 의 대상만 새 객체로 바꿉니다. 실제 출력: first=가입 인사:40 second=로그인 구현:50 same=false ReferenceCopySurprise 는 정상 종료하지만 “편집본만 수정한다”는 요구를 깨뜨립니다. 다른 행의 Post 와 MutableEntry 를 같은 실행의 한 객체로 섞지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
각 프로그램의 복사 준비

숫자 값과 객체 참조를 각각 두 변수에 대입한 직후를 비교합니다.

  • 기본형: planned=40, revised=40
  • 참조 복사: original·draft는 가입 인사 40의 같은 MutableEntry
  • 참조 재대입 전: first·second는 가입 인사 40의 같은 Post
  • 숫자는 각 변수 값이고 두 객체 사례는 각자의 동일 객체 참조를 복사합니다. 서로 다른 프로그램의 객체를 공유하지 않습니다.
각 변경 동작

각 프로그램이 숫자 대입, 공유 객체 쓰기, 새 객체 참조 대입을 실행합니다.

  • 기본형 변경: revisedViewCount=90
  • 공유 객체 변경: draft.setViewCount(90)
  • 참조 재대입: second=new Post("로그인 구현",50)
  • 숫자 대입은 revised만, setter는 공유 MutableEntry의 필드만, 새 참조 대입은 second의 대상을 바꿉니다.
각 프로그램의 관찰

변경 뒤 각 변수로 읽은 값과 참조 동일성은 다른 결과입니다.

  • 숫자 관찰: planned=40, revised=90
  • 공유 관찰: original=90, draft=90, same=true
  • 재대입 관찰: first=가입 인사 40, second=로그인 구현 50, same=false
  • 공유 객체 쓰기는 original에서도 90으로 보이지만 숫자와 참조 변수의 재대입은 첫 변수의 값·대상을 바꾸지 않습니다.

대입 뒤 바뀌는 값과 관찰 범위를 세 프로그램에서 비교한다

값 복사 후 재대입·참조 공유 후 필드 변경·새 객체로 참조 재대입의 세 독립 사례입니다. 초기와 마지막 상태를 대응시켜 무엇이 변하는지 보여 주되 사례 사이 연결을 만들지 않습니다.

대입 뒤 바뀌는 값과 관찰 범위를 세 프로그램에서 비교한다

기본형 재대입은 두 번째 숫자만 바꾸고, 공유 참조를 통한 필드 변경은 두 변수에서 보이며, 새 객체 참조 재대입은 해당 변수의 대상만 바꾼다. 세 행은 별도 원문 프로그램이다.

숫자 대입·객체 변경·참조 재대입은 관찰 결과가 다릅니다

아래 행은 서로 다른 세 프로그램입니다. 위에서 아래로 이어지는 하나의 실행이 아닙니다.

숫자 대입·객체 변경·참조 재대입은 관찰 결과가 다릅니다 세 독립 실행을 비교한다. 기본형은40값복사뒤 revised에90대입, 참조복사에서는original과draft가같은Entry90, 재대입에서는first의가입인사40과second의로그인구현50이서로다른Post이다. 기본형 예제 · 값 복사 이후 대입 planned 변수 int 값 40 revised 변수 int 값 90 40 복사 뒤 90 대입 참조 복사 예제 · 같은 객체 original 참조 draft 참조 같은 Entry 가입 인사 / 90 참조 재대입 예제 · 서로 다른 Post first 참조 second 참조 기존 Post 가입 인사 / 40 새 Post 로그인 구현 / 50 각 영역은 독립 프로그램의 마지막 상태 · 영역 사이 실행 연결 없음
숫자 대입·객체 변경·참조 재대입은 관찰 결과가 다릅니다 세 독립 실행을 비교한다. 기본형은40값복사뒤 revised에90대입, 참조복사에서는original과draft가같은Entry90, 재대입에서는first의가입인사40과second의로그인구현50이서로다른Post이다. 기본형 예제 · 별도 실행 planned 변수 int 값 40 revised 변수 int 값 90 40 복사 뒤 90 대입 참조 복사 · 별도 실행 original 참조 draft 참조 같은 Entry 조회 수 90 참조 재대입 · 별도 실행 first 참조: 기존 Post 기존 Post 가입 인사 / 40 second 새 참조 새 Post 50 실선: 현재 참조 · 점선: 값 복사
별도 프로그램의 숫자 대입, 필드 변경, 참조 재대입 비교
원문 프로그램무엇을 바꿨는가실제 출력
PrimitiveValueCopyrevisedViewCount = plannedViewCount로 40을 복사한 뒤 revisedViewCount = 90으로 숫자만 재대입합니다.
planned=40
revised=90
ReferenceCopySurprisedraft = original 뒤 draft.setViewCount(90)이 두 변수가 함께 보는 MutableEntry를 수정합니다.
original=90
draft=90
same=true
ReferenceRebindingsecond = first 뒤 second = new Post("로그인 구현", 50)이 second의 대상만 새 객체로 바꿉니다.
first=가입 인사:40
second=로그인 구현:50
same=false
PrimitiveValueCopy
무엇을 바꿨는가: revisedViewCount = plannedViewCount로 40을 복사한 뒤 revisedViewCount = 90으로 숫자만 재대입합니다.
실제 출력:
planned=40
revised=90
ReferenceCopySurprise
무엇을 바꿨는가: draft = original 뒤 draft.setViewCount(90)이 두 변수가 함께 보는 MutableEntry를 수정합니다.
실제 출력:
original=90
draft=90
same=true
ReferenceRebinding
무엇을 바꿨는가: second = first 뒤 second = new Post("로그인 구현", 50)이 second의 대상만 새 객체로 바꿉니다.
실제 출력:
first=가입 인사:40
second=로그인 구현:50
same=false

ReferenceCopySurprise는 정상 종료하지만 “편집본만 수정한다”는 요구를 깨뜨립니다. 다른 행의 Post와 MutableEntry를 같은 실행의 한 객체로 섞지 않습니다.

이 예의 조건

  • 세 영역은 PrimitiveValueCopy, ReferenceCopySurprise, ReferenceRebinding의 독립 프로그램입니다. 복사 준비→변경 동작→각 관찰의 공통 단계마다 비교합니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2세 독립 실행을 비교한다. 기본형은 40 값 복사 뒤 revised에 90을 대입하고, 참조 복사에서는 original과 draft가 같은 Entry 90을 가리키며, 재대입에서는 first의 가입 인사 40과 second의 로그인 구현 50이 서로 다른 Post이다.
15가입 인사
1690
23가입 인사
26로그인 구현
원 도표의 설명과 주석
구분내용
설명세 독립 실행을 비교한다. 기본형은 40 값 복사 뒤 revised에 90을 대입하고, 참조 복사에서는 original과 draft가 같은 Entry 90을 가리키며, 재대입에서는 first의 가입 인사 40과 second의 로그인 구현 50이 서로 다른 Post이다.
전체 읽기 정보대입 뒤 바뀌는 값과 관찰 범위를 세 프로그램에서 비교한다 대입 뒤 바뀌는 값과 관찰 범위를 세 프로그램에서 비교한다 기본형 재대입은 두 번째 숫자만 바꾸고, 공유 참조를 통한 필드 변경은 두 변수에서 보이며, 새 객체 참조 재대입은 해당 변수의 대상만 바꾼다. 세 행은 별도 원문 프로그램이다. 숫자 대입·객체 변경·참조 재대입은 관찰 결과가 다릅니다 아래 행은 서로 다른 세 프로그램입니다. 위에서 아래로 이어지는 하나의 실행이 아닙니다. 별도 프로그램의 숫자 대입, 필드 변경, 참조 재대입 비교 원문 프로그램 무엇을 바꿨는가 실제 출력 PrimitiveValueCopy revisedViewCount = plannedViewCount 로 40 을 복사한 뒤 revisedViewCount = 90 으로 숫자만 재대입합니다. planned=40 revised=90 ReferenceCopySurprise draft = original 뒤 draft.setViewCount(90) 이 두 변수가 함께 보는 MutableEntry 를 수정합니다. original=90 draft=90 same=true ReferenceRebinding second = first 뒤 second = new Post("로그인 구현", 50) 이 second 의 대상만 새 객체로 바꿉니다. first=가입 인사:40 second=로그인 구현:50 same=false PrimitiveValueCopy 무엇을 바꿨는가: revisedViewCount = plannedViewCount 로 40 을 복사한 뒤 revisedViewCount = 90 으로 숫자만 재대입합니다. 실제 출력: planned=40 revised=90 ReferenceCopySurprise 무엇을 바꿨는가: draft = original 뒤 draft.setViewCount(90) 이 두 변수가 함께 보는 MutableEntry 를 수정합니다. 실제 출력: original=90 draft=90 same=true ReferenceRebinding 무엇을 바꿨는가: second = first 뒤 second = new Post("로그인 구현", 50) 이 second 의 대상만 새 객체로 바꿉니다. 실제 출력: first=가입 인사:40 second=로그인 구현:50 same=false ReferenceCopySurprise 는 정상 종료하지만 “편집본만 수정한다”는 요구를 깨뜨립니다. 다른 행의 Post 와 MutableEntry 를 같은 실행의 한 객체로 섞지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
각 프로그램의 복사 준비

숫자 값과 객체 참조를 각각 두 변수에 대입한 직후를 비교합니다.

  • 기본형: planned=40, revised=40
  • 참조 복사: original·draft는 가입 인사 40의 같은 MutableEntry
  • 참조 재대입 전: first·second는 가입 인사 40의 같은 Post
  • 숫자는 각 변수 값이고 두 객체 사례는 각자의 동일 객체 참조를 복사합니다. 서로 다른 프로그램의 객체를 공유하지 않습니다.
각 변경 동작

각 프로그램이 숫자 대입, 공유 객체 쓰기, 새 객체 참조 대입을 실행합니다.

  • 기본형 변경: revisedViewCount=90
  • 공유 객체 변경: draft.setViewCount(90)
  • 참조 재대입: second=new Post("로그인 구현",50)
  • 숫자 대입은 revised만, setter는 공유 MutableEntry의 필드만, 새 참조 대입은 second의 대상을 바꿉니다.
각 프로그램의 관찰

변경 뒤 각 변수로 읽은 값과 참조 동일성은 다른 결과입니다.

  • 숫자 관찰: planned=40, revised=90
  • 공유 관찰: original=90, draft=90, same=true
  • 재대입 관찰: first=가입 인사 40, second=로그인 구현 50, same=false
  • 공유 객체 쓰기는 original에서도 90으로 보이지만 숫자와 참조 변수의 재대입은 첫 변수의 값·대상을 바꾸지 않습니다.

메서드에 객체를 넘겨도 참조값이 복사됩니다

메서드를 호출할 때도 같은 규칙이 적용됩니다. 인수의 값이 매개변수라는 새 지역 변수에 복사되고, 참조형 인수라면 그 값은 참조값입니다. 다음 프로그램은 게시 목표 수를 담은 PublishQuota를 두 메서드에 차례로 넘깁니다. changeObject는 받은 객체의 필드를 고치고, rebindParameter는 매개변수에 새 객체를 대입합니다.

src/ParameterValueCopy.java
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

changeObject(quota)를 호출하면 main의 quota에 든 참조값이 changeObject의 매개변수 quota에 복사됩니다. 이름은 같지만 서로 다른 두 변수가 같은 객체를 가리키는 것입니다. 그래서 correctTargetCount(120)이 그 객체의 목표 수를 바꾸면, 메서드가 끝난 뒤 main에서 읽어도 120이 나옵니다.

rebindParameter의 매개변수에도 같은 참조값이 복사되지만, 첫 줄에서 새 객체를 대입합니다. 이 대입은 rebindParameter 안의 지역 변수만 바꾸므로, 메서드 안에서는 inside=999가 출력됩니다. 하지만 main의 quota에는 아무것도 대입하지 않았으므로 메서드가 끝난 뒤에도 원래 객체를 가리키고, 세 번째 줄은 여전히 120입니다.

그래서 '자바는 객체를 참조로 전달한다'는 말은 정확하지 않습니다. 참조에 의한 전달(call by reference)이라면 매개변수에 새 객체를 대입했을 때 호출자의 변수도 바뀌어야 하기 때문입니다. 정확한 표현은 '자바는 모든 인수를 값으로 전달하고, 참조형 인수의 값은 참조값이다'입니다. 아래 그림에서 두 호출이 진행되는 동안 main의 변수와 매개변수가 각각 어떤 객체를 가리키는지 따라가 보세요.

매개변수의 객체 변경과 지역 참조 재대입은 결과가 다르다

changeObject는 공유 Quota 100→120을 바꾸고 뒤의 rebindParameter는 지역만 999 객체로 바꿔 main 120을 유지합니다. 같은 호출자 상태와 두 메서드의 변경 대상을 순차로 추적합니다.

매개변수의 객체 변경과 지역 참조 재대입은 결과가 다르다

ParameterValueCopy는 quota의 targetCount를 100에서 120으로 고친 뒤, 다른 호출에서 매개변수만 999 객체로 재대입한다. inside=999 뒤에도 main의 quota는 120을 읽는다.

같은 객체를 바꾸는 호출과 매개변수만 바꾸는 호출

ParameterValueCopy의 main은 new PublishQuota(100)을 생성합니다. 두 메서드 호출과 출력의 실제 순서를 비교합니다.

같은 객체를 바꾸는 호출과 매개변수만 바꾸는 호출 changeObject의매개변수와main참조는같은Quota를가리켜100을120으로바꾼다. rebindParameter는매개변수참조만999인새Quota로바꾸고main은기존120을유지한다. 각영역은해당호출중상태다. changeObject 호출 안 · 참조값은 복사, 대상은 공유 main의 quota 참조 매개변수 quota 참조 같은 Quota targetCount: 100 → 120 rebindParameter 호출 안 · 매개변수만 새 참조 main의 quota 기존 참조 매개변수 quota 새 참조 기존 Quota targetCount = 120 새 Quota targetCount = 999 main 출력120 유지 · 호출 중 매개변수의 inside 출력만999
같은 객체를 바꾸는 호출과 매개변수만 바꾸는 호출 changeObject의매개변수와main참조는같은Quota를가리켜100을120으로바꾼다. rebindParameter는매개변수참조만999인새Quota로바꾸고main은기존120을유지한다. 각영역은해당호출중상태다. changeObject 호출 안 main quota 매개변수 quota 같은 Quota targetCount: 100 → 120 rebindParameter 호출 안 main의 quota 기존 참조 유지 기존 Quota targetCount = 120 매개변수 새 참조 새 Quota 999 호출자 출력: 120 매개변수 출력: 999
같은 객체의 필드 변경과 매개변수 재대입의 관찰 위치
출력 시점참조와 상태출력
changeObject

반환 뒤

correctTargetCount(120)이 호출자와 매개변수가 함께 보는 객체를 수정합니다. main의 quota가 120을 읽습니다.after-object-change=120
rebindParameter

안

quota = new PublishQuota(999)은 이 호출의 매개변수만 새 객체를 가리키게 합니다. main의 참조 대상은 그대로입니다.inside=999
rebindParameter

반환 뒤

main의 quota는 이전 객체의 120을 읽습니다.after-rebind=120
  1. changeObject 반환 뒤
    참조와 상태: correctTargetCount(120)이 호출자와 매개변수가 함께 보는 객체를 수정합니다. main의 quota가 120을 읽습니다.
    출력: after-object-change=120
  2. rebindParameter 안
    참조와 상태: quota = new PublishQuota(999)은 이 호출의 매개변수만 새 객체를 가리키게 합니다. main의 참조 대상은 그대로입니다.
    출력: inside=999
  3. rebindParameter 반환 뒤
    참조와 상태: main의 quota는 이전 객체의 120을 읽습니다.
    출력: after-rebind=120

각 호출의 매개변수는 호출자 참조값의 복사본입니다. 매개변수 재대입이 호출자 변수로 되돌아가지는 않습니다. 새 객체의 회수 시점이나 실제 메모리 주소는 이 비교의 내용이 아닙니다.

이 예의 조건

  • 참조형 인수도 참조값의 복사다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2changeObject의 매개변수와 main 참조는 같은 Quota를 가리켜 100을 120으로 바꾼다. rebindParameter는 매개변수 참조만 999인 새 Quota로 바꾸고 main은 기존 120을 유지한다. 각 영역은 해당 호출 중의 상태다.
19main 출력 120 유지 · 호출 중 매개변수의 inside 출력만 999
원 도표의 설명과 주석
구분내용
설명changeObject의 매개변수와 main 참조는 같은 Quota를 가리켜 100을 120으로 바꾼다. rebindParameter는 매개변수 참조만 999인 새 Quota로 바꾸고 main은 기존 120을 유지한다. 각 영역은 해당 호출 중의 상태다.
전체 읽기 정보매개변수의 객체 변경과 지역 참조 재대입은 결과가 다르다 매개변수의 객체 변경과 지역 참조 재대입은 결과가 다르다 ParameterValueCopy는 quota의 targetCount를 100에서 120으로 고친 뒤, 다른 호출에서 매개변수만 999 객체로 재대입한다. inside=999 뒤에도 main의 quota는 120을 읽는다. 같은 객체를 바꾸는 호출과 매개변수만 바꾸는 호출 ParameterValueCopy 의 main 은 new PublishQuota(100) 을 생성합니다. 두 메서드 호출과 출력의 실제 순서를 비교합니다. 같은 객체의 필드 변경과 매개변수 재대입의 관찰 위치 출력 시점 참조와 상태 출력 changeObject 반환 뒤 correctTargetCount(120) 이 호출자와 매개변수가 함께 보는 객체를 수정합니다. main 의 quota 가 120 을 읽습니다. after-object-change=120 rebindParameter 안 quota = new PublishQuota(999) 은 이 호출의 매개변수만 새 객체를 가리키게 합니다. main 의 참조 대상은 그대로입니다. inside=999 rebindParameter 반환 뒤 main 의 quota 는 이전 객체의 120 을 읽습니다. after-rebind=120 changeObject 반환 뒤 참조와 상태: correctTargetCount(120) 이 호출자와 매개변수가 함께 보는 객체를 수정합니다. main 의 quota 가 120 을 읽습니다. 출력: after-object-change=120 rebindParameter 안 참조와 상태: quota = new PublishQuota(999) 은 이 호출의 매개변수만 새 객체를 가리키게 합니다. main 의 참조 대상은 그대로입니다. 출력: inside=999 rebindParameter 반환 뒤 참조와 상태: main 의 quota 는 이전 객체의 120 을 읽습니다. 출력: after-rebind=120 각 호출의 매개변수는 호출자 참조값의 복사본입니다. 매개변수 재대입이 호출자 변수로 되돌아가지는 않습니다. 새 객체의 회수 시점이나 실제 메모리 주소는 이 비교의 내용이 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 인수 복사

main과 changeObject가 같은 Quota 100을 봅니다.

  • main 대상: Quota 100
  • 매개변수 대상: 같은 객체
  • 호출은 Quota 필드 전체를 복제하지 않는다.
공유 필드 수정

correctTargetCount로 120을 저장합니다.

  • main 관찰: 120
  • 현재 객체: Quota 120
  • 복사된 참조를 통한 쓰기가 공유 객체를 바꾼다.
지역 참조 재대입

rebindParameter는 새 Quota 999를 봅니다.

  • 매개변수 관찰: 999
  • main 대상: 기존 Quota 120
  • 새 참조 대입은 피호출자 지역 변수만 바꾼다.
복귀

main은 여전히 120을 읽습니다.

  • after-rebind: 120
  • 호출자 참조에는 대입이 수행되지 않았다.

매개변수의 객체 변경과 지역 참조 재대입은 결과가 다르다

changeObject는 공유 Quota 100→120을 바꾸고 뒤의 rebindParameter는 지역만 999 객체로 바꿔 main 120을 유지합니다. 같은 호출자 상태와 두 메서드의 변경 대상을 순차로 추적합니다.

매개변수의 객체 변경과 지역 참조 재대입은 결과가 다르다

ParameterValueCopy는 quota의 targetCount를 100에서 120으로 고친 뒤, 다른 호출에서 매개변수만 999 객체로 재대입한다. inside=999 뒤에도 main의 quota는 120을 읽는다.

같은 객체를 바꾸는 호출과 매개변수만 바꾸는 호출

ParameterValueCopy의 main은 new PublishQuota(100)을 생성합니다. 두 메서드 호출과 출력의 실제 순서를 비교합니다.

같은 객체를 바꾸는 호출과 매개변수만 바꾸는 호출 changeObject의매개변수와main참조는같은Quota를가리켜100을120으로바꾼다. rebindParameter는매개변수참조만999인새Quota로바꾸고main은기존120을유지한다. 각영역은해당호출중상태다. changeObject 호출 안 · 참조값은 복사, 대상은 공유 main의 quota 참조 매개변수 quota 참조 같은 Quota targetCount: 100 → 120 rebindParameter 호출 안 · 매개변수만 새 참조 main의 quota 기존 참조 매개변수 quota 새 참조 기존 Quota targetCount = 120 새 Quota targetCount = 999 main 출력120 유지 · 호출 중 매개변수의 inside 출력만999
같은 객체를 바꾸는 호출과 매개변수만 바꾸는 호출 changeObject의매개변수와main참조는같은Quota를가리켜100을120으로바꾼다. rebindParameter는매개변수참조만999인새Quota로바꾸고main은기존120을유지한다. 각영역은해당호출중상태다. changeObject 호출 안 main quota 매개변수 quota 같은 Quota targetCount: 100 → 120 rebindParameter 호출 안 main의 quota 기존 참조 유지 기존 Quota targetCount = 120 매개변수 새 참조 새 Quota 999 호출자 출력: 120 매개변수 출력: 999
같은 객체의 필드 변경과 매개변수 재대입의 관찰 위치
출력 시점참조와 상태출력
changeObject

반환 뒤

correctTargetCount(120)이 호출자와 매개변수가 함께 보는 객체를 수정합니다. main의 quota가 120을 읽습니다.after-object-change=120
rebindParameter

안

quota = new PublishQuota(999)은 이 호출의 매개변수만 새 객체를 가리키게 합니다. main의 참조 대상은 그대로입니다.inside=999
rebindParameter

반환 뒤

main의 quota는 이전 객체의 120을 읽습니다.after-rebind=120
  1. changeObject 반환 뒤
    참조와 상태: correctTargetCount(120)이 호출자와 매개변수가 함께 보는 객체를 수정합니다. main의 quota가 120을 읽습니다.
    출력: after-object-change=120
  2. rebindParameter 안
    참조와 상태: quota = new PublishQuota(999)은 이 호출의 매개변수만 새 객체를 가리키게 합니다. main의 참조 대상은 그대로입니다.
    출력: inside=999
  3. rebindParameter 반환 뒤
    참조와 상태: main의 quota는 이전 객체의 120을 읽습니다.
    출력: after-rebind=120

각 호출의 매개변수는 호출자 참조값의 복사본입니다. 매개변수 재대입이 호출자 변수로 되돌아가지는 않습니다. 새 객체의 회수 시점이나 실제 메모리 주소는 이 비교의 내용이 아닙니다.

이 예의 조건

  • 참조형 인수도 참조값의 복사다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2changeObject의 매개변수와 main 참조는 같은 Quota를 가리켜 100을 120으로 바꾼다. rebindParameter는 매개변수 참조만 999인 새 Quota로 바꾸고 main은 기존 120을 유지한다. 각 영역은 해당 호출 중의 상태다.
19main 출력 120 유지 · 호출 중 매개변수의 inside 출력만 999
원 도표의 설명과 주석
구분내용
설명changeObject의 매개변수와 main 참조는 같은 Quota를 가리켜 100을 120으로 바꾼다. rebindParameter는 매개변수 참조만 999인 새 Quota로 바꾸고 main은 기존 120을 유지한다. 각 영역은 해당 호출 중의 상태다.
전체 읽기 정보매개변수의 객체 변경과 지역 참조 재대입은 결과가 다르다 매개변수의 객체 변경과 지역 참조 재대입은 결과가 다르다 ParameterValueCopy는 quota의 targetCount를 100에서 120으로 고친 뒤, 다른 호출에서 매개변수만 999 객체로 재대입한다. inside=999 뒤에도 main의 quota는 120을 읽는다. 같은 객체를 바꾸는 호출과 매개변수만 바꾸는 호출 ParameterValueCopy 의 main 은 new PublishQuota(100) 을 생성합니다. 두 메서드 호출과 출력의 실제 순서를 비교합니다. 같은 객체의 필드 변경과 매개변수 재대입의 관찰 위치 출력 시점 참조와 상태 출력 changeObject 반환 뒤 correctTargetCount(120) 이 호출자와 매개변수가 함께 보는 객체를 수정합니다. main 의 quota 가 120 을 읽습니다. after-object-change=120 rebindParameter 안 quota = new PublishQuota(999) 은 이 호출의 매개변수만 새 객체를 가리키게 합니다. main 의 참조 대상은 그대로입니다. inside=999 rebindParameter 반환 뒤 main 의 quota 는 이전 객체의 120 을 읽습니다. after-rebind=120 changeObject 반환 뒤 참조와 상태: correctTargetCount(120) 이 호출자와 매개변수가 함께 보는 객체를 수정합니다. main 의 quota 가 120 을 읽습니다. 출력: after-object-change=120 rebindParameter 안 참조와 상태: quota = new PublishQuota(999) 은 이 호출의 매개변수만 새 객체를 가리키게 합니다. main 의 참조 대상은 그대로입니다. 출력: inside=999 rebindParameter 반환 뒤 참조와 상태: main 의 quota 는 이전 객체의 120 을 읽습니다. 출력: after-rebind=120 각 호출의 매개변수는 호출자 참조값의 복사본입니다. 매개변수 재대입이 호출자 변수로 되돌아가지는 않습니다. 새 객체의 회수 시점이나 실제 메모리 주소는 이 비교의 내용이 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 인수 복사

main과 changeObject가 같은 Quota 100을 봅니다.

  • main 대상: Quota 100
  • 매개변수 대상: 같은 객체
  • 호출은 Quota 필드 전체를 복제하지 않는다.
공유 필드 수정

correctTargetCount로 120을 저장합니다.

  • main 관찰: 120
  • 현재 객체: Quota 120
  • 복사된 참조를 통한 쓰기가 공유 객체를 바꾼다.
지역 참조 재대입

rebindParameter는 새 Quota 999를 봅니다.

  • 매개변수 관찰: 999
  • main 대상: 기존 Quota 120
  • 새 참조 대입은 피호출자 지역 변수만 바꾼다.
복귀

main은 여전히 120을 읽습니다.

  • after-rebind: 120
  • 호출자 참조에는 대입이 수행되지 않았다.

독립된 사본은 새 객체를 만들어야 생깁니다

처음 문제로 돌아가겠습니다. 원본과 따로 고칠 수 있는 사본이 필요하다면 new로 새 객체를 만들고 원본의 필드 값을 옮겨 담아야 합니다. 다음 프로그램은 MutableEntry에 그 일을 하는 정적 메서드 copyOf를 추가했습니다. 이렇게 객체를 만들어 돌려주는 정적 메서드를 정적 팩터리 메서드(static factory method)라고 부르고, 복사본을 만드는 것은 복사 팩터리라고 합니다.

src/MutableEntryCopy.java
public final class MutableEntryCopy {
    public static void main(String[] args) {
        MutableEntry original = new MutableEntry("가입 인사", 40);
        MutableEntry draft = MutableEntry.copyOf(original);

        draft.setViewCount(90);

        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=가입 인사:40
draft=가입 인사:90
same=false

copyOf 안에서 new가 실행되므로 이번에는 객체가 두 개입니다. 조회 수 40은 int라서 값 자체가 새 객체로 복사되고, 그 뒤에 draft의 조회 수를 90으로 바꿔도 원본 객체에는 영향이 없습니다. 두 변수가 서로 다른 객체를 가리키므로 original == draft도 false입니다. copyOf가 source.title처럼 다른 객체의 private 필드를 읽을 수 있는 것은, private이 객체 단위가 아니라 클래스 단위로 접근을 허용하기 때문입니다.

제목 필드는 조금 다릅니다. String은 참조형이므로 source.title을 넘기면 문자열을 복제하지 않고 같은 문자열 객체의 참조값을 복사합니다. 그래도 문제가 없는 것은 String이 불변이어서, 두 게시글이 같은 문자열을 함께 가리켜도 누구도 그 문자열의 내용을 바꿀 수 없기 때문입니다. 이렇게 필드에 든 값을 그대로 옮기는 복사를 얕은 복사(shallow copy)라고 합니다. 필드가 기본형이거나 불변 객체뿐이면 얕은 복사만으로 원본과 사본이 완전히 독립합니다. 하지만 필드가 배열처럼 바꿀 수 있는 객체를 가리킨다면, 그 객체까지 새로 만들어 복사할지 정해야 합니다. 이 경우는 연습 문제 2에서 확인합니다.

더 깊이: clone() 대신 복사 팩터리를 쓰는 이유

Object에는 clone()이라는 복사 메서드도 있습니다. 하지만 직접 만든 클래스에서 쓰려면 Cloneable 인터페이스를 구현해야 하고, 구현하지 않은 채 super.clone()을 호출하면 CloneNotSupportedException이 발생합니다. Cloneable에는 메서드가 하나도 없어서, 인터페이스를 구현했다는 사실이 Object.clone()의 동작을 바꾸는 특이한 구조입니다.

super.clone()은 생성자를 거치지 않고 필드 값을 그대로 옮긴 얕은 복사본을 만듭니다. 그래서 배열 필드는 원본과 복사본이 같은 배열을 공유합니다. 배열까지 새로 만들려면 복사본의 필드에 새 배열을 다시 대입해야 하는데, 그 필드가 final이면 cannot assign a value to final variable 오류로 대입할 수 없습니다. 반면 copyOf 같은 복사 팩터리나 복사 생성자는 평범한 생성자를 호출하므로, final 필드와 생성자의 검사를 그대로 쓸 수 있습니다. 이 교재가 직접 만든 클래스에서 clone()을 쓰지 않는 이유입니다. 다만 배열의 clone()은 이런 문제 없이 같은 길이의 새 배열을 만들어 주므로, 다음 글에서처럼 배열을 복사할 때 씁니다.

의도한 공유와 우연한 공유

같은 객체를 여러 변수가 가리키는 것 자체가 잘못은 아닙니다. 게시판 화면과 저장 기능이 같은 Board 객체를 함께 가리키는 경우를 생각해 봅시다. 한쪽에서 게시글을 추가하면 다른 쪽에서도 곧바로 보여야 하므로, 이때는 같은 인스턴스를 공유하는 것이 바로 원하는 동작입니다. 반대로 임시 편집본이나 변경 전 상태를 보관하는 스냅샷(snapshot)처럼 원본과 독립된 상태를 기대하는 곳에서는, 같은 공유가 이 글의 첫 예제 같은 버그가 됩니다.

어느 쪽이 필요한지는 다음 기준으로 가를 수 있습니다.

  • 같은 변경을 모든 곳에서 바로 봐야 한다면 같은 인스턴스를 넘깁니다.
  • 어느 시점의 값을 그대로 보관해야 한다면 새 객체로 복사해 넘기거나, 처음부터 바꿀 수 없는 불변 객체를 넘깁니다.
  • 변수가 다른 객체를 가리키지 못하게만 하려면 final을 붙입니다.
  • 객체의 상태까지 바꾸지 못하게 하려면 클래스를 불변으로 설계합니다.

변수 이름에 copy나 draft를 붙인다고 복사가 일어나지는 않습니다. 코드에서 독립된 사본을 확인할 수 있는 곳은 new, 복사 생성자, copyOf 같은 복사 팩터리처럼 새 객체를 만드는 코드입니다. 대입문만 있고 새 객체를 만드는 코드가 없다면, 그 변수는 별칭이라고 읽어야 합니다. 가변 객체를 여러 곳에서 함께 쓸 때 생기는 문제와 해결 방법은 다음 글에서 이어서 다룹니다.

정리

  • 대입과 메서드 호출은 언제나 변수에 든 값을 복사합니다. 기본형 변수는 값 자체를, 참조형 변수는 참조값을 복사합니다.
  • 참조값을 복사하면 두 변수가 같은 객체를 가리키는 별칭이 됩니다. 한쪽에서 객체의 상태를 바꾸면 다른 쪽에서도 바뀐 값이 보입니다.
  • 변수.메서드()로 객체의 상태를 바꾸는 것과 변수 = 새 객체로 변수가 가리킬 객체를 바꾸는 것은 다릅니다. final은 뒤의 것만 막습니다.
  • 메서드 안에서 매개변수에 새 객체를 대입해도 호출자의 변수는 바뀌지 않습니다. 자바는 참조값도 값으로 전달합니다.
  • 독립된 사본이 필요하면 새 객체를 만들어 필드를 복사합니다. 필드가 가변 객체를 가리키면 그 객체까지 복사할지 따로 정해야 합니다.

연습 문제

  1. 다음 프로그램이 출력하는 값을 예측하고, revise 안의 세 줄이 각각 어느 객체에 영향을 주는지 설명하세요. MutableEntry는 이 글의 첫 예제와 같은 클래스입니다.
public static void main(String[] args) {
    MutableEntry original = new MutableEntry("가입 인사", 40);
    revise(original);
    System.out.println("original=" + original.viewCount());
}

private static void revise(MutableEntry entry) {
    entry.setViewCount(70);
    entry = new MutableEntry("새 글", 0);
    entry.setViewCount(5);
}
  1. 태그 배열을 가진 TaggedEntry에 다음과 같은 복사 팩터리를 만들었습니다. 복사본의 첫 태그를 changed로 바꾼 뒤 원본의 첫 태그를 출력하면 무엇이 나올까요? 원본이 바뀌지 않게 하려면 copyOf를 어떻게 고쳐야 할까요?
src/ShallowCopyExercise.java
public final class ShallowCopyExercise {
    public static void main(String[] args) {
        TaggedEntry original = new TaggedEntry("가입 인사", new String[] {"java", "intro"});
        TaggedEntry draft = TaggedEntry.copyOf(original);

        draft.changeTag(0, "changed");

        System.out.println("original=" + original.tag(0));
        System.out.println("draft=" + draft.tag(0));
    }

    private static final class TaggedEntry {
        private final String title;
        private final String[] tags;

        TaggedEntry(String title, String[] tags) {
            this.title = title;
            this.tags = tags;
        }

        static TaggedEntry copyOf(TaggedEntry source) {
            return new TaggedEntry(source.title, source.tags);
        }

        void changeTag(int index, String tag) { tags[index] = tag; }
        String tag(int index) { return tags[index]; }
    }
}
해설 보기
  1. original=70이 출력됩니다. revise를 호출하면 original의 참조값이 매개변수 entry에 복사되므로, 첫 줄의 entry.setViewCount(70)은 main의 original과 같은 객체의 조회 수를 바꿉니다. 둘째 줄은 entry 변수에 새 객체의 참조값을 넣으므로, 이때부터 entry는 main과 관계없는 새 객체를 가리킵니다. 그래서 셋째 줄의 setViewCount(5)는 그 새 객체만 바꾸고, 새 객체는 메서드가 끝나면 더 이상 가리키는 변수가 없어집니다. main에서 보이는 변화는 첫 줄의 70뿐입니다.

  2. 실행하면 다음과 같이 원본의 태그도 바뀝니다.

original=changed
draft=changed

copyOf는 새 TaggedEntry를 만들었지만 source.tags의 참조값을 그대로 넘겼습니다. 그래서 두 게시글 객체는 서로 다르지만, 두 객체의 tags 필드는 같은 배열 하나를 가리킵니다. draft.changeTag(0, "changed")가 그 배열의 0번 칸을 바꾸므로 원본에서도 바뀐 태그가 보입니다. 바깥 객체만 새로 만든 얕은 복사의 한계입니다.

copyOf에서 배열도 새로 만들어 넘기면 해결됩니다.

static TaggedEntry copyOf(TaggedEntry source) {
    return new TaggedEntry(source.title, source.tags.clone());
}

배열의 clone()은 같은 길이의 새 배열을 만들고 원소를 복사하므로, 이제 두 게시글은 서로 다른 배열을 가집니다. 이렇게 고치면 original=java, draft=changed가 출력됩니다. 배열의 원소인 String은 불변이므로 원소까지 복제할 필요는 없습니다. 원소가 바꿀 수 있는 객체였다면 원소도 하나씩 복사해야 원본과 완전히 독립합니다.

다음 글

공유 참조와 소유권에서는 가변 게시글 하나를 두 객체가 함께 들고 있을 때 생기는 문제를 다룹니다. 한쪽의 수정이 다른 쪽의 결과를 바꾸는 상황을 재현하고, 복사하기, 변경을 한 객체에 맡기기, 불변 객체 만들기 가운데 무엇을 고를지 살펴봅니다.