본문 바로가기

공유 참조와 소유권

가변 게시글 하나를 여러 객체가 함께 쓸 때 생기는 부수 효과를 재현하고, 복사하기·변경을 한 객체에 맡기기·불변 객체 만들기 가운데 상황에 맞는 해결책을 고르는 방법을 배웁니다.

앞 글에서는 변수 두 개가 같은 객체를 가리킬 때, 한 변수로 바꾼 값이 다른 변수에서도 보인다는 것을 확인했습니다. 실제 프로그램에서는 이런 공유가 변수 두 개가 아니라 객체 두 개 사이에서 일어나는 경우가 많습니다. 보고서 객체와 미리보기 객체가 같은 게시글을 필드로 들고 있으면, 미리보기가 게시글을 고치는 순간 보고서가 읽는 값도 함께 바뀝니다.

이렇게 어떤 코드가 자기 일을 하면서 다른 곳에서 보이는 상태까지 바꾸는 것을 부수 효과(side effect)라고 합니다. 함께 쓰는 객체를 바꾸는 것이 협력의 목적이라면 부수 효과는 원하는 동작입니다. 문제는 한쪽이 혼자만 쓰려고 고친 값이 다른 쪽의 결과까지 바꿀 때입니다. 이때 핵심은 참조를 공유했다는 사실보다, 그 객체를 누가 바꿀 수 있는지가 코드에 드러나지 않는다는 데 있습니다. 이 글에서는 이런 부수 효과를 재현한 뒤 세 가지 해결책을 차례로 살펴봅니다.

미리보기에서 고친 조회 수가 보고서에도 보입니다

다음 프로그램에서 일간 보고서 DailyReport는 저장된 게시글의 조회 수를 보여 줘야 하고, 미리보기 PreviewService는 순위 미리보기를 위해 조회 수에 5를 더해 봅니다. 두 객체는 같은 MutableEntry를 생성자로 받습니다. 두 클래스는 캡슐화로 회원 저장소 완성하기에서 본 record로 선언했습니다. record는 구성 요소를 다시 대입할 수 없을 뿐이고, 구성 요소가 가리키는 객체의 상태는 얼마든지 바뀔 수 있습니다.

lab/SharedEntrySideEffect.java
public final class SharedEntrySideEffect {
    public static void main(String[] args) {
        MutableEntry entry = new MutableEntry("object", 40);
        DailyReport report = new DailyReport(entry);
        PreviewService preview = new PreviewService(entry);

        preview.applyPreviewBoost(5);

        System.out.println("stored=" + report.totalViewCount());
        System.out.println("preview=" + preview.previewViewCount());
    }

    private static final class MutableEntry {
        private final String title;
        private int viewCount;

        MutableEntry(String title, int viewCount) {
            this.title = title;
            this.viewCount = viewCount;
        }

        void addViewCount(int extra) { viewCount += extra; }
        int viewCount() { return viewCount; }
    }

    private record DailyReport(MutableEntry entry) {
        int totalViewCount() { return entry.viewCount(); }
    }

    private record PreviewService(MutableEntry entry) {
        void applyPreviewBoost(int extra) { entry.addViewCount(extra); }
        int previewViewCount() { return entry.viewCount(); }
    }
}
의도하지 않은 결과
stored=45
preview=45

보고서는 저장된 조회 수 40을 보여 줘야 했지만 45가 나왔습니다. main이 같은 entry를 두 생성자에 넘겼으므로 report와 preview의 필드는 같은 MutableEntry 하나를 가리킵니다. 그래서 applyPreviewBoost(5)가 그 객체의 조회 수를 45로 바꾸면, 보고서가 읽는 값도 45가 됩니다.

applyPreviewBoost라는 이름만 보면 미리보기 안에서만 계산하는 것 같지만, 실제로는 밖에서 넘겨받은 객체를 고칩니다. 이런 숨은 변경이 있으면 호출 순서에 따라 결과가 달라집니다. 이 프로그램에서도 applyPreviewBoost를 호출하기 전에 보고서를 출력하면 40이 나오고, 호출한 뒤에 출력하면 45가 나옵니다. 테스트에서도 같은 일이 생겨서, 한 테스트가 고친 객체 때문에 다른 테스트가 실패하는 일이 벌어집니다.

첫 번째 방법: 복사해서 넘기기

미리보기가 게시글을 독립적으로 고쳐야 한다면 값이 같은 새 객체를 넘기면 됩니다. 다음 프로그램은 MutableEntry에 자기 사본을 만드는 copy()를 추가하고, 미리보기에는 사본을 넘깁니다.

src/SeparatedMutableEntries.java
public final class SeparatedMutableEntries {
    public static void main(String[] args) {
        MutableEntry stored = new MutableEntry("object", 40);
        MutableEntry previewEntry = stored.copy();

        DailyReport report = new DailyReport(stored);
        PreviewService preview = new PreviewService(previewEntry);
        preview.applyPreviewBoost(5);

        System.out.println("stored=" + report.totalViewCount());
        System.out.println("preview=" + preview.previewViewCount());
    }

    private static final class MutableEntry {
        private final String title;
        private int viewCount;

        MutableEntry(String title, int viewCount) {
            this.title = title;
            this.viewCount = viewCount;
        }

        MutableEntry copy() { return new MutableEntry(title, viewCount); }
        void addViewCount(int extra) { viewCount += extra; }
        int viewCount() { return viewCount; }
    }

    private record DailyReport(MutableEntry entry) {
        int totalViewCount() { return entry.viewCount(); }
    }

    private record PreviewService(MutableEntry entry) {
        void applyPreviewBoost(int extra) { entry.addViewCount(extra); }
        int previewViewCount() { return entry.viewCount(); }
    }
}
stored=40
preview=45

stored.copy()가 new로 새 객체를 만들므로 이제 게시글 객체가 두 개입니다. 보고서는 stored를, 미리보기는 previewEntry를 가리키므로 미리보기가 조회 수를 45로 바꿔도 보고서는 40을 읽습니다. 복사하는 코드를 객체들을 만들어 연결하는 main에 두었기 때문에, 어디서 두 객체가 갈라지는지 한눈에 보인다는 장점도 있습니다. 아래 그림에서 공유하는 경우와 복사하는 경우를 단계별로 비교해 보세요. 두 행은 서로 다른 실행입니다.

같은 게시글을 공유할 때와 복사한 게시글을 수정할 때

공유 실행은 report/preview가 같은 Entry 40→45를 보고 copy 실행은 report 40을 보존하며 preview만 45가 됩니다. 두 독립 전후 상태를 고정 참조 역할로 비교할 수 있습니다.

같은 게시글을 공유할 때와 복사한 게시글을 수정할 때

SharedEntrySideEffect는 두 소비자가 같은 MutableEntry를 보므로 미리보기 +5가 보고서에도 45로 보인다. SeparatedMutableEntries는 previewEntry를 새 객체로 복사해 저장 값 40과 미리보기 45를 분리한다.

복사한 객체를 수정하면 보고서의 원본이 유지됩니다

두 프로그램 모두 제목 "object", 조회 수 40에서 시작해 preview.applyPreviewBoost(5)를 호출합니다. 아래 행은 서로 다른 실행입니다.

복사한 객체를 수정하면 보고서의 원본이 유지됩니다 공유예제에서report와preview는같은가변Entry40→45를본다. 별도copy()예제에서는report의40과preview의45가다른객체이며점선은생성시값복사이다. 공유 예제 copy() 예제 report 참조 preview 참조 같은 Entry 40 → 45 report의 Entry 조회 수 40 preview의 Entry 40 → 45 copy(): 별도 객체 왼쪽은 참조 공유 · 오른쪽은 생성 시 값 복사 · 독립된 두 실행
복사한 객체를 수정하면 보고서의 원본이 유지됩니다 공유예제에서report와preview는같은가변Entry40→45를본다. 별도copy()예제에서는report의40과preview의45가다른객체이며점선은생성시값복사이다. 공유 예제 · 별도 실행 report 참조 preview 참조 같은 Entry 40 → 45 복사 예제 · 별도 실행 report의 Entry 조회 수 40 preview의 Entry 40 → 45 copy(): 새 객체 외부 파일 저장의 뜻 아님
같은 미리보기 보정이 참조 공유 여부에 따라 달라지는 결과
원문 프로그램두 소비자가 보는 대상보정 뒤 실제 출력
SharedEntrySideEffectDailyReport와 PreviewService에 같은 entry를 전달합니다. 한 MutableEntry의 조회 수가 45가 됩니다.
stored=45
preview=45
SeparatedMutableEntriespreviewEntry = stored.copy()가 새 MutableEntry를 만듭니다. 보고서는 stored, 미리보기는 previewEntry를 봅니다.
stored=40
preview=45
SharedEntrySideEffect
두 소비자가 보는 대상: DailyReport와 PreviewService에 같은 entry를 전달합니다. 한 MutableEntry의 조회 수가 45가 됩니다.
보정 뒤 실제 출력:
stored=45
preview=45
SeparatedMutableEntries
두 소비자가 보는 대상: previewEntry = stored.copy()가 새 MutableEntry를 만듭니다. 보고서는 stored, 미리보기는 previewEntry를 봅니다.
보정 뒤 실제 출력:
stored=40
preview=45

보고서의 stored=는 report.totalViewCount()의 출력 라벨입니다. 이 예제에 파일 저장소나 별도 화면 객체는 없습니다. 복사본의 제목 문자열과 숫자 상태만 비교하며, 임의의 중첩 가변 객체까지 복제한다고 주장하지 않습니다.

이 예의 조건

  • 공유와 copy는 별도 실행입니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2공유 예제에서 report와 preview는 같은 가변 Entry 40→45를 본다. 별도 copy() 예제에서는 report의 40과 preview의 45가 다른 객체이며 점선은 생성 시 값 복사이다.
원 도표의 설명과 주석
구분내용
설명공유 예제에서 report와 preview는 같은 가변 Entry 40→45를 본다. 별도 copy() 예제에서는 report의 40과 preview의 45가 다른 객체이며 점선은 생성 시 값 복사이다.
전체 읽기 정보같은 게시글을 공유할 때와 복사한 게시글을 수정할 때 같은 게시글을 공유할 때와 복사한 게시글을 수정할 때 SharedEntrySideEffect는 두 소비자가 같은 MutableEntry를 보므로 미리보기 +5가 보고서에도 45로 보인다. SeparatedMutableEntries는 previewEntry를 새 객체로 복사해 저장 값 40과 미리보기 45를 분리한다. 복사한 객체를 수정하면 보고서의 원본이 유지됩니다 두 프로그램 모두 제목 "object" , 조회 수 40 에서 시작해 preview.applyPreviewBoost(5) 를 호출합니다. 아래 행은 서로 다른 실행입니다. 같은 미리보기 보정이 참조 공유 여부에 따라 달라지는 결과 원문 프로그램 두 소비자가 보는 대상 보정 뒤 실제 출력 SharedEntrySideEffect DailyReport 와 PreviewService 에 같은 entry 를 전달합니다. 한 MutableEntry 의 조회 수가 45 가 됩니다. stored=45 preview=45 SeparatedMutableEntries previewEntry = stored.copy() 가 새 MutableEntry 를 만듭니다. 보고서는 stored , 미리보기는 previewEntry 를 봅니다. stored=40 preview=45 SharedEntrySideEffect 두 소비자가 보는 대상: DailyReport 와 PreviewService 에 같은 entry 를 전달합니다. 한 MutableEntry 의 조회 수가 45 가 됩니다. 보정 뒤 실제 출력: stored=45 preview=45 SeparatedMutableEntries 두 소비자가 보는 대상: previewEntry = stored.copy() 가 새 MutableEntry 를 만듭니다. 보고서는 stored , 미리보기는 previewEntry 를 봅니다. 보정 뒤 실제 출력: stored=40 preview=45 보고서의 stored= 는 report.totalViewCount() 의 출력 라벨입니다. 이 예제에 파일 저장소나 별도 화면 객체는 없습니다. 복사본의 제목 문자열과 숫자 상태만 비교하며, 임의의 중첩 가변 객체까지 복제한다고 주장하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
초기 값

두 실행의 report는 Entry 40입니다.

  • 공유 preview: 같은 참조
  • copy preview: 새 객체 40
  • copy가 새 저장 객체를 만듭니다.
preview 변경

preview의 조회 수를 45로 바꿉니다.

  • 공유 객체: 40→45
  • copy 객체: 40→45
  • 쓰기 표현식은 같아도 대상 객체가 다릅니다.
report 관찰

공유 report는 45, copy report는 40입니다.

  • 공유: report/preview 45
  • copy: report 40/preview45
  • 생성 시 값 복사가 앞 객체의 상태를 보존합니다.

같은 게시글을 공유할 때와 복사한 게시글을 수정할 때

공유 실행은 report/preview가 같은 Entry 40→45를 보고 copy 실행은 report 40을 보존하며 preview만 45가 됩니다. 두 독립 전후 상태를 고정 참조 역할로 비교할 수 있습니다.

같은 게시글을 공유할 때와 복사한 게시글을 수정할 때

SharedEntrySideEffect는 두 소비자가 같은 MutableEntry를 보므로 미리보기 +5가 보고서에도 45로 보인다. SeparatedMutableEntries는 previewEntry를 새 객체로 복사해 저장 값 40과 미리보기 45를 분리한다.

복사한 객체를 수정하면 보고서의 원본이 유지됩니다

두 프로그램 모두 제목 "object", 조회 수 40에서 시작해 preview.applyPreviewBoost(5)를 호출합니다. 아래 행은 서로 다른 실행입니다.

복사한 객체를 수정하면 보고서의 원본이 유지됩니다 공유예제에서report와preview는같은가변Entry40→45를본다. 별도copy()예제에서는report의40과preview의45가다른객체이며점선은생성시값복사이다. 공유 예제 copy() 예제 report 참조 preview 참조 같은 Entry 40 → 45 report의 Entry 조회 수 40 preview의 Entry 40 → 45 copy(): 별도 객체 왼쪽은 참조 공유 · 오른쪽은 생성 시 값 복사 · 독립된 두 실행
복사한 객체를 수정하면 보고서의 원본이 유지됩니다 공유예제에서report와preview는같은가변Entry40→45를본다. 별도copy()예제에서는report의40과preview의45가다른객체이며점선은생성시값복사이다. 공유 예제 · 별도 실행 report 참조 preview 참조 같은 Entry 40 → 45 복사 예제 · 별도 실행 report의 Entry 조회 수 40 preview의 Entry 40 → 45 copy(): 새 객체 외부 파일 저장의 뜻 아님
같은 미리보기 보정이 참조 공유 여부에 따라 달라지는 결과
원문 프로그램두 소비자가 보는 대상보정 뒤 실제 출력
SharedEntrySideEffectDailyReport와 PreviewService에 같은 entry를 전달합니다. 한 MutableEntry의 조회 수가 45가 됩니다.
stored=45
preview=45
SeparatedMutableEntriespreviewEntry = stored.copy()가 새 MutableEntry를 만듭니다. 보고서는 stored, 미리보기는 previewEntry를 봅니다.
stored=40
preview=45
SharedEntrySideEffect
두 소비자가 보는 대상: DailyReport와 PreviewService에 같은 entry를 전달합니다. 한 MutableEntry의 조회 수가 45가 됩니다.
보정 뒤 실제 출력:
stored=45
preview=45
SeparatedMutableEntries
두 소비자가 보는 대상: previewEntry = stored.copy()가 새 MutableEntry를 만듭니다. 보고서는 stored, 미리보기는 previewEntry를 봅니다.
보정 뒤 실제 출력:
stored=40
preview=45

보고서의 stored=는 report.totalViewCount()의 출력 라벨입니다. 이 예제에 파일 저장소나 별도 화면 객체는 없습니다. 복사본의 제목 문자열과 숫자 상태만 비교하며, 임의의 중첩 가변 객체까지 복제한다고 주장하지 않습니다.

이 예의 조건

  • 공유와 copy는 별도 실행입니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2공유 예제에서 report와 preview는 같은 가변 Entry 40→45를 본다. 별도 copy() 예제에서는 report의 40과 preview의 45가 다른 객체이며 점선은 생성 시 값 복사이다.
원 도표의 설명과 주석
구분내용
설명공유 예제에서 report와 preview는 같은 가변 Entry 40→45를 본다. 별도 copy() 예제에서는 report의 40과 preview의 45가 다른 객체이며 점선은 생성 시 값 복사이다.
전체 읽기 정보같은 게시글을 공유할 때와 복사한 게시글을 수정할 때 같은 게시글을 공유할 때와 복사한 게시글을 수정할 때 SharedEntrySideEffect는 두 소비자가 같은 MutableEntry를 보므로 미리보기 +5가 보고서에도 45로 보인다. SeparatedMutableEntries는 previewEntry를 새 객체로 복사해 저장 값 40과 미리보기 45를 분리한다. 복사한 객체를 수정하면 보고서의 원본이 유지됩니다 두 프로그램 모두 제목 "object" , 조회 수 40 에서 시작해 preview.applyPreviewBoost(5) 를 호출합니다. 아래 행은 서로 다른 실행입니다. 같은 미리보기 보정이 참조 공유 여부에 따라 달라지는 결과 원문 프로그램 두 소비자가 보는 대상 보정 뒤 실제 출력 SharedEntrySideEffect DailyReport 와 PreviewService 에 같은 entry 를 전달합니다. 한 MutableEntry 의 조회 수가 45 가 됩니다. stored=45 preview=45 SeparatedMutableEntries previewEntry = stored.copy() 가 새 MutableEntry 를 만듭니다. 보고서는 stored , 미리보기는 previewEntry 를 봅니다. stored=40 preview=45 SharedEntrySideEffect 두 소비자가 보는 대상: DailyReport 와 PreviewService 에 같은 entry 를 전달합니다. 한 MutableEntry 의 조회 수가 45 가 됩니다. 보정 뒤 실제 출력: stored=45 preview=45 SeparatedMutableEntries 두 소비자가 보는 대상: previewEntry = stored.copy() 가 새 MutableEntry 를 만듭니다. 보고서는 stored , 미리보기는 previewEntry 를 봅니다. 보정 뒤 실제 출력: stored=40 preview=45 보고서의 stored= 는 report.totalViewCount() 의 출력 라벨입니다. 이 예제에 파일 저장소나 별도 화면 객체는 없습니다. 복사본의 제목 문자열과 숫자 상태만 비교하며, 임의의 중첩 가변 객체까지 복제한다고 주장하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
초기 값

두 실행의 report는 Entry 40입니다.

  • 공유 preview: 같은 참조
  • copy preview: 새 객체 40
  • copy가 새 저장 객체를 만듭니다.
preview 변경

preview의 조회 수를 45로 바꿉니다.

  • 공유 객체: 40→45
  • copy 객체: 40→45
  • 쓰기 표현식은 같아도 대상 객체가 다릅니다.
report 관찰

공유 report는 45, copy report는 40입니다.

  • 공유: report/preview 45
  • copy: report 40/preview45
  • 생성 시 값 복사가 앞 객체의 상태를 보존합니다.

하지만 이 방법에는 약점이 있습니다. 게시글을 넘기는 모든 곳에서 복사를 기억해야 하고, 한 곳만 빠뜨려도 처음 문제가 다시 생깁니다. 받는 쪽인 PreviewService의 생성자에서 entry.copy()를 저장하도록 바꾸면 호출하는 쪽이 잊을 걱정은 줄어듭니다. 그래도 게시글을 받는 다른 모든 클래스가 같은 일을 해야 한다는 점은 변하지 않습니다.

복사의 깊이도 문제입니다. copy()는 필드를 그대로 옮기는 얕은 복사라서, 게시글이 태그 목록 같은 가변 객체를 필드로 가지면 사본과 원본이 그 목록을 함께 쓰게 됩니다. 게다가 나중에 MutableEntry에 필드가 추가되면 copy()도 함께 고쳐야 하는데, 복사하는 코드가 여러 곳에 흩어져 있으면 그중 일부를 빠뜨리기 쉽습니다.

언어가 공유를 막아 주지는 않습니다

복사를 빠뜨리지 않도록 자바가 대신 막아 줄 수는 없을까요? 자바에는 '이 클래스의 객체는 한 곳에서만 참조할 수 있다'고 선언하는 문법이 없습니다. 객체 참조는 언제든 다른 변수에 대입하거나 여러 생성자에 넘길 수 있습니다. 호출하는 쪽이 같은 객체를 두 번 넘길 수도 있고, 배열의 여러 칸에 같은 객체를 넣을 수도 있습니다. 그래서 공유 문제는 클래스를 설계하는 방식으로 막아야 하고, 방법은 크게 세 가지입니다.

  1. 독립적으로 고쳐야 하는 곳에 넘길 때 명시적으로 복사합니다. 앞 절에서 본 방법입니다.
  2. 가변 객체를 바꿀 수 있는 곳을 한 객체로 정하고, 밖에는 바꿀 수 없는 값만 내보냅니다. 이렇게 어떤 객체만 다른 객체를 바꿀 수 있게 하는 관계를 이 글에서는 소유권(ownership)이라고 부르겠습니다.
  3. 객체를 불변으로 만들어, 공유해도 아무도 바꿀 수 없게 합니다.

무조건 복사하는 것도 정답은 아닙니다. 회원이나 주문처럼 하나의 실체를 나타내는 객체는 모든 곳에서 같은 최신 상태를 봐야 하는데, 복사하면 오히려 사본마다 상태가 달라집니다. 이런 객체는 두 번째 방법처럼 한 객체가 변경을 맡는 편이 낫습니다. 반대로 날짜, 금액, 게시글 스냅샷처럼 값 자체가 의미인 객체는 세 번째 방법인 불변 설계가 자연스럽습니다. 다음 절에서는 두 번째 방법을 코드로 확인합니다.

두 번째 방법: 변경은 Board만 할 수 있게 하기

이번에는 게시판 Board가 게시글을 내부 배열에 보관하고, 게시글을 바꾸는 일도 Board만 하게 만들어 보겠습니다. 바깥 코드는 게시글 객체를 직접 받지 않고, '이 id의 조회 수를 55로 바꿔 달라'는 명령을 Board의 메서드로 보냅니다. 조회할 때는 내부 MutableEntry 대신 바꿀 수 없는 record EntryView를 새로 만들어 돌려줍니다.

src/OwnedBoard.java
public final class OwnedBoard {
    public static void main(String[] args) {
        Board board = new Board();
        long id = board.add("references", 40);
        board.reviseViewCount(id, 55);

        EntryView[] snapshot = board.snapshot();
        for (EntryView entry : snapshot) System.out.println(entry);
        System.out.println("total=" + board.totalViewCount());
    }

    private record EntryView(long id, String title, int viewCount) { }

    private static final class MutableEntry {
        private final long id;
        private final String title;
        private int viewCount;

        MutableEntry(long id, String title, int viewCount) {
            this.id = id;
            this.title = title;
            this.viewCount = viewCount;
        }
    }

    private static final class Board {
        private final MutableEntry[] entries = new MutableEntry[10];
        private int size;
        private long nextId = 1;

        long add(String title, int viewCount) {
            validateViewCount(viewCount);
            if (size == entries.length) throw new IllegalStateException("full");
            long id = nextId++;
            entries[size++] = new MutableEntry(id, title, viewCount);
            return id;
        }

        void reviseViewCount(long id, int viewCount) {
            validateViewCount(viewCount);
            for (int index = 0; index < size; index++) {
                MutableEntry entry = entries[index];
                if (entry.id == id) {
                    entry.viewCount = viewCount;
                    return;
                }
            }
            throw new IllegalArgumentException("id=" + id);
        }

        EntryView[] snapshot() {
            EntryView[] result = new EntryView[size];
            for (int index = 0; index < size; index++) {
                MutableEntry entry = entries[index];
                result[index] = new EntryView(entry.id, entry.title, entry.viewCount);
            }
            return result;
        }

        long totalViewCount() {
            long total = 0;
            for (int index = 0; index < size; index++) {
                total += entries[index].viewCount;
            }
            return total;
        }

        private static void validateViewCount(int viewCount) {
            if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
        }
    }
}
EntryView[id=1, title=references, viewCount=55]
total=55

main은 add로 조회 수 40인 게시글을 등록하고, 돌려받은 id 1로 reviseViewCount를 호출해 조회 수를 55로 바꿉니다. 게시글 객체는 Board 안에서 만들어지고 밖으로 나가지 않으므로, 조회 수를 바꾸는 길은 Board의 메서드뿐입니다. 그 메서드가 validateViewCount로 값을 먼저 검사하므로 0 이하의 조회 수는 처음부터 들어갈 수 없습니다. 출력의 첫 줄은 snapshot()이 돌려준 EntryView를 record의 toString()으로 출력한 것이고, 둘째 줄은 Board가 계산한 조회 수 합계입니다.

Board 바깥의 main이 entry.viewCount 같은 private 필드를 읽지 않는다는 점도 눈여겨보세요. 모든 클래스가 OwnedBoard 안에 중첩되어 있어서 문법상으로는 main도 private 필드에 접근할 수 있지만, 이 예제는 일부러 Board의 메서드만 사용합니다. 이 글에서 말하는 소유권은 이렇게 설계로 정한 약속이지, 자바가 강제하는 별도의 기능이 아닙니다.

snapshot()은 호출한 시점의 상태를 새 배열과 새 EntryView들에 복사해 돌려줍니다. id와 조회 수는 기본형이라 값 자체가 복사되고, 제목은 불변인 String이라 같은 문자열을 함께 가리켜도 안전합니다. 그래서 받은 배열의 칸에 다른 EntryView를 넣어도 Board의 entries 배열은 바뀌지 않습니다. 또 이후에 Board의 상태가 바뀌어도 이미 받은 결과는 그대로 남습니다. 아래 그림에서 명령으로 등록하고, 승인된 변경을 거쳐, 조회 사본을 만드는 과정을 따라가 보세요.

Board의 변경 명령과 새 EntryView 조회값을 구별한다

Board.add 40 뒤 revise 55가 내부 MutableEntry를 바꾸고 snapshot은 새 배열/EntryView 값 55를 내보냅니다. 명령·내부 변경·조회 사본의 실제 전환이며 언어 차원의 소유권 보장으로 확대하지 않습니다.

Board의 변경 명령과 새 EntryView 조회값을 구별한다

OwnedBoard의 main은 references 게시글 40을 추가하고 id=1의 조회 수를 55로 수정한 뒤 새 EntryView 배열을 받는다. Board의 조회 메서드는 내부 MutableEntry를 반환하지 않으며 반환 배열과 각 조회값의 변경 가능성은 다르다.

변경은 Board가 승인하고 조회는 새 EntryView로 돌려줍니다

OwnedBoard의 실제 main 호출입니다. Board의 조회 메서드는 내부 MutableEntry 대신 새 EntryView를 반환합니다.

변경은 Board가 승인하고 조회는 새 EntryView로 돌려줍니다 main이add40뒤id1을55로수정한다. Board내부MutableEntry는references55이다. snapshot은새배열과EntryView로필드를복사해내보내고total은55이다. 실선은명령또는계산,점선은조회사본복사로서외부배열슬롯교체가내부entries를교체하지않는다. main의 명령 add: references / 40 revise: id1 / 55 Board 내부 MutableEntry id1 / references viewCount = 55 조회 사본 새 배열 + EntryView id1 / references / 55 합계 결과 total = 55 snapshot: 필드 값을 복사 영역은 이 코드의 내부 보관 · 언어가 보장하는 별도 소유권 기능 아님
변경은 Board가 승인하고 조회는 새 EntryView로 돌려줍니다 main이add40뒤id1을55로수정한다. Board내부MutableEntry는references55이다. snapshot은새배열과EntryView로필드를복사해내보내고total은55이다. 실선은명령또는계산,점선은조회사본복사로서외부배열슬롯교체가내부entries를교체하지않는다. main의 명령 add: references / 40 revise: id1 / 55 Board의 내부 보관 MutableEntry id1 / references viewCount = 55 조회 사본 새 배열 + EntryView id1 / references / 55 합계 결과 total = 55 snapshot: 값 복사 영역은 이 코드의 내부 보관 외부 슬롯 교체 ≠ 내부 변경
OwnedBoard의 실제 명령과 조회 경계
main의 순서Board가 하는 일호출자에게 주는 값
add("references", 40)조회 수 검증 뒤 id=1인 MutableEntry를 보관합니다.long id 값 1
reviseViewCount(id, 55)조회 수 검증 뒤 같은 id의 내부 항목을 찾아 viewCount=55로 수정합니다.void · 반환값 없음
snapshot()새 EntryView[]와 새 EntryView를 만들어 현재 필드값을 담습니다.id=1, title="references", viewCount=55인 조회값
totalViewCount()현재 보관 항목의 조회 수를 합합니다.55
  1. add("references", 40)
    Board가 하는 일: 조회 수 검증 뒤 id=1인 MutableEntry를 보관합니다.
    호출자에게 주는 값: long id 값 1
  2. reviseViewCount(id, 55)
    Board가 하는 일: 조회 수 검증 뒤 같은 id의 내부 항목을 찾아 viewCount=55로 수정합니다.
    호출자에게 주는 값: void · 반환값 없음
  3. snapshot()
    Board가 하는 일: 새 EntryView[]와 새 EntryView를 만들어 현재 필드값을 담습니다.
    호출자에게 주는 값: id=1, title="references", viewCount=55인 조회값
  4. totalViewCount()
    Board가 하는 일: 현재 보관 항목의 조회 수를 합합니다.
    호출자에게 주는 값: 55

이후 Board를 수정해도 이미 만든 EntryView의 값은 바뀌지 않습니다. 그러나 반환 배열의 슬롯은 호출자가 바꿀 수 있습니다. 새 배열의 슬롯 변경은 Board의 보관 배열을 바꾸지 않습니다.

main의 출력은 EntryView[id=1, title=references, viewCount=55]와 total=55입니다. 여기서의 소유권은 Board가 제공하는 메서드 경계이며, 같은 최상위 클래스 안의 코드까지 접근을 금지하는 별도의 언어 기능을 뜻하지 않습니다.

이 예의 조건

  • Board의 명령·조회 규칙이며 언어 자체 별도 소유권 기능이 아닙니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2main이 add 40 뒤 id 1을 55로 수정한다. Board 내부 MutableEntry는 references 55이다. snapshot은 새 배열과 EntryView로 필드를 복사해 내보내고 total은 55이다. 실선은 명령 또는 계산이며 점선은 조회 사본 복사로서 외부 배열 슬롯 교체가 내부 entries를 교체하지 않는다.
4add: references
540
6revise: id 1
10id 1
11references
15id 1
16references
원 도표의 설명과 주석
구분내용
설명main이 add 40 뒤 id 1을 55로 수정한다. Board 내부 MutableEntry는 references 55이다. snapshot은 새 배열과 EntryView로 필드를 복사해 내보내고 total은 55이다. 실선은 명령 또는 계산이며 점선은 조회 사본 복사로서 외부 배열 슬롯 교체가 내부 entries를 교체하지 않는다.
전체 읽기 정보Board의 변경 명령과 새 EntryView 조회값을 구별한다 Board의 변경 명령과 새 EntryView 조회값을 구별한다 OwnedBoard의 main은 references 게시글 40을 추가하고 id=1의 조회 수를 55로 수정한 뒤 새 EntryView 배열을 받는다. Board의 조회 메서드는 내부 MutableEntry를 반환하지 않으며 반환 배열과 각 조회값의 변경 가능성은 다르다. 변경은 Board가 승인하고 조회는 새 EntryView로 돌려줍니다 OwnedBoard 의 실제 main 호출입니다. Board 의 조회 메서드는 내부 MutableEntry 대신 새 EntryView 를 반환합니다. OwnedBoard의 실제 명령과 조회 경계 main의 순서 Board가 하는 일 호출자에게 주는 값 add("references", 40) 조회 수 검증 뒤 id=1 인 MutableEntry 를 보관합니다. long id 값 1 reviseViewCount(id, 55) 조회 수 검증 뒤 같은 id 의 내부 항목을 찾아 viewCount=55 로 수정합니다. void · 반환값 없음 snapshot() 새 EntryView[] 와 새 EntryView 를 만들어 현재 필드값을 담습니다. id=1 , title="references" , viewCount=55 인 조회값 totalViewCount() 현재 보관 항목의 조회 수를 합합니다. 55 add("references", 40) Board가 하는 일: 조회 수 검증 뒤 id=1 인 MutableEntry 를 보관합니다. 호출자에게 주는 값: long id 값 1 reviseViewCount(id, 55) Board가 하는 일: 조회 수 검증 뒤 같은 id 의 내부 항목을 찾아 viewCount=55 로 수정합니다. 호출자에게 주는 값: void · 반환값 없음 snapshot() Board가 하는 일: 새 EntryView[] 와 새 EntryView 를 만들어 현재 필드값을 담습니다. 호출자에게 주는 값: id=1 , title="references" , viewCount=55 인 조회값 totalViewCount() Board가 하는 일: 현재 보관 항목의 조회 수를 합합니다. 호출자에게 주는 값: 55 이후 Board 를 수정해도 이미 만든 EntryView 의 값은 바뀌지 않습니다. 그러나 반환 배열의 슬롯은 호출자가 바꿀 수 있습니다. 새 배열의 슬롯 변경은 Board 의 보관 배열을 바꾸지 않습니다. main 의 출력은 EntryView[id=1, title=references, viewCount=55] 와 total=55 입니다. 여기서의 소유권은 Board 가 제공하는 메서드 경계이며, 같은 최상위 클래스 안의 코드까지 접근을 금지하는 별도의 언어 기능을 뜻하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
명령으로 등록

main이 references 40을 Board에 add합니다.

  • 내부: MutableEntry id 1
  • viewCount: 40
  • 내부 가변 상태의 변경 입구는 Board 명령입니다.
승인된 변경

revise가 id 1을 55로 바꿉니다.

  • 내부 count: 55
  • 계산 total: 55
  • 조회 결과를 직접 변경하는 대신 명령으로 수정합니다.
조회 복사

snapshot이 새 배열과 EntryView로 필드를 복사합니다.

  • 사본: id 1,references 55
  • 외부 슬롯 교체: 내부 entries 교체 안 함
  • 조회 값이 내부 가변 참조를 노출하지 않습니다.

Board의 변경 명령과 새 EntryView 조회값을 구별한다

Board.add 40 뒤 revise 55가 내부 MutableEntry를 바꾸고 snapshot은 새 배열/EntryView 값 55를 내보냅니다. 명령·내부 변경·조회 사본의 실제 전환이며 언어 차원의 소유권 보장으로 확대하지 않습니다.

Board의 변경 명령과 새 EntryView 조회값을 구별한다

OwnedBoard의 main은 references 게시글 40을 추가하고 id=1의 조회 수를 55로 수정한 뒤 새 EntryView 배열을 받는다. Board의 조회 메서드는 내부 MutableEntry를 반환하지 않으며 반환 배열과 각 조회값의 변경 가능성은 다르다.

변경은 Board가 승인하고 조회는 새 EntryView로 돌려줍니다

OwnedBoard의 실제 main 호출입니다. Board의 조회 메서드는 내부 MutableEntry 대신 새 EntryView를 반환합니다.

변경은 Board가 승인하고 조회는 새 EntryView로 돌려줍니다 main이add40뒤id1을55로수정한다. Board내부MutableEntry는references55이다. snapshot은새배열과EntryView로필드를복사해내보내고total은55이다. 실선은명령또는계산,점선은조회사본복사로서외부배열슬롯교체가내부entries를교체하지않는다. main의 명령 add: references / 40 revise: id1 / 55 Board 내부 MutableEntry id1 / references viewCount = 55 조회 사본 새 배열 + EntryView id1 / references / 55 합계 결과 total = 55 snapshot: 필드 값을 복사 영역은 이 코드의 내부 보관 · 언어가 보장하는 별도 소유권 기능 아님
변경은 Board가 승인하고 조회는 새 EntryView로 돌려줍니다 main이add40뒤id1을55로수정한다. Board내부MutableEntry는references55이다. snapshot은새배열과EntryView로필드를복사해내보내고total은55이다. 실선은명령또는계산,점선은조회사본복사로서외부배열슬롯교체가내부entries를교체하지않는다. main의 명령 add: references / 40 revise: id1 / 55 Board의 내부 보관 MutableEntry id1 / references viewCount = 55 조회 사본 새 배열 + EntryView id1 / references / 55 합계 결과 total = 55 snapshot: 값 복사 영역은 이 코드의 내부 보관 외부 슬롯 교체 ≠ 내부 변경
OwnedBoard의 실제 명령과 조회 경계
main의 순서Board가 하는 일호출자에게 주는 값
add("references", 40)조회 수 검증 뒤 id=1인 MutableEntry를 보관합니다.long id 값 1
reviseViewCount(id, 55)조회 수 검증 뒤 같은 id의 내부 항목을 찾아 viewCount=55로 수정합니다.void · 반환값 없음
snapshot()새 EntryView[]와 새 EntryView를 만들어 현재 필드값을 담습니다.id=1, title="references", viewCount=55인 조회값
totalViewCount()현재 보관 항목의 조회 수를 합합니다.55
  1. add("references", 40)
    Board가 하는 일: 조회 수 검증 뒤 id=1인 MutableEntry를 보관합니다.
    호출자에게 주는 값: long id 값 1
  2. reviseViewCount(id, 55)
    Board가 하는 일: 조회 수 검증 뒤 같은 id의 내부 항목을 찾아 viewCount=55로 수정합니다.
    호출자에게 주는 값: void · 반환값 없음
  3. snapshot()
    Board가 하는 일: 새 EntryView[]와 새 EntryView를 만들어 현재 필드값을 담습니다.
    호출자에게 주는 값: id=1, title="references", viewCount=55인 조회값
  4. totalViewCount()
    Board가 하는 일: 현재 보관 항목의 조회 수를 합합니다.
    호출자에게 주는 값: 55

이후 Board를 수정해도 이미 만든 EntryView의 값은 바뀌지 않습니다. 그러나 반환 배열의 슬롯은 호출자가 바꿀 수 있습니다. 새 배열의 슬롯 변경은 Board의 보관 배열을 바꾸지 않습니다.

main의 출력은 EntryView[id=1, title=references, viewCount=55]와 total=55입니다. 여기서의 소유권은 Board가 제공하는 메서드 경계이며, 같은 최상위 클래스 안의 코드까지 접근을 금지하는 별도의 언어 기능을 뜻하지 않습니다.

이 예의 조건

  • Board의 명령·조회 규칙이며 언어 자체 별도 소유권 기능이 아닙니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2main이 add 40 뒤 id 1을 55로 수정한다. Board 내부 MutableEntry는 references 55이다. snapshot은 새 배열과 EntryView로 필드를 복사해 내보내고 total은 55이다. 실선은 명령 또는 계산이며 점선은 조회 사본 복사로서 외부 배열 슬롯 교체가 내부 entries를 교체하지 않는다.
4add: references
540
6revise: id 1
10id 1
11references
15id 1
16references
원 도표의 설명과 주석
구분내용
설명main이 add 40 뒤 id 1을 55로 수정한다. Board 내부 MutableEntry는 references 55이다. snapshot은 새 배열과 EntryView로 필드를 복사해 내보내고 total은 55이다. 실선은 명령 또는 계산이며 점선은 조회 사본 복사로서 외부 배열 슬롯 교체가 내부 entries를 교체하지 않는다.
전체 읽기 정보Board의 변경 명령과 새 EntryView 조회값을 구별한다 Board의 변경 명령과 새 EntryView 조회값을 구별한다 OwnedBoard의 main은 references 게시글 40을 추가하고 id=1의 조회 수를 55로 수정한 뒤 새 EntryView 배열을 받는다. Board의 조회 메서드는 내부 MutableEntry를 반환하지 않으며 반환 배열과 각 조회값의 변경 가능성은 다르다. 변경은 Board가 승인하고 조회는 새 EntryView로 돌려줍니다 OwnedBoard 의 실제 main 호출입니다. Board 의 조회 메서드는 내부 MutableEntry 대신 새 EntryView 를 반환합니다. OwnedBoard의 실제 명령과 조회 경계 main의 순서 Board가 하는 일 호출자에게 주는 값 add("references", 40) 조회 수 검증 뒤 id=1 인 MutableEntry 를 보관합니다. long id 값 1 reviseViewCount(id, 55) 조회 수 검증 뒤 같은 id 의 내부 항목을 찾아 viewCount=55 로 수정합니다. void · 반환값 없음 snapshot() 새 EntryView[] 와 새 EntryView 를 만들어 현재 필드값을 담습니다. id=1 , title="references" , viewCount=55 인 조회값 totalViewCount() 현재 보관 항목의 조회 수를 합합니다. 55 add("references", 40) Board가 하는 일: 조회 수 검증 뒤 id=1 인 MutableEntry 를 보관합니다. 호출자에게 주는 값: long id 값 1 reviseViewCount(id, 55) Board가 하는 일: 조회 수 검증 뒤 같은 id 의 내부 항목을 찾아 viewCount=55 로 수정합니다. 호출자에게 주는 값: void · 반환값 없음 snapshot() Board가 하는 일: 새 EntryView[] 와 새 EntryView 를 만들어 현재 필드값을 담습니다. 호출자에게 주는 값: id=1 , title="references" , viewCount=55 인 조회값 totalViewCount() Board가 하는 일: 현재 보관 항목의 조회 수를 합합니다. 호출자에게 주는 값: 55 이후 Board 를 수정해도 이미 만든 EntryView 의 값은 바뀌지 않습니다. 그러나 반환 배열의 슬롯은 호출자가 바꿀 수 있습니다. 새 배열의 슬롯 변경은 Board 의 보관 배열을 바꾸지 않습니다. main 의 출력은 EntryView[id=1, title=references, viewCount=55] 와 total=55 입니다. 여기서의 소유권은 Board 가 제공하는 메서드 경계이며, 같은 최상위 클래스 안의 코드까지 접근을 금지하는 별도의 언어 기능을 뜻하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
명령으로 등록

main이 references 40을 Board에 add합니다.

  • 내부: MutableEntry id 1
  • viewCount: 40
  • 내부 가변 상태의 변경 입구는 Board 명령입니다.
승인된 변경

revise가 id 1을 55로 바꿉니다.

  • 내부 count: 55
  • 계산 total: 55
  • 조회 결과를 직접 변경하는 대신 명령으로 수정합니다.
조회 복사

snapshot이 새 배열과 EntryView로 필드를 복사합니다.

  • 사본: id 1,references 55
  • 외부 슬롯 교체: 내부 entries 교체 안 함
  • 조회 값이 내부 가변 참조를 노출하지 않습니다.

내부 객체를 그대로 돌려주면 생기는 일

MutableEntry에 조회 수를 바꾸는 setViewCount가 있고, Board에는 snapshot() 대신 id로 게시글을 찾아 내부 MutableEntry를 그대로 돌려주는 find(id)가 있다고 해 봅시다. 그러면 board.find(id).setViewCount(-100)처럼 바깥 코드가 Board를 거치지 않고 게시글을 고칠 수 있습니다. 실제로 이렇게 바꿔 실행하면 스냅샷에는 viewCount=-100, 합계에는 total=-100이 나옵니다. reviseViewCount의 검사를 우회해 Board가 막으려던 음수 조회 수가 들어간 것입니다. 이처럼 필드를 private으로 숨겨도 내부의 가변 객체를 그대로 돌려주면 소유권은 무너집니다. 그래서 조회 메서드는 내부 객체 대신 바꿀 수 없는 값이나 사본을 돌려줘야 합니다.

더 깊이: 실패한 add와 합계의 자료형

만약 add가 long id = nextId++;를 용량 검사보다 먼저 실행한다면, 배열이 가득 찬 상태에서 add를 호출할 때 full 예외가 나는데도 nextId는 이미 1 늘어나 실패한 호출 때문에 id 하나가 건너뛰어집니다. 그래서 위 코드는 검사를 모두 통과한 뒤에 id를 발급합니다. 메서드가 예외로 끝날 때는 객체의 상태를 하나도 바꾸지 않는 것이 좋습니다. 그래야 호출한 쪽이 예외를 잡은 뒤에도 객체를 믿고 계속 쓸 수 있습니다.

합계를 int로 더한다면 어떻게 될까요? 조회 수가 각각 Integer.MAX_VALUE(2147483647)인 게시글 두 개를 int로 더하면 범위를 넘쳐 -2가 됩니다. 위 코드처럼 long으로 더하면 4294967294가 나옵니다. 게시글은 최대 10개이고 각 조회 수는 int 범위 안에 있으므로, 합계는 많아야 21474836470이라 long 범위 안에 충분히 들어갑니다.

세 번째 방법: 바꿀 수 없는 객체로 만들기

불변 객체는 공유해도 아무도 바꿀 수 없으므로, 복사를 기억할 필요도 변경 창구를 한 곳으로 모을 필요도 없습니다. 위 예제의 EntryView가 바로 그런 객체입니다. record이고 구성 요소가 기본형과 String뿐이어서, 어디에 넘겨도 받은 쪽이 값을 바꿀 수 없습니다. Board가 조회 결과를 불변 객체로 돌려준 것도 그 때문입니다. 불변 클래스를 직접 설계하는 방법은 불변 객체와 안전한 공유에서 자세히 다룹니다.

어떤 방법을 고를까

부수 효과가 보인다고 해서 모든 클래스를 불변으로 바꿀 필요는 없습니다. 먼저 그 객체의 상태가 왜 바뀌는지, 그리고 바뀐 상태를 누가 봐야 하는지를 따져 봅니다.

  • 여러 기능이 같은 실체의 최신 상태를 봐야 하는가? 그렇다면 공유하되, 변경은 한 소유 객체에 모읍니다.
  • 지나간 시점의 상태를 보관해야 하는가? 그렇다면 불변 값이나 사본으로 넘깁니다.
  • 객체 안에 배열이나 목록 같은 가변 객체가 있는가? 그렇다면 복사할 때 그 객체까지 복사할지 정해야 합니다.
  • 복사 비용과 예측 가능성 중 무엇이 더 중요한가? 아주 큰 객체를 자주 복사해야 한다면 변경 창구를 모으는 편이 나을 수 있습니다.

실제 프로그램에서는 이 방법들을 섞어 씁니다. 스냅샷과 설정 값은 불변 객체로, 계속 바뀌는 게시판 상태는 Board 같은 단일 소유 객체로, 잠깐 쓰는 편집본은 명시적 복사로 다루는 식입니다. 판단의 중심은 객체를 공유하느냐가 아니라, 공유한 객체를 누가 바꿀 수 있느냐입니다.

정리

  • 객체 두 개가 같은 가변 객체를 필드로 들고 있으면, 한쪽의 변경이 다른 쪽의 결과를 바꾸는 부수 효과가 생깁니다.
  • 자바에는 공유를 막는 문법이 없으므로 설계로 막아야 합니다. 방법은 복사해서 넘기기, 변경을 한 객체에 맡기기, 불변 객체 만들기의 세 가지입니다.
  • 복사는 간단하지만 넘기는 모든 곳에서 기억해야 하고, 얕은 복사는 안쪽의 가변 객체를 공유합니다.
  • 변경을 한 객체에 맡길 때는 내부 가변 객체를 밖으로 돌려주지 않고, 명령은 메서드로 받고 조회 결과는 불변 값이나 사본으로 돌려줍니다.

연습 문제

  1. 위 OwnedBoard의 main을 바꿔, add 직후 reviseViewCount를 호출하기 전에 첫 스냅샷 before를 받아 두세요. 조회 수를 55로 바꾼 뒤 두 번째 스냅샷 after를 받아 before[0]과 after[0]의 조회 수를 출력하고, after[0]에 조회 수가 999인 새 EntryView를 넣은 다음 board.snapshot()을 다시 호출해 조회 수를 출력하세요. 세 값을 먼저 예상한 뒤 실행해 보세요.
  2. 생성자에 넘긴 String[]을 나중에 바꾸거나, 조회 메서드가 돌려준 배열을 바꿔도 내부 태그가 바뀌지 않는 BoardNote를 만드세요.
  3. 2번의 해설 코드에서 tags()가 return tags;로 내부 배열을 그대로 돌려준다면 출력이 어떻게 바뀔까요? 반대로 생성자가 this.tags = tags;로 복사 없이 저장한다면 어떻게 바뀔까요?
해설 보기
  1. main을 다음처럼 바꿉니다. Board, EntryView, MutableEntry는 위 예제 그대로입니다.
public static void main(String[] args) {
    Board board = new Board();
    long id = board.add("references", 40);
    EntryView[] before = board.snapshot();

    board.reviseViewCount(id, 55);
    EntryView[] after = board.snapshot();
    System.out.println("before=" + before[0].viewCount());
    System.out.println("after=" + after[0].viewCount());

    after[0] = new EntryView(id, "references", 999);
    System.out.println("board=" + board.snapshot()[0].viewCount());
}
before=40
after=55
board=55

before는 조회 수가 40일 때 만든 사본이므로, 그 뒤에 Board의 게시글이 55로 바뀌어도 40으로 남습니다. 스냅샷은 Board와 실시간으로 연결된 것이 아니라 호출한 시점의 값을 복사한 결과입니다. after는 수정한 뒤에 만들었으므로 55입니다. after[0]에 새 EntryView를 넣는 것은 snapshot()이 새로 만들어 준 배열의 칸을 바꿀 뿐, Board의 entries 배열과는 관계가 없습니다. 그래서 다시 받은 스냅샷의 조회 수는 여전히 55입니다.

  1. 생성자에서 받은 배열을 복사해 저장하고, 조회 메서드도 내부 배열의 사본을 돌려줍니다.
src/DefensiveTagCopyExercise.java
public final class DefensiveTagCopyExercise {
    public static void main(String[] args) {
        String[] source = {"java", "object"};
        BoardNote note = new BoardNote("sharing", source);

        source[0] = "leak";
        String[] view = note.tags();
        view[1] = "changed";

        String[] safe = note.tags();
        System.out.println("source=" + source[0] + "," + source[1]);
        System.out.println("note=" + safe[0] + "," + safe[1]);
    }

    private static final class BoardNote {
        private final String text;
        private final String[] tags;

        BoardNote(String text, String[] tags) {
            this.text = text;
            this.tags = tags.clone();
        }

        String[] tags() {
            return tags.clone();
        }
    }
}
source=leak,object
note=java,object

생성자의 tags.clone() 덕분에 BoardNote는 호출한 쪽의 source와 다른 배열을 가집니다. 그래서 source[0] = "leak"은 source만 바꾸고, 첫 줄에 leak가 보이는 것도 source 배열 자체를 출력했기 때문입니다. tags()도 호출할 때마다 새 배열을 돌려주므로 view[1] = "changed"는 view 배열만 바꿉니다. 마지막에 다시 받은 safe에는 내부 태그 java, object가 그대로 들어 있습니다. 배열의 원소인 String은 불변이므로 배열만 복사해도 충분합니다. 원소가 바꿀 수 있는 객체였다면 원소도 하나씩 복사해야 합니다. 이렇게 객체가 자기 내부 상태를 지키려고 받을 때와 내줄 때 복사하는 것을 방어적 복사(defensive copy)라고 하며, final 참조와 객체 변경에서 처음 다뤘습니다.

태그 배열은 생성과 조회 양쪽에서 복사한다

생성 시 source를 복사하고 조회 시 별도 view를 만들어 각각 외부 대입을 해도 내부 java/object와 다음 safe가 유지됩니다. 두 경계에서 복사본과 내부 값의 실제 상태를 추적합니다.

태그 배열은 생성과 조회 양쪽에서 복사한다

DefensiveTagCopyExercise에서 생성자의 clone은 source 변경이 내부 배열에 전파되는 것을 막고, tags의 clone은 반환 배열 변경을 내부와 분리한다. 최종 source는 leak,object지만 새 조회 safe는 java,object다.

입력과 반환 배열을 모두 복사해 내부 태그를 지킵니다

DefensiveTagCopyExercise의 BoardNote는 생성자와 tags()에서 각각 배열을 clone()합니다. 문자열 원소는 공유하지만 배열의 슬롯은 분리됩니다.

입력과 반환 배열을 모두 복사해 내부 태그를 지킵니다 생성자clone으로source와내부tags배열을분리하고각tags조회도새배열을만든다. 최종sourceleak/object,내부java/object,viewjava/changed,safejava/object이다. 점선은복사당시의경로,현재참조별칭이아니며String요소는불변으로공유한다. source [0] leak [1] object 내부 tags [0] java [1] object view [0] java [1] changed safe [0] java [1] object 생성 시 입력 복사 첫 조회: 배열 복사 둘째 조회: 배열 복사 점선은 생성·조회 시의 복사 · 현재 같은 배열을 공유한다는 뜻 아님
입력과 반환 배열을 모두 복사해 내부 태그를 지킵니다 생성자clone으로source와내부tags배열을분리하고각tags조회도새배열을만든다. 최종sourceleak/object,내부java/object,viewjava/changed,safejava/object이다. 점선은복사당시의경로,현재참조별칭이아니며String요소는불변으로공유한다. 클래스 내부 source [0] leak [1] object 내부 tags [0] java [1] object view [0] java [1] changed safe [0] java [1] object 생성 시 입력 복사 첫 조회: 복사 둘째 조회: 복사 점선: 새 배열을 만든 복사 슬롯의 String은 불변 공유
입력 배열과 반환 배열의 수정이 서로 다른 복사 경계를 만나는 과정
원문의 시점바꾸거나 만드는 배열내부 태그와의 관계
new BoardNote 생성source의 "java", "object"를 새 내부 배열로 복사합니다.입력 배열과 내부 배열은 서로 다릅니다.
source[0] = "leak"호출자 입력 배열은 "leak", "object"가 됩니다.내부는 "java", "object" 그대로입니다.
view = note.tags()

view[1] = "changed"

새 반환 배열 view만 "java", "changed"가 됩니다.내부는 "java", "object" 그대로입니다.
safe = note.tags()내부 태그를 또 다른 새 배열 safe에 복사합니다.safe에서 "java", "object"를 읽습니다.
  1. new BoardNote 생성
    바꾸거나 만드는 배열: source의 "java", "object"를 새 내부 배열로 복사합니다.
    내부 태그와의 관계: 입력 배열과 내부 배열은 서로 다릅니다.
  2. source[0] = "leak"
    바꾸거나 만드는 배열: 호출자 입력 배열은 "leak", "object"가 됩니다.
    내부 태그와의 관계: 내부는 "java", "object" 그대로입니다.
  3. view = note.tags() 뒤 view[1] = "changed"
    바꾸거나 만드는 배열: 새 반환 배열 view만 "java", "changed"가 됩니다.
    내부 태그와의 관계: 내부는 "java", "object" 그대로입니다.
  4. safe = note.tags()
    바꾸거나 만드는 배열: 내부 태그를 또 다른 새 배열 safe에 복사합니다.
    내부 태그와의 관계: safe에서 "java", "object"를 읽습니다.

원문 main이 출력하는 두 줄입니다.

source=leak,object
note=java,object

view의 두 슬롯이 "java", "changed"인 상태는 대입문에서 읽은 설명이며 별도의 출력 줄이 아닙니다. 배열 원소인 문자열은 불변입니다. 이 clone()이 임의의 가변 원소까지 깊은 복사하는 것은 아닙니다.

이 예의 조건

  • String 요소는 불변이며 입력/출력 배열 구조를 복사한다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2생성자의 clone으로 source와 내부 tags 배열을 분리하고 각 tags 조회도 새 배열을 만든다. 최종 source는 leak/object, 내부는 java/object, view는 java/changed, safe는 java/object이다. 점선은 복사 당시의 경로이며 현재 참조 별칭이 아니고 String 요소는 불변으로 공유한다.
원 도표의 설명과 주석
구분내용
설명생성자의 clone으로 source와 내부 tags 배열을 분리하고 각 tags 조회도 새 배열을 만든다. 최종 source는 leak/object, 내부는 java/object, view는 java/changed, safe는 java/object이다. 점선은 복사 당시의 경로이며 현재 참조 별칭이 아니고 String 요소는 불변으로 공유한다.
전체 읽기 정보태그 배열은 생성과 조회 양쪽에서 복사한다 태그 배열은 생성과 조회 양쪽에서 복사한다 DefensiveTagCopyExercise에서 생성자의 clone은 source 변경이 내부 배열에 전파되는 것을 막고, tags의 clone은 반환 배열 변경을 내부와 분리한다. 최종 source는 leak,object지만 새 조회 safe는 java,object다. 입력과 반환 배열을 모두 복사해 내부 태그를 지킵니다 DefensiveTagCopyExercise 의 BoardNote 는 생성자와 tags() 에서 각각 배열을 clone() 합니다. 문자열 원소는 공유하지만 배열의 슬롯은 분리됩니다. 입력 배열과 반환 배열의 수정이 서로 다른 복사 경계를 만나는 과정 원문의 시점 바꾸거나 만드는 배열 내부 태그와의 관계 new BoardNote 생성 source 의 "java", "object" 를 새 내부 배열로 복사합니다. 입력 배열과 내부 배열은 서로 다릅니다. source[0] = "leak" 호출자 입력 배열은 "leak", "object" 가 됩니다. 내부는 "java", "object" 그대로입니다. view = note.tags() view[1] = "changed" 새 반환 배열 view 만 "java", "changed" 가 됩니다. 내부는 "java", "object" 그대로입니다. safe = note.tags() 내부 태그를 또 다른 새 배열 safe 에 복사합니다. safe 에서 "java", "object" 를 읽습니다. new BoardNote 생성 바꾸거나 만드는 배열: source 의 "java", "object" 를 새 내부 배열로 복사합니다. 내부 태그와의 관계: 입력 배열과 내부 배열은 서로 다릅니다. source[0] = "leak" 바꾸거나 만드는 배열: 호출자 입력 배열은 "leak", "object" 가 됩니다. 내부 태그와의 관계: 내부는 "java", "object" 그대로입니다. view = note.tags() 뒤 view[1] = "changed" 바꾸거나 만드는 배열: 새 반환 배열 view 만 "java", "changed" 가 됩니다. 내부 태그와의 관계: 내부는 "java", "object" 그대로입니다. safe = note.tags() 바꾸거나 만드는 배열: 내부 태그를 또 다른 새 배열 safe 에 복사합니다. 내부 태그와의 관계: safe 에서 "java", "object" 를 읽습니다. 원문 main 이 출력하는 두 줄입니다. source=leak,object note=java,object view 의 두 슬롯이 "java", "changed" 인 상태는 대입문에서 읽은 설명이며 별도의 출력 줄이 아닙니다. 배열 원소인 문자열은 불변입니다. 이 clone() 이 임의의 가변 원소까지 깊은 복사하는 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
입력 복사

java/object인 source를 내부 tags로 복사합니다.

  • source: [java,object]
  • tags: [java,object]
  • 외부 배열 대입이 내부 슬롯에 닿지 않는 경계를 만든다.
입력 변경

source[0]을 leak로 바꿉니다.

  • source: [leak,object]
  • tags: [java,object] 유지
  • 생성 이후 외부 입력 변경은 분리된다.
조회 복사본 변경

tags()가 반환한 view[1]을 changed로 바꿉니다.

  • view: [java,changed]
  • tags: [java,object] 유지
  • 반환 경계에서도 별도 배열을 제공한다.
다음 조회

safe가 내부 값의 새 배열을 받습니다.

  • safe: [java,object]
  • source: [leak,object]
  • 앞선 두 외부 대입이 내부 보관값을 바꾸지 않았다.

태그 배열은 생성과 조회 양쪽에서 복사한다

생성 시 source를 복사하고 조회 시 별도 view를 만들어 각각 외부 대입을 해도 내부 java/object와 다음 safe가 유지됩니다. 두 경계에서 복사본과 내부 값의 실제 상태를 추적합니다.

태그 배열은 생성과 조회 양쪽에서 복사한다

DefensiveTagCopyExercise에서 생성자의 clone은 source 변경이 내부 배열에 전파되는 것을 막고, tags의 clone은 반환 배열 변경을 내부와 분리한다. 최종 source는 leak,object지만 새 조회 safe는 java,object다.

입력과 반환 배열을 모두 복사해 내부 태그를 지킵니다

DefensiveTagCopyExercise의 BoardNote는 생성자와 tags()에서 각각 배열을 clone()합니다. 문자열 원소는 공유하지만 배열의 슬롯은 분리됩니다.

입력과 반환 배열을 모두 복사해 내부 태그를 지킵니다 생성자clone으로source와내부tags배열을분리하고각tags조회도새배열을만든다. 최종sourceleak/object,내부java/object,viewjava/changed,safejava/object이다. 점선은복사당시의경로,현재참조별칭이아니며String요소는불변으로공유한다. source [0] leak [1] object 내부 tags [0] java [1] object view [0] java [1] changed safe [0] java [1] object 생성 시 입력 복사 첫 조회: 배열 복사 둘째 조회: 배열 복사 점선은 생성·조회 시의 복사 · 현재 같은 배열을 공유한다는 뜻 아님
입력과 반환 배열을 모두 복사해 내부 태그를 지킵니다 생성자clone으로source와내부tags배열을분리하고각tags조회도새배열을만든다. 최종sourceleak/object,내부java/object,viewjava/changed,safejava/object이다. 점선은복사당시의경로,현재참조별칭이아니며String요소는불변으로공유한다. 클래스 내부 source [0] leak [1] object 내부 tags [0] java [1] object view [0] java [1] changed safe [0] java [1] object 생성 시 입력 복사 첫 조회: 복사 둘째 조회: 복사 점선: 새 배열을 만든 복사 슬롯의 String은 불변 공유
입력 배열과 반환 배열의 수정이 서로 다른 복사 경계를 만나는 과정
원문의 시점바꾸거나 만드는 배열내부 태그와의 관계
new BoardNote 생성source의 "java", "object"를 새 내부 배열로 복사합니다.입력 배열과 내부 배열은 서로 다릅니다.
source[0] = "leak"호출자 입력 배열은 "leak", "object"가 됩니다.내부는 "java", "object" 그대로입니다.
view = note.tags()

view[1] = "changed"

새 반환 배열 view만 "java", "changed"가 됩니다.내부는 "java", "object" 그대로입니다.
safe = note.tags()내부 태그를 또 다른 새 배열 safe에 복사합니다.safe에서 "java", "object"를 읽습니다.
  1. new BoardNote 생성
    바꾸거나 만드는 배열: source의 "java", "object"를 새 내부 배열로 복사합니다.
    내부 태그와의 관계: 입력 배열과 내부 배열은 서로 다릅니다.
  2. source[0] = "leak"
    바꾸거나 만드는 배열: 호출자 입력 배열은 "leak", "object"가 됩니다.
    내부 태그와의 관계: 내부는 "java", "object" 그대로입니다.
  3. view = note.tags() 뒤 view[1] = "changed"
    바꾸거나 만드는 배열: 새 반환 배열 view만 "java", "changed"가 됩니다.
    내부 태그와의 관계: 내부는 "java", "object" 그대로입니다.
  4. safe = note.tags()
    바꾸거나 만드는 배열: 내부 태그를 또 다른 새 배열 safe에 복사합니다.
    내부 태그와의 관계: safe에서 "java", "object"를 읽습니다.

원문 main이 출력하는 두 줄입니다.

source=leak,object
note=java,object

view의 두 슬롯이 "java", "changed"인 상태는 대입문에서 읽은 설명이며 별도의 출력 줄이 아닙니다. 배열 원소인 문자열은 불변입니다. 이 clone()이 임의의 가변 원소까지 깊은 복사하는 것은 아닙니다.

이 예의 조건

  • String 요소는 불변이며 입력/출력 배열 구조를 복사한다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2생성자의 clone으로 source와 내부 tags 배열을 분리하고 각 tags 조회도 새 배열을 만든다. 최종 source는 leak/object, 내부는 java/object, view는 java/changed, safe는 java/object이다. 점선은 복사 당시의 경로이며 현재 참조 별칭이 아니고 String 요소는 불변으로 공유한다.
원 도표의 설명과 주석
구분내용
설명생성자의 clone으로 source와 내부 tags 배열을 분리하고 각 tags 조회도 새 배열을 만든다. 최종 source는 leak/object, 내부는 java/object, view는 java/changed, safe는 java/object이다. 점선은 복사 당시의 경로이며 현재 참조 별칭이 아니고 String 요소는 불변으로 공유한다.
전체 읽기 정보태그 배열은 생성과 조회 양쪽에서 복사한다 태그 배열은 생성과 조회 양쪽에서 복사한다 DefensiveTagCopyExercise에서 생성자의 clone은 source 변경이 내부 배열에 전파되는 것을 막고, tags의 clone은 반환 배열 변경을 내부와 분리한다. 최종 source는 leak,object지만 새 조회 safe는 java,object다. 입력과 반환 배열을 모두 복사해 내부 태그를 지킵니다 DefensiveTagCopyExercise 의 BoardNote 는 생성자와 tags() 에서 각각 배열을 clone() 합니다. 문자열 원소는 공유하지만 배열의 슬롯은 분리됩니다. 입력 배열과 반환 배열의 수정이 서로 다른 복사 경계를 만나는 과정 원문의 시점 바꾸거나 만드는 배열 내부 태그와의 관계 new BoardNote 생성 source 의 "java", "object" 를 새 내부 배열로 복사합니다. 입력 배열과 내부 배열은 서로 다릅니다. source[0] = "leak" 호출자 입력 배열은 "leak", "object" 가 됩니다. 내부는 "java", "object" 그대로입니다. view = note.tags() view[1] = "changed" 새 반환 배열 view 만 "java", "changed" 가 됩니다. 내부는 "java", "object" 그대로입니다. safe = note.tags() 내부 태그를 또 다른 새 배열 safe 에 복사합니다. safe 에서 "java", "object" 를 읽습니다. new BoardNote 생성 바꾸거나 만드는 배열: source 의 "java", "object" 를 새 내부 배열로 복사합니다. 내부 태그와의 관계: 입력 배열과 내부 배열은 서로 다릅니다. source[0] = "leak" 바꾸거나 만드는 배열: 호출자 입력 배열은 "leak", "object" 가 됩니다. 내부 태그와의 관계: 내부는 "java", "object" 그대로입니다. view = note.tags() 뒤 view[1] = "changed" 바꾸거나 만드는 배열: 새 반환 배열 view 만 "java", "changed" 가 됩니다. 내부 태그와의 관계: 내부는 "java", "object" 그대로입니다. safe = note.tags() 바꾸거나 만드는 배열: 내부 태그를 또 다른 새 배열 safe 에 복사합니다. 내부 태그와의 관계: safe 에서 "java", "object" 를 읽습니다. 원문 main 이 출력하는 두 줄입니다. source=leak,object note=java,object view 의 두 슬롯이 "java", "changed" 인 상태는 대입문에서 읽은 설명이며 별도의 출력 줄이 아닙니다. 배열 원소인 문자열은 불변입니다. 이 clone() 이 임의의 가변 원소까지 깊은 복사하는 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
입력 복사

java/object인 source를 내부 tags로 복사합니다.

  • source: [java,object]
  • tags: [java,object]
  • 외부 배열 대입이 내부 슬롯에 닿지 않는 경계를 만든다.
입력 변경

source[0]을 leak로 바꿉니다.

  • source: [leak,object]
  • tags: [java,object] 유지
  • 생성 이후 외부 입력 변경은 분리된다.
조회 복사본 변경

tags()가 반환한 view[1]을 changed로 바꿉니다.

  • view: [java,changed]
  • tags: [java,object] 유지
  • 반환 경계에서도 별도 배열을 제공한다.
다음 조회

safe가 내부 값의 새 배열을 받습니다.

  • safe: [java,object]
  • source: [leak,object]
  • 앞선 두 외부 대입이 내부 보관값을 바꾸지 않았다.
  1. tags()가 내부 배열을 그대로 돌려주면 view가 내부 배열의 별칭이 되므로, view[1] = "changed"가 내부 태그를 바꿉니다. 출력의 둘째 줄은 note=java,changed가 됩니다. 생성자가 복사 없이 저장하면 내부 배열이 source와 같은 배열이 되므로, source[0] = "leak"이 내부 태그를 바꿔 둘째 줄이 note=leak,object가 됩니다. 두 경우 모두 첫 줄은 source=leak,object 그대로입니다. 받을 때와 내줄 때 중 한쪽만 복사하면 다른 쪽으로 내부 상태가 새어 나가므로, 양쪽을 모두 복사해야 합니다.

다음 글

불변 객체와 안전한 공유에서는 세 번째 방법을 다룹니다. setter를 없애고 필드를 final로 만드는 것에서 시작해, 생성자에서 값을 검사하고 배열 필드를 복사해 공유해도 안전한 불변 객체를 완성합니다.