본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
35장 : 함수형 프로그래밍

불변 값과 컬렉션 스냅샷

가변 키 조회 실패를 재현하고 `final`·방어적 복사·`record`·with 메서드로 Study Log 상태를 안전한 새 값으로 전환합니다.

불변성은 데이터를 절대 복제하지 않는 기술이 아니라 생성 이후 관찰 가능한 값을 바꾸지 않는 설계입니다. 변경이 필요하면 새 값을 만들고 기존 값은 그대로 둡니다. 그러면 과거 스냅샷, 캐시 키, 여러 스레드에 전달한 값의 의미가 안정됩니다. Java의 final 필드만으로는 충분하지 않으며 필드가 가리키는 컬렉션과 원소까지 소유권을 확인해야 합니다.

HashMap 키의 삽입 후 변경 위험

HashMap은 키의 해시 코드로 저장 위치를 선택합니다. 아래의 가변 키는 삽입한 뒤 트랙이 바뀌면서 해시 코드도 달라집니다. 같은 객체를 get에 넣어도 원래 위치를 찾지 못해 출력은 wrong-found=false입니다.

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

public final class MutableMapKeyLookupBug {
    private static final class StudyKey {
        private String track;

        private StudyKey(String track) {
            this.track = track;
        }

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

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

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

    public static void main(String[] args) {
        StudyKey key = new StudyKey("java");
        Map<StudyKey, Integer> minutes = new HashMap<>();
        minutes.put(key, 60);
        key.rename("spring");
        System.out.println("wrong-found=" + (minutes.get(key) != null));
    }
}

원칙은 Map의 키와 Set의 원소에서 동등성·해시 계산에 사용하는 상태를 불변으로 유지하는 것입니다. 이름 변경이 도메인 연산이라면 새 키를 만들고 Map 항목을 명시적으로 이동합니다. 가변 객체를 캐시 키로 사용하면 조회뿐 아니라 제거와 중복 검사도 깨집니다.

record 기반 불변 값 모델링

record 구성 요소는 final이고 값 동등성이 자동으로 제공됩니다. 컴팩트 생성자에서 불변식을 검사하면 StudySession이 잘못된 상태로 생성되지 않습니다. 완료 처리도 필드를 바꾸지 않고 새 record를 반환합니다.

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

public final class ImmutableStudySessionRecord {
    private record StudySession(String topic, int minutes, LocalDate date, boolean completed) {
        private StudySession {
            if (topic == null || topic.isBlank()) {
                throw new IllegalArgumentException("topic");
            }
            if (minutes <= 0) {
                throw new IllegalArgumentException("minutes");
            }
        }

        StudySession complete() {
            if (completed) {
                return this;
            }
            return new StudySession(topic, minutes, date, true);
        }

        StudySession withMinutes(int newMinutes) {
            return new StudySession(topic, newMinutes, date, completed);
        }
    }

    public static void main(String[] args) {
        StudySession draft = new StudySession("immutability", 40, LocalDate.of(2026, 7, 14), false);
        StudySession completed = draft.complete().withMinutes(50);
        System.out.println("draft=" + draft);
        System.out.println("completed=" + completed);
    }
}

complete()의 멱등 경로는 이미 완료된 값에 this를 반환해 불필요한 할당을 피합니다. 식별 정보가 필요한 엔터티라면 record만으로 해결하려 하지 말고 식별자와 생명 주기를 별도 집계에서 관리할 수 있습니다.

final 참조와 불변 객체의 차이

final List<String> tags는 필드가 다른 목록을 가리키지 못하게 할 뿐 호출자가 넘긴 ArrayList 변경까지 막지 않습니다. 생성자에서 List.copyOf로 스냅샷을 만들고 접근자에서도 가변 내부 컬렉션을 노출하지 않습니다.

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

public final class DefensiveStudyPlanSnapshot {
    private static final class StudyPlan {
        private final String title;
        private final List<String> topics;

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

        String title() {
            return title;
        }

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

        StudyPlan addTopic(String topic) {
            ArrayList<String> updated = new ArrayList<>(topics);
            updated.add(topic);
            return new StudyPlan(title, updated);
        }
    }

    public static void main(String[] args) {
        ArrayList<String> source = new ArrayList<>(List.of("lambda", "stream"));
        StudyPlan original = new StudyPlan("functional", source);
        source.add("external-change");
        StudyPlan updated = original.addTopic("optional");
        System.out.println("original=" + original.topics());
        System.out.println("updated=" + updated.topics());
    }
}

List.copyOf는 원소 자체를 깊게 복사하지 않습니다. 원소가 가변이면 컬렉션 스냅샷 안에서도 값이 바뀔 수 있습니다. 원소를 불변 값으로 만들거나 소유권 이전과 깊은 복사 정책을 명시합니다.

list 변환의 원본과 새 결과 분리

가변 참여자의 수준을 반복문에서 올리면 원본 목록과 다른 참조가 같은 참여자를 볼 때 상태가 함께 바뀝니다. 불변 Participantadvance로 새 값을 만들므로 변환 전후를 동시에 비교할 수 있습니다.

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);
    }
}

불변 스냅샷의 동시 읽기 단순화

완성된 불변 보고서를 여러 스레드에 전달하면 내부 필드를 보호하는 락이 필요 없습니다. 공개 자체는 스레드 안전한 큐, volatile 참조, Future 완료 같은 happens-before 경계를 통해 수행해야 합니다. 불변성은 안전한 공개를 대신하지 않지만 공개 뒤에 값이 바뀔 가능성을 없앱니다.

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

public final class ImmutableStudyReportPublication {
    private record Entry(String topic, int minutes) {}

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

    static Report calculate(List<Entry> entries) {
        List<Entry> snapshot = List.copyOf(entries);
        int total = snapshot.stream().mapToInt(Entry::minutes).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.totalMinutes());
        System.out.println("entries=" + report.entries().size());
    }
}

불변성을 억지로 적용하지 않는 기준

  • 도메인 값, 명령 입력, 이벤트, 보고서 스냅샷은 불변성이 특히 유리합니다.
  • 대형 버퍼를 성능에 민감한 반복문에서 원소마다 복사하면 할당과 GC 비용이 커집니다. 메서드 내부에서는 가변 빌더로 만들고 경계에서 불변 스냅샷을 반환할 수 있습니다.
  • ORM 엔터티나 UI 모델처럼 프레임워크가 가변 생명 주기를 요구하면 변경을 한 소유자 안에 가두고 외부에는 스냅샷을 노출합니다.
  • Collections.unmodifiableList는 뷰일 수 있고 원본이 바뀌면 보이는 내용도 바뀝니다. 독립 스냅샷은 List.copyOf를 사용합니다.
  • 깊은 객체 그래프를 복사할 때는 비용과 객체 식별성의 의미를 검토합니다.
모든 설정자 메서드를 없애면 스레드 안전한가요?

아닙니다. 가변 컬렉션을 그대로 보관하거나 생성 중에 this가 노출되거나 안전하지 않은 방식으로 공개하면 문제가 남습니다. 불변 객체는 생성 시 전체 불변식을 만족하고 도달 가능한 내부 상태도 바뀌지 않으며, 안전한 공개 경계를 통해 공유돼야 합니다.

연습 문제

원본 계획을 바꾸지 않고 지정한 인덱스의 주제를 교체한 새 계획을 반환하세요. 범위를 벗어나면 예외를 내고 결과 목록은 수정할 수 없어야 합니다.

해설 보기
exercise/ImmutableTopicReplacementSolution.java
import java.util.ArrayList;
import java.util.List;

public final class ImmutableTopicReplacementSolution {
    private record StudyPlan(String title, List<String> topics) {
        private StudyPlan {
            topics = List.copyOf(topics);
        }

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

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

종료 기준은 original의 두 번째 값은 스트림으로 남고 updated의 값만 수집기로 바뀌는 것입니다. updated.topics().add는 지원되지 않으므로 스냅샷을 외부에서 변경할 수 없습니다.