본문 바로가기

불변 값과 컬렉션 스냅샷

값이 바뀌는 객체가 HashMap 키와 공유 목록에서 일으키는 문제를 실행해 보고, record와 List.copyOf로 바뀌지 않는 값과 컬렉션 스냅샷을 만드는 방법을 배웁니다.

앞 글에서는 점수 계산 같은 핵심 로직을 순수 함수로 떼어 내고, 시간·난수·저장소처럼 바깥 세계와 닿는 일은 그 밖으로 밀어냈습니다. 그런데 순수 함수에 넘긴 값이 함수 밖에서 바뀔 수 있다면 이야기가 달라집니다. 같은 목록으로 두 번 계산했는데 그사이 누군가 그 목록에 원소를 추가했다면, 함수 자체는 순수해도 두 결과는 다릅니다. 함수형 스타일로 값을 마음 놓고 주고받으려면 값 자체가 바뀌지 않아야 합니다.

만들어진 뒤로 바깥에서 볼 수 있는 상태가 바뀌지 않는 객체를 불변(immutable) 객체라고 합니다. 내용을 바꿔야 할 때는 기존 객체를 고치지 않고, 바뀐 내용을 담은 새 객체를 만듭니다. 기본 규칙은 불변 객체와 안전한 공유와 불변 값과 with 메서드에서 다뤘으므로, 이 글은 불변 값을 컬렉션과 함께 쓸 때 생기는 문제에 집중합니다. 먼저 HashMap의 키가 바뀌면 무슨 일이 생기는지 보고, 이어서 final 필드만으로는 왜 부족한지와 목록의 그 시점 내용을 고정한 사본, 즉 스냅샷을 만드는 방법을 살펴봅니다. 마지막으로 불변 값을 여러 스레드에 넘길 때 무엇이 쉬워지는지 확인합니다.

HashMap에 넣은 키를 바꾸면 생기는 일

아래 PostKey는 게시판 분류 이름 하나를 감싼 키 클래스입니다. equals와 hashCode를 모두 category로 계산하므로 HashSet에 넣을 객체의 equals와 hashCode에서 배운 규칙은 지키고 있습니다. 문제는 rename 메서드 때문에 맵에 넣은 뒤에도 category를 바꿀 수 있다는 점입니다. 프로그램은 조회 수 60을 java 키로 저장한 다음 키의 이름을 spring으로 바꾸고, 같은 객체와 예전 이름, 새 이름으로 각각 다시 찾아봅니다.

lab/MutableMapKeyLookupBug.java
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public final class MutableMapKeyLookupBug {
    private static final class PostKey {
        private String category;

        private PostKey(String category) {
            this.category = category;
        }

        void rename(String category) {
            this.category = category;
        }

        @Override
        public boolean equals(Object object) {
            return object instanceof PostKey other && Objects.equals(category, other.category);
        }

        @Override
        public int hashCode() {
            return Objects.hash(category);
        }
    }

    public static void main(String[] args) {
        PostKey key = new PostKey("java");
        Map<PostKey, Integer> viewCount = new HashMap<>();
        viewCount.put(key, 60);
        System.out.println("before=" + viewCount.get(key));

        key.rename("spring");
        System.out.println("same-object=" + viewCount.get(key));
        System.out.println("old-name=" + viewCount.get(new PostKey("java")));
        System.out.println("new-name=" + viewCount.get(new PostKey("spring")));
        System.out.println("size=" + viewCount.size());
    }
}
실행 결과
before=60
same-object=null
old-name=null
new-name=null
size=1

첫 줄은 이름을 바꾸기 전이라 60을 찾습니다. 그런데 이름을 바꾼 뒤에는 넣을 때 쓴 바로 그 객체로 찾아도, 예전 이름이나 새 이름으로 찾아도 모두 null입니다. 그렇다고 항목이 지워진 것은 아닙니다. 마지막 줄의 size=1이 보여 주듯 항목은 맵 안에 그대로 있는데, 어떤 키로도 꺼낼 수 없게 되었습니다.

원인은 HashMap이 키를 찾는 방식에 있습니다. put은 키의 hashCode()로 저장할 버킷(bucket)을 고르고, 그때 계산한 해시 값을 항목과 함께 기억해 둡니다. get은 찾는 키의 해시 값을 새로 계산해 버킷을 찾은 뒤, 기억해 둔 해시 값과 equals로 키를 확인합니다. 이름이 바뀐 key나 새로 만든 PostKey("spring")은 해시 값이 달라졌으므로 다른 버킷을 찾아가고, 우연히 같은 버킷에 닿더라도 기억해 둔 해시 값과 맞지 않습니다. 반대로 PostKey("java")는 원래 버킷을 정확히 찾아가지만, 그 안에 있는 키가 이제 spring이라서 equals가 false를 돌려줍니다.

Map의 공식 문서도 이 상황을 직접 경고합니다. 키가 맵에 들어 있는 동안 equals 비교에 영향을 주는 방식으로 키의 값을 바꾸면, 맵의 동작은 정해져 있지 않다(not specified)는 것입니다. 위 출력은 지금의 HashMap 구현에서 나온 결과일 뿐이고, 어떤 결과가 나와야 한다는 약속은 없다는 뜻입니다. HashSet도 내부적으로 HashMap의 키 자리에 원소를 저장하므로 같은 문제가 생깁니다.

해결책은 키를 불변으로 만드는 것입니다. record는 구성 요소 필드가 모두 final이고 equals와 hashCode를 구성 요소로 만들어 주므로 키로 쓰기에 알맞습니다. 이름을 바꾸는 일이 업무상 필요하다면 키를 고치지 말고 새 키를 만든 뒤, 맵의 항목을 그 키로 옮깁니다.

src/ImmutableMapKeyMove.java
import java.util.HashMap;
import java.util.Map;

public final class ImmutableMapKeyMove {
    private record PostKey(String category) {
        PostKey withCategory(String newCategory) {
            return new PostKey(newCategory);
        }
    }

    public static void main(String[] args) {
        PostKey javaKey = new PostKey("java");
        Map<PostKey, Integer> viewCount = new HashMap<>();
        viewCount.put(javaKey, 60);

        PostKey springKey = javaKey.withCategory("spring");
        Integer count = viewCount.remove(javaKey);
        viewCount.put(springKey, count);

        System.out.println("old-name=" + viewCount.get(new PostKey("java")));
        System.out.println("new-name=" + viewCount.get(new PostKey("spring")));
        System.out.println("size=" + viewCount.size());
    }
}
실행 결과
old-name=null
new-name=60
size=1

이번에도 old-name은 null이지만 이유가 다릅니다. 앞에서는 항목을 잃어버려서 나온 null이고, 여기서는 remove로 꺼내 새 키로 옮겼기 때문에 나온 null입니다. 새 이름으로는 60을 찾고 크기는 여전히 1입니다. 맵이 바뀌는 지점이 remove와 put 두 줄로 코드에 드러난다는 것도 차이입니다. 앞의 코드에서는 key.rename("spring")이 맵을 망가뜨린다는 사실이 코드 어디에도 보이지 않았습니다.

record로 바뀌지 않는 값 만들기

맵의 키가 아니더라도 여러 곳에서 주고받는 값은 불변으로 만들어 두면 따라가기 쉽습니다. 아래 BoardPostMetric은 게시글 하나의 통계로 분류·조회 수·날짜·공개 여부를 담습니다. 컴팩트 생성자(compact constructor)는 매개변수 목록을 다시 쓰지 않고 검사나 정리 코드만 적는 record의 생성자로, 여기서는 분류가 비어 있거나 조회 수가 0 이하이면 예외를 던져 객체가 만들어지지 않게 합니다. 날짜가 null인지는 검사하지 않으므로, 실제 도메인이라면 필요한 조건을 더 넣어야 합니다.

이 record에는 필드를 바꾸는 메서드가 없습니다. 대신 publish()와 withViewCount()가 바뀐 내용을 담은 새 record를 돌려줍니다.

src/ImmutableBoardPostMetricRecord.java
import java.time.LocalDate;

public final class ImmutableBoardPostMetricRecord {
    private record BoardPostMetric(String category, int viewCount, LocalDate date, boolean published) {
        private BoardPostMetric {
            if (category == null || category.isBlank()) {
                throw new IllegalArgumentException("category");
            }
            if (viewCount <= 0) {
                throw new IllegalArgumentException("viewCount");
            }
        }

        BoardPostMetric publish() {
            if (published) {
                return this;
            }
            return new BoardPostMetric(category, viewCount, date, true);
        }

        BoardPostMetric withViewCount(int newViewCount) {
            return new BoardPostMetric(category, newViewCount, date, published);
        }
    }

    public static void main(String[] args) {
        BoardPostMetric draft = new BoardPostMetric("immutability", 40, LocalDate.of(2026, 7, 14), false);
        BoardPostMetric published = draft.publish().withViewCount(50);
        System.out.println("draft=" + draft);
        System.out.println("published=" + published);
        System.out.println("reused=" + (published.publish() == published));
    }
}
실행 결과
draft=BoardPostMetric[category=immutability, viewCount=40, date=2026-07-14, published=false]
published=BoardPostMetric[category=immutability, viewCount=50, date=2026-07-14, published=true]
reused=true

draft.publish().withViewCount(50)은 새 객체를 두 번 만듭니다. publish()가 공개 상태인 새 값을 만들고, 이어서 그 값의 withViewCount(50)이 조회 수만 바꾼 또 다른 값을 만듭니다. 그래서 첫 줄의 draft는 여전히 조회 수 40, 미공개 상태입니다. 바뀌기 전의 값이 필요할 때 미리 복사해 둘 필요 없이 원래 변수를 그대로 쓰면 된다는 것이 불변 값의 장점입니다.

셋째 줄은 이미 공개된 값에 publish()를 다시 부르면 새 객체를 만들지 않고 this를 돌려준다는 것을 보여 줍니다. 가변 객체라면 이렇게 같은 객체를 돌려주는 것이 위험합니다. 받은 쪽이 객체를 고치면 원래 쪽도 함께 바뀌기 때문입니다. 불변 객체는 누구도 고칠 수 없으므로 여러 곳에서 공유해도 안전하고, 덕분에 이런 식으로 불필요한 할당을 줄일 수 있습니다.

record는 구성 요소가 모두 같으면 equals가 true인 값입니다. 통계나 금액처럼 내용이 곧 정체인 데이터에는 이 성질이 잘 맞지만, 조회 수가 바뀌어도 같은 게시글로 봐야 하는 대상에는 맞지 않습니다. 그런 대상은 식별자를 가진 별도 클래스로 관리하고, 어느 시점의 상태를 이처럼 불변 record로 떠서 주고받는 방식이 자연스럽습니다.

final 필드가 가리키는 목록은 바뀔 수 있다

필드를 final로 선언하면 불변이 될 것 같지만, final이 막는 것은 필드에 다른 객체를 다시 대입하는 일뿐입니다. 필드가 가리키는 ArrayList에 원소를 넣거나 빼는 일은 막지 못합니다. 특히 생성자가 호출한 쪽의 목록을 그대로 보관하면, 호출한 쪽이 나중에 그 목록을 고칠 때 객체의 내용도 함께 바뀝니다.

그래서 생성자에서 받은 목록을 List.copyOf로 복사해 둡니다. List.copyOf는 주어진 컬렉션의 원소를 같은 순서로 담은 수정할 수 없는(unmodifiable) 목록을 돌려주고, 원래 컬렉션이 나중에 바뀌어도 그 변화를 반영하지 않습니다. 이처럼 어느 시점의 내용을 고정해 둔 사본을 스냅샷(snapshot)이라고 하고, 받은 값을 복사해 바깥의 변경을 막는 일을 방어적 복사(defensive copy)라고 합니다. 아래 BoardPlan은 분류 목록을 이렇게 보관하고, 분류를 추가할 때도 자신을 고치지 않고 새 계획을 돌려줍니다.

src/DefensiveBoardPlanSnapshot.java
import java.util.ArrayList;
import java.util.List;

public final class DefensiveBoardPlanSnapshot {
    private static final class BoardPlan {
        private final String title;
        private final List<String> categories;

        private BoardPlan(String title, List<String> categories) {
            this.title = title;
            this.categories = List.copyOf(categories);
        }

        String title() {
            return title;
        }

        List<String> categories() {
            return categories;
        }

        BoardPlan addCategory(String category) {
            ArrayList<String> updated = new ArrayList<>(categories);
            updated.add(category);
            return new BoardPlan(title, updated);
        }
    }

    public static void main(String[] args) {
        ArrayList<String> source = new ArrayList<>(List.of("lambda", "stream"));
        BoardPlan original = new BoardPlan("functional", source);
        source.add("external-change");
        BoardPlan updated = original.addCategory("optional");
        System.out.println("original=" + original.categories());
        System.out.println("updated=" + updated.categories());
    }
}
실행 결과
original=[lambda, stream]
updated=[lambda, stream, optional]

원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다

source 변경은 스냅샷 original에 반영되지 않고 addCategory는 optional이 든 새 updated 값을 만듭니다. 세 목록을 고정해 변경 전·후와 새 계획을 비교할 수 있습니다.

원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다

DefensiveBoardPlanSnapshot의 main이 각 문장을 완료한 뒤 보관하는 목록 내용을 비교합니다.

외부 목록과 두 계획의 스냅샷

원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다

source에 external-change를 추가해도 original은 바뀌지 않습니다. addCategory는 optional이 든 updated를 만들고 original을 유지합니다.

외부 목록과 불변 계획 사이의 구조 경계

외부 목록과 불변 계획 사이의 구조 경계source에서 original을 만든 뒤 외부 추가는 source에만 남는다. original.addCategory(optional)은 기존 계획을 바꾸지 않고 updated 값을 만든다. String 원소이며 copyOf의 새 객체 할당을 주장하지 않는다.복사외부 sourcelambda · streamoriginal snapshotlambda · streamsource추가외부 변경updated 새 계획lambda · stream · optional
외부 목록과 불변 계획 사이의 구조 경계source에서 original을 만든 뒤 외부 추가는 source에만 남는다. original.addCategory(optional)은 기존 계획을 바꾸지 않고 updated 값을 만든다. String 원소이며 copyOf의 새 객체 할당을 주장하지 않는다.복사외부 sourcelambda · streamoriginal snapshotlambda · streamsource추가외부 변경updated 새 계획lambda · stream · optional

source에서 original을 만든 뒤 외부 추가는 source에만 남는다. original.addCategory(optional)은 기존 계획을 바꾸지 않고 updated 값을 만든다. String 원소이며 copyOf의 새 객체 할당을 주장하지 않는다.

상태별 상세 해설
원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다
목록 위치main의 변경 이후 내용수정 계약
외부 원본 source[lambda, stream, external-change]호출자가 추가·삭제할 수 있음
기존 계획 original[lambda, stream]목록 구조 수정 불가
새 계획 updated[lambda, stream, optional]목록 구조 수정 불가
외부 원본 source
main의 변경 이후 내용
[lambda, stream, external-change]
수정 계약
호출자가 추가·삭제할 수 있음
기존 계획 original
main의 변경 이후 내용
[lambda, stream]
수정 계약
목록 구조 수정 불가
새 계획 updated
main의 변경 이후 내용
[lambda, stream, optional]
수정 계약
목록 구조 수정 불가

원문을 따라 계산한 값입니다. 두 계획은 String 원소를 보관하며, List.copyOf가 매번 새 객체를 할당한다는 보장은 아닙니다.

이 예의 조건

  • List.copyOf가 매번 새 객체를 할당한다고 보장하지 않는다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다 원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다 DefensiveBoardPlanSnapshot의 main이 각 문장을 완료한 뒤 보관하는 목록 내용을 비교합니다. 외부 목록과 두 계획의 스냅샷 원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다 source에 external-change를 추가해도 original은 바뀌지 않습니다. addCategory는 optional이 든 updated를 만들고 original을 유지합니다. 원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다 목록 위치 main의 변경 이후 내용 수정 계약 외부 원본 source [lambda, stream, external-change] 호출자가 추가·삭제할 수 있음 기존 계획 original [lambda, stream] 목록 구조 수정 불가 새 계획 updated [lambda, stream, optional] 목록 구조 수정 불가 외부 원본 source main의 변경 이후 내용 [lambda, stream, external-change] 수정 계약 호출자가 추가·삭제할 수 있음 기존 계획 original main의 변경 이후 내용 [lambda, stream] 수정 계약 목록 구조 수정 불가 새 계획 updated main의 변경 이후 내용 [lambda, stream, optional] 수정 계약 목록 구조 수정 불가 원문을 따라 계산한 값입니다. 두 계획은 String 원소를 보관하며, List.copyOf가 매번 새 객체를 할당한다는 보장은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
계획 생성

source의 lambda/stream을 original 값에 보관합니다.

  • source: [lambda,stream]
  • original: [lambda,stream]
  • 계획은 외부 목록의 이후 구조 변경을 받지 않는다.
외부 목록 변경

source에 external-change를 추가합니다.

  • source: [lambda,stream,external-change]
  • original: [lambda,stream] 유지
  • 방어적 snapshot이 외부 추가 경로를 차단한다.
새 계획 추가

original.addCategory(optional)을 호출해 새 계획 값을 받습니다.

  • updated: [lambda,stream,optional]
  • original: [lambda,stream] 유지
  • 기존 값 변경 대신 다른 계획 값을 반환한다.

원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다

source 변경은 스냅샷 original에 반영되지 않고 addCategory는 optional이 든 새 updated 값을 만듭니다. 세 목록을 고정해 변경 전·후와 새 계획을 비교할 수 있습니다.

원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다

DefensiveBoardPlanSnapshot의 main이 각 문장을 완료한 뒤 보관하는 목록 내용을 비교합니다.

외부 목록과 두 계획의 스냅샷

원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다

source에 external-change를 추가해도 original은 바뀌지 않습니다. addCategory는 optional이 든 updated를 만들고 original을 유지합니다.

외부 목록과 불변 계획 사이의 구조 경계

외부 목록과 불변 계획 사이의 구조 경계source에서 original을 만든 뒤 외부 추가는 source에만 남는다. original.addCategory(optional)은 기존 계획을 바꾸지 않고 updated 값을 만든다. String 원소이며 copyOf의 새 객체 할당을 주장하지 않는다.복사외부 sourcelambda · streamoriginal snapshotlambda · streamsource추가외부 변경updated 새 계획lambda · stream · optional
외부 목록과 불변 계획 사이의 구조 경계source에서 original을 만든 뒤 외부 추가는 source에만 남는다. original.addCategory(optional)은 기존 계획을 바꾸지 않고 updated 값을 만든다. String 원소이며 copyOf의 새 객체 할당을 주장하지 않는다.복사외부 sourcelambda · streamoriginal snapshotlambda · streamsource추가외부 변경updated 새 계획lambda · stream · optional

source에서 original을 만든 뒤 외부 추가는 source에만 남는다. original.addCategory(optional)은 기존 계획을 바꾸지 않고 updated 값을 만든다. String 원소이며 copyOf의 새 객체 할당을 주장하지 않는다.

상태별 상세 해설
원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다
목록 위치main의 변경 이후 내용수정 계약
외부 원본 source[lambda, stream, external-change]호출자가 추가·삭제할 수 있음
기존 계획 original[lambda, stream]목록 구조 수정 불가
새 계획 updated[lambda, stream, optional]목록 구조 수정 불가
외부 원본 source
main의 변경 이후 내용
[lambda, stream, external-change]
수정 계약
호출자가 추가·삭제할 수 있음
기존 계획 original
main의 변경 이후 내용
[lambda, stream]
수정 계약
목록 구조 수정 불가
새 계획 updated
main의 변경 이후 내용
[lambda, stream, optional]
수정 계약
목록 구조 수정 불가

원문을 따라 계산한 값입니다. 두 계획은 String 원소를 보관하며, List.copyOf가 매번 새 객체를 할당한다는 보장은 아닙니다.

이 예의 조건

  • List.copyOf가 매번 새 객체를 할당한다고 보장하지 않는다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다 원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다 DefensiveBoardPlanSnapshot의 main이 각 문장을 완료한 뒤 보관하는 목록 내용을 비교합니다. 외부 목록과 두 계획의 스냅샷 원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다 source에 external-change를 추가해도 original은 바뀌지 않습니다. addCategory는 optional이 든 updated를 만들고 original을 유지합니다. 원본 목록 변경과 새 계획 추가가 서로 다른 값에 남는다 목록 위치 main의 변경 이후 내용 수정 계약 외부 원본 source [lambda, stream, external-change] 호출자가 추가·삭제할 수 있음 기존 계획 original [lambda, stream] 목록 구조 수정 불가 새 계획 updated [lambda, stream, optional] 목록 구조 수정 불가 외부 원본 source main의 변경 이후 내용 [lambda, stream, external-change] 수정 계약 호출자가 추가·삭제할 수 있음 기존 계획 original main의 변경 이후 내용 [lambda, stream] 수정 계약 목록 구조 수정 불가 새 계획 updated main의 변경 이후 내용 [lambda, stream, optional] 수정 계약 목록 구조 수정 불가 원문을 따라 계산한 값입니다. 두 계획은 String 원소를 보관하며, List.copyOf가 매번 새 객체를 할당한다는 보장은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
계획 생성

source의 lambda/stream을 original 값에 보관합니다.

  • source: [lambda,stream]
  • original: [lambda,stream]
  • 계획은 외부 목록의 이후 구조 변경을 받지 않는다.
외부 목록 변경

source에 external-change를 추가합니다.

  • source: [lambda,stream,external-change]
  • original: [lambda,stream] 유지
  • 방어적 snapshot이 외부 추가 경로를 차단한다.
새 계획 추가

original.addCategory(optional)을 호출해 새 계획 값을 받습니다.

  • updated: [lambda,stream,optional]
  • original: [lambda,stream] 유지
  • 기존 값 변경 대신 다른 계획 값을 반환한다.

main을 한 문장씩 따라가면 세 목록이 서로 다른 내용을 갖게 됩니다. original을 만들 때 생성자가 source의 두 원소를 새 목록에 복사합니다. 그 뒤 source에 external-change를 넣어도 original은 복사해 둔 목록을 보고 있으므로 바뀌지 않습니다. addCategory("optional")은 기존 두 원소를 새 ArrayList에 옮겨 담고 세 번째 원소를 넣은 다음, 그 목록으로 새 BoardPlan을 만듭니다. 새 계획의 생성자가 다시 List.copyOf를 부르므로 updated도 수정할 수 없는 목록을 갖게 됩니다.

복사하지 않으면 어떻게 되는지도 확인해 보겠습니다. 생성자의 대입을 this.categories = categories;로 바꿔 실행하면 출력이 다음과 같이 바뀝니다.

복사하지 않았을 때의 실행 결과
original=[lambda, stream, external-change]
updated=[lambda, stream, external-change, optional]

original이 source와 같은 목록을 가리키기 때문에, 객체를 만든 뒤 바깥에서 한 추가가 그대로 보입니다. 필드가 final이어도 결과는 같습니다. updated는 이렇게 바뀐 목록을 바탕으로 만들어졌으니 external-change까지 물려받았습니다.

읽기 메서드 categories()가 내부 목록을 그대로 돌려줘도 되는 것은 그 목록이 List.copyOf가 만든 수정할 수 없는 목록이기 때문입니다. 받은 쪽이 add를 부르면 UnsupportedOperationException이 발생하는데, 연습 문제 1에서 직접 확인합니다. 내부에 ArrayList를 보관했다면 읽기 메서드에서도 사본이나 수정할 수 없는 목록을 만들어 돌려줘야 했을 것입니다.

하나 더 알아 둘 점은 List.copyOf가 null 원소를 받지 않는다는 것입니다. 목록에 null이 하나라도 있으면 NullPointerException을 던지므로, 원소가 null일 수 있는 데이터라면 미리 처리 방법을 정해야 합니다.

List.copyOf는 원소까지 복사하지 않는다

List.copyOf가 복사하는 것은 목록의 구조, 즉 어떤 원소가 어떤 순서로 들어 있는지입니다. 원소 객체 자체는 복사하지 않고 원래 목록과 같은 객체를 가리킵니다. 원소가 String처럼 불변이면 상관없지만, 가변 객체라면 스냅샷의 내용도 바뀌어 보입니다.

lab/ShallowSnapshotElements.java
import java.util.List;

public final class ShallowSnapshotElements {
    public static void main(String[] args) {
        StringBuilder note = new StringBuilder("lambda");
        List<StringBuilder> snapshot = List.copyOf(List.of(note));

        note.append("-edited");
        System.out.println("snapshot=" + snapshot);
    }
}
실행 결과
snapshot=[lambda-edited]

스냅샷 목록에 원소를 넣거나 뺄 수는 없지만, 그 안의 StringBuilder는 바깥의 note와 같은 객체이므로 append한 결과가 그대로 보입니다. List의 공식 문서도 수정할 수 없는 목록에 대해, 원소 자체가 가변이면 목록의 내용이 바뀌는 것처럼 보일 수 있다고 적고 있습니다. 그래서 스냅샷을 정말로 고정하려면 원소도 불변이어야 하고, 그럴 수 없다면 원소까지 복사하는 깊은 복사(deep copy)를 직접 해야 합니다. BoardPlan의 원소는 String이라서 목록만 복사해도 충분했습니다. 원소를 공유하는 문제는 수정할 수 없는 목록과 정렬 결과 다루기에서도 다뤘습니다.

더 깊이: List.copyOf가 같은 객체를 돌려줄 때

List.copyOf가 언제나 새 객체를 만드는 것은 아닙니다. 공식 문서의 구현 참고(Implementation Note)에는 인수가 이미 수정할 수 없는 목록이면 대개 복사하지 않는다고 적혀 있습니다. 실제로 JDK 21에서 List.of나 List.copyOf로 만든 목록을 다시 List.copyOf에 넘기면 같은 객체가 돌아오고, ArrayList를 넘기면 새 객체가 만들어집니다. 내용을 바꿀 방법이 없는 목록이라면 원본과 사본을 구별할 이유가 없으므로, 복사를 건너뛰어도 스냅샷의 의미는 그대로입니다.

이 동작 덕분에 생성자마다 List.copyOf를 부르는 습관은 생각보다 비용이 적습니다. 이미 스냅샷인 목록을 다른 불변 객체의 생성자에 넘길 때는 복사가 생략되기 때문입니다. 반대로 List.copyOf의 결과가 새 객체라는 데 기대어 ==로 비교하는 코드는 쓰지 말아야 합니다. 문서가 약속하는 것은 수정할 수 없고 원본의 이후 변경을 반영하지 않는다는 성질뿐이고, 새 객체를 할당할지는 구현이 정합니다.

목록을 고치지 말고 새 목록으로 변환하기

목록의 원소를 한꺼번에 바꿔야 할 때 원소 공유는 실제 버그로 이어집니다. 아래 코드는 참여자들의 레벨을 하나씩 올리는데, 바뀌기 전 상태와 비교하려고 반복문 전에 목록을 before로 복사해 둡니다. 원소인 Participant는 advance()로 자기 레벨을 직접 올리는 가변 객체입니다.

lab/MutableParticipantUpdate.java
import java.util.ArrayList;
import java.util.List;

public final class MutableParticipantUpdate {
    private static final class Participant {
        private final String name;
        private int level;

        private Participant(String name, int level) {
            this.name = name;
            this.level = level;
        }

        void advance() {
            level++;
        }

        @Override
        public String toString() {
            return name + ":" + level;
        }
    }

    public static void main(String[] args) {
        List<Participant> participants = List.of(new Participant("min", 1), new Participant("seo", 2));
        List<Participant> before = new ArrayList<>(participants);

        for (Participant participant : participants) {
            participant.advance();
        }

        System.out.println("before=" + before);
        System.out.println("after=" + participants);
    }
}
실행 결과
before=[min:2, seo:3]
after=[min:2, seo:3]

before에도 올라간 레벨이 찍혔습니다. new ArrayList<>(participants)는 목록을 새로 만들었지만, 두 목록이 담은 것은 같은 Participant 객체 두 개입니다. 반복문이 그 객체들의 레벨을 고쳤으니 어느 목록으로 보든 바뀐 값이 보입니다. 앞 절의 StringBuilder와 같은 일이 실제 코드에서 벌어진 것입니다.

원소를 불변 record로 바꾸면 이 문제가 생길 여지가 없어집니다. advance()는 자기 레벨을 고치는 대신 레벨이 하나 높은 새 Participant를 돌려주고, 스트림의 map이 원소마다 이 메서드를 불러 새 목록을 만듭니다.

src/ImmutableParticipantTransformation.java
import java.util.List;

public final class ImmutableParticipantTransformation {
    private record Participant(String name, int level) {
        private Participant {
            if (level < 1) {
                throw new IllegalArgumentException("level");
            }
        }

        Participant advance() {
            return new Participant(name, level + 1);
        }
    }

    public static void main(String[] args) {
        List<Participant> original = List.of(new Participant("min", 1), new Participant("seo", 2));
        List<Participant> advanced = original.stream().map(Participant::advance).toList();

        System.out.println("original=" + original);
        System.out.println("advanced=" + advanced);
    }
}
실행 결과
original=[Participant[name=min, level=1], Participant[name=seo, level=2]]
advanced=[Participant[name=min, level=2], Participant[name=seo, level=3]]

원본 목록이 그대로 남아 있어서 변환 전후를 나란히 비교할 수 있고, 비교용 사본을 미리 떠 둘 필요도 없습니다. 누구도 원본을 고칠 수 없기 때문입니다. Java 16부터 쓸 수 있는 Stream.toList()가 돌려주는 목록도 수정할 수 없으므로, 결과를 다른 코드에 넘겨도 그쪽에서 목록을 바꿀 걱정이 없습니다. 함수형 스타일에서 데이터를 변환한다는 말은 이렇게 원본은 두고 새 값을 만든다는 뜻입니다.

불변 객체는 여러 스레드가 락 없이 읽을 수 있다

불변 값의 이점이 가장 크게 드러나는 곳은 여러 스레드가 같은 데이터를 읽을 때입니다. 가시성·원자성·임계 영역에서 본 문제들은 한 스레드가 값을 바꾸는 동안 다른 스레드가 그 값을 읽어서 생겼습니다. 만들어진 뒤로 아무도 바꾸지 않는 객체라면 읽는 스레드가 몇 개든 서로 방해할 일이 없으므로, 내부 필드를 지키는 락이 필요 없습니다.

아래 코드는 다른 스레드에서 보고서를 계산하고, 결과를 CompletableFuture를 통해 메인 스레드로 넘깁니다. Report의 컴팩트 생성자는 매개변수 entries에 List.copyOf로 만든 사본을 다시 대입하는데, 컴팩트 생성자가 끝나면 매개변수의 마지막 값이 필드에 저장됩니다. 그래서 계산하는 쪽이 넘긴 목록을 나중에 고치더라도 보고서의 내용은 바뀌지 않습니다.

app/ImmutableBoardReportPublication.java
import java.util.List;
import java.util.concurrent.CompletableFuture;

public final class ImmutableBoardReportPublication {
    private record Entry(String category, int viewCount) {}

    private record Report(List<Entry> entries, int totalViewCount) {
        private Report {
            entries = List.copyOf(entries);
        }
    }

    static Report calculate(List<Entry> entries) {
        List<Entry> snapshot = List.copyOf(entries);
        int total = snapshot.stream().mapToInt(Entry::viewCount).sum();
        return new Report(snapshot, total);
    }

    public static void main(String[] args) {
        CompletableFuture<Report> published =
                CompletableFuture.supplyAsync(
                        () -> calculate(List.of(new Entry("record", 25), new Entry("copy", 35))));
        Report report = published.join();
        System.out.println("total=" + report.totalViewCount());
        System.out.println("entries=" + report.entries().size());
    }
}
실행 결과
total=60
entries=2

supplyAsync는 람다를 다른 스레드에서 실행하고, join()은 그 계산이 끝날 때까지 기다렸다가 결과를 돌려줍니다. 조회 수 25와 35를 더해 total=60이고, 항목이 두 개라서 entries=2입니다. 메인 스레드는 보고서를 읽기만 하고, 보고서는 만들어진 뒤로 바뀔 수 없으니 읽는 동안 락을 잡을 이유가 없습니다.

여기서 구별해야 할 것이 두 가지 있습니다. 하나는 객체의 내용이 바뀌지 않는다는 것이고, 다른 하나는 다른 스레드가 그 객체의 참조를 언제 보게 되느냐입니다. 불변은 첫째를 해결하지만 둘째까지 해결하지는 않습니다.

예를 들어 새 보고서를 평범한 static 필드에 대입해 두고 다른 스레드가 읽게 하면, 읽는 쪽은 새 참조를 늦게 보거나 계속 예전 참조를 볼 수 있습니다. 그래서 참조를 넘길 때는 이 예제의 CompletableFuture처럼, 다른 스레드에 보이는 시점을 정해 주는 통로를 씁니다. BlockingQueue나 volatile 필드도 그런 통로입니다.

setter를 모두 없애면 스레드 안전한 불변 객체가 되나요?

아닙니다. setter가 없어도 다음과 같은 경우에는 문제가 남습니다.

  • 생성자가 받은 가변 목록을 복사하지 않고 보관하면 바깥에서 여전히 내용을 바꿀 수 있습니다. 앞에서 this.categories = categories;로 실행해 본 경우입니다.
  • 생성자가 끝나기 전에 this를 다른 스레드가 볼 수 있는 곳에 넘기면, 그 스레드가 덜 만들어진 객체를 볼 수 있습니다.
  • 참조를 넘기는 통로가 안전하지 않으면 읽는 스레드가 새 객체를 늦게 보거나 끝내 보지 못할 수 있습니다.

불변 객체라고 부르려면 생성자에서 모든 규칙을 검사하고, 바깥에서 닿을 수 있는 내부 상태가 하나도 바뀌지 않아야 합니다. 그리고 그 객체를 다른 스레드에 넘길 때는 안전한 통로를 거칩니다.

더 깊이: final 필드가 주는 초기화 보장

Java 언어 명세의 17.5절은 final 필드에 특별한 보장을 둡니다. 생성자가 끝난 뒤에야 객체의 참조를 보게 된 스레드는, 별도의 동기화 없이도 그 객체의 final 필드가 올바르게 초기화된 값을 봅니다. final 필드가 가리키는 객체나 배열도 적어도 그 필드만큼은 최신인 상태로 보입니다. record의 필드는 모두 final이고 Report가 담은 목록은 생성자가 끝나기 전에 이미 다 만들어졌으며 그 뒤로 바뀌지 않으므로, Report는 이 보장을 그대로 받습니다.

다만 이 보장에는 조건이 있습니다. 생성자가 끝나기 전에 this를 다른 스레드가 볼 수 있는 곳에 써 두면 안 됩니다. 또 이 보장은 참조를 보게 되면 final 필드의 내용이 올바르다는 것이지, 새로 대입한 참조를 다른 스레드가 언제 보게 되는지까지 정하지는 않습니다. 앞에서 불변이 참조를 넘기는 통로까지 해결하지는 않는다고 한 것이 이 뜻입니다.

불변이 맞지 않는 곳과 함께 쓰는 방법

모든 것을 불변으로 만들 필요는 없습니다. 값이 바뀔 때마다 새 객체를 만드는 데는 할당 비용이 들고, 큰 목록의 원소 하나를 바꿀 때마다 목록 전체를 복사하는 일을 반복문 안에서 되풀이하면 그 비용이 금방 커집니다. 이럴 때는 메서드 안에서는 ArrayList나 StringBuilder 같은 가변 객체로 결과를 만들고, 메서드를 빠져나갈 때 불변 스냅샷으로 바꿔 돌려줍니다. 앞의 addCategory가 바로 이 모양입니다. 가변 상태가 메서드 안에만 머물면 호출하는 쪽에서는 그 메서드를 불변 값을 주고받는 함수로 볼 수 있는데, Java 프로그래밍 패러다임에서 지역 변수만 바꾸는 반복문을 바깥에서 보기에 예측 가능한 계산으로 본 것과 같은 생각입니다.

프레임워크가 가변 객체를 요구하는 경우도 있습니다. 데이터베이스 매핑 프레임워크의 엔티티나 화면 모델처럼 프레임워크가 객체를 만든 뒤 필드를 채우거나 바꾸는 구조라면 억지로 불변으로 만들 수 없습니다. 이때는 그 객체를 고치는 코드를 한 곳에 모으고, 바깥에는 필요한 내용만 불변 스냅샷으로 떠서 내보냅니다.

수정할 수 없는 목록을 만드는 방법이 하나 더 있는데, 스냅샷과는 다르게 동작하므로 구별해야 합니다. Collections.unmodifiableList는 원본 목록을 감싼 읽기 전용 뷰(view)를 돌려줍니다. 아래 코드는 같은 원본으로 뷰와 스냅샷을 하나씩 만든 뒤 원본에 원소를 추가합니다.

lab/UnmodifiableViewVsCopy.java
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public final class UnmodifiableViewVsCopy {
    public static void main(String[] args) {
        List<String> source = new ArrayList<>(List.of("lambda", "stream"));
        List<String> view = Collections.unmodifiableList(source);
        List<String> copy = List.copyOf(source);

        source.add("optional");

        System.out.println("view=" + view);
        System.out.println("copy=" + copy);
    }
}
실행 결과
view=[lambda, stream, optional]
copy=[lambda, stream]

뷰를 통해서는 원소를 넣을 수 없지만, 원본이 바뀌면 그 변화가 뷰에 그대로 보입니다. 반면 List.copyOf로 만든 스냅샷은 만든 시점의 내용을 유지합니다. 객체 안의 목록을 실시간으로 보여 주되 바깥에서 고치지만 못하게 하려면 뷰가 맞고, 어느 시점의 내용을 고정해서 넘겨야 한다면 List.copyOf가 맞습니다.

정리

  • 불변 객체는 만들어진 뒤 바깥에서 볼 수 있는 상태가 바뀌지 않습니다. 내용을 바꿔야 하면 새 객체를 만들고 기존 객체는 그대로 둡니다.
  • HashMap의 키나 HashSet의 원소를 넣은 뒤 equals·hashCode에 쓰이는 값을 바꾸면, 항목이 남아 있는데도 찾을 수 없게 됩니다. 키는 record 같은 불변 값으로 만들고, 이름이 바뀌면 새 키로 항목을 옮깁니다.
  • final은 필드의 재대입만 막습니다. 생성자에서 받은 목록은 List.copyOf로 스냅샷을 떠야 바깥의 변경을 막을 수 있고, 원소까지 고정하려면 원소도 불변이어야 합니다.
  • 목록을 변환할 때는 원소를 고치지 말고 map으로 새 값을 만들어 새 목록에 담습니다. 원본이 남아 있으므로 전후를 비교하기 쉽습니다.
  • 불변 객체는 여러 스레드가 락 없이 읽을 수 있습니다. 다만 참조를 다른 스레드에 넘기는 통로는 CompletableFuture, BlockingQueue, volatile 필드처럼 안전한 것을 씁니다.
  • Collections.unmodifiableList는 원본의 변화가 보이는 뷰이고, List.copyOf는 만든 시점에 고정된 스냅샷입니다.

연습 문제

  1. 계획 이름이 java이고 분류가 lambda, stream, optional 순서인 계획이 있습니다. 원본을 바꾸지 않고 지정한 인덱스의 분류를 교체한 새 계획을 돌려주는 replace(int index, String category)를 만드세요. 인덱스는 0부터 세고, 범위를 벗어나면 예외를 던지며, 결과 계획의 목록은 수정할 수 없어야 합니다. 인덱스 1을 collector로 교체한 뒤 두 계획의 목록을 예상하고, 같은 원본에서 인덱스 3을 교체하려 하면 원본이 어떻게 되는지도 생각해 보세요.
  2. 본문의 가변 PostKey를 HashSet에 넣은 뒤 이름을 spring으로 바꿨습니다. 이어서 contains(key), remove(key), add(key)를 차례로 호출하면 각각 무엇을 돌려줄까요? 마지막에 집합의 크기와 내용이 어떻게 되는지도 실행하기 전에 예상해 보세요.
1번 해설 보기
exercise/ImmutableCategoryReplacementSolution.java
import java.util.ArrayList;
import java.util.List;

public final class ImmutableCategoryReplacementSolution {
    private record BoardPlan(String title, List<String> categories) {
        private BoardPlan {
            categories = List.copyOf(categories);
        }

        BoardPlan replace(int index, String category) {
            if (index < 0 || index >= categories.size()) {
                throw new IndexOutOfBoundsException(index);
            }
            ArrayList<String> copy = new ArrayList<>(categories);
            copy.set(index, category);
            return new BoardPlan(title, copy);
        }
    }

    public static void main(String[] args) {
        BoardPlan original = new BoardPlan("java", List.of("lambda", "stream", "optional"));
        BoardPlan updated = original.replace(1, "collector");
        System.out.println("original=" + original.categories());
        System.out.println("updated=" + updated.categories());

        try {
            updated.categories().add("record");
        } catch (UnsupportedOperationException e) {
            System.out.println("add: " + e.getClass().getSimpleName());
        }
        try {
            original.replace(3, "record");
        } catch (IndexOutOfBoundsException e) {
            System.out.println("replace(3): " + e.getMessage());
        }
        System.out.println("original=" + original.categories());
    }
}
실행 결과
original=[lambda, stream, optional]
updated=[lambda, collector, optional]
add: UnsupportedOperationException
replace(3): Index out of range: 3
original=[lambda, stream, optional]

original의 두 번째 값은 stream으로 남고, updated에서만 collector로 바뀝니다. 두 목록의 크기는 모두 3입니다. replace는 수정할 수 없는 categories를 직접 고칠 수 없으므로 ArrayList로 복사해 교체한 뒤 새 BoardPlan을 만들고, 그 생성자가 다시 List.copyOf로 스냅샷을 뜹니다. 그래서 셋째 줄처럼 updated.categories()에 add를 부르면 UnsupportedOperationException으로 거절됩니다.

인덱스 3은 index >= categories.size() 검사에 걸려 예외가 나고, Index out of range: 3은 IndexOutOfBoundsException(int) 생성자가 만든 메시지입니다. 마지막 줄이 보여 주듯 원본은 그대로입니다. 사실 이 검사가 없어도 원본은 바뀌지 않습니다. 범위를 벗어난 인덱스로 set을 부르다 예외가 나는 곳은 원본이 아니라 임시 사본이기 때문입니다.

이처럼 불변 값을 쓰면 작업이 중간에 실패해도 원본이 반쯤 바뀐 채로 남는 일이 없습니다. 참고로 인덱스가 유효해도 새 분류로 null을 넘기면, 새 계획을 만드는 List.copyOf가 NullPointerException을 던집니다.

2번 해설 보기
exercise/MutableKeyInHashSet.java
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public final class MutableKeyInHashSet {
    private static final class PostKey {
        private String category;

        private PostKey(String category) {
            this.category = category;
        }

        void rename(String category) {
            this.category = category;
        }

        @Override
        public boolean equals(Object object) {
            return object instanceof PostKey other && Objects.equals(category, other.category);
        }

        @Override
        public int hashCode() {
            return Objects.hash(category);
        }

        @Override
        public String toString() {
            return category;
        }
    }

    public static void main(String[] args) {
        Set<PostKey> keys = new HashSet<>();
        PostKey key = new PostKey("java");
        keys.add(key);
        key.rename("spring");

        System.out.println("contains=" + keys.contains(key));
        System.out.println("remove=" + keys.remove(key));
        System.out.println("add=" + keys.add(key));
        System.out.println("keys=" + keys + ", size=" + keys.size());
    }
}
실행 결과
contains=false
remove=false
add=true
keys=[spring, spring], size=2

contains와 remove는 본문의 get과 같은 이유로 실패합니다. 키의 지금 해시 값으로 찾아가면 저장할 때 기억해 둔 해시 값과 맞지 않기 때문입니다. add도 같은 이유로 아직 없는 원소라고 판단해 항목을 하나 더 만들고 true를 돌려줍니다. 결국 집합에는 같은 객체를 가리키는 항목이 두 개 생기고, 둘 다 지금 이름인 spring으로 출력됩니다. 같은 원소를 두 번 담지 않는다는 Set의 기본 성질마저 깨진 것입니다.

다음 글

일급 함수와 함수 합성에서는 값뿐 아니라 동작도 값처럼 다룹니다. 규칙을 담은 함수를 변수에 넣어 메서드에 넘기는 방법과, 작은 함수를 이어 붙여 새 함수를 만들 때 순서가 왜 중요한지 살펴봅니다.