record 심화와 불변 스냅샷
ch5에서 배운 record 기초를 바탕으로 가변 구성 요소의 얕은 불변성 문제를 방어적 복사로 해결합니다.
ch5에서는 record StudyEntry(String topic, int minutes)가 생성자·읽기 메서드·값 기반 객체 계약을 제공한다는 기초를 배웠습니다.
record는 Java 16에서 정식 도입됐고 Java 25에서도 프리뷰 플래그 없이 사용하는 안정 기능입니다.
이 절에서는 기초 문법을 반복하지 않고 구성 요소가 가변 객체일 때도 안전한 값 스냅샷을 만드는 법으로 범위를 넓힙니다.
가변 구성 요소의 직접 보관 위험
record 구성 요소 참조는 final이지만 가리키는 ArrayList는 바뀔 수 있습니다.
아래 record는 생성 뒤 원본 변경을 그대로 보아 출력이 wrong-tag-count=2가 됩니다.
import java.util.ArrayList;
import java.util.List;
public final class ShallowRecordImmutabilityBug {
private record StudyNote(String title, List<String> tags) {}
public static void main(String[] args) {
ArrayList<String> tags = new ArrayList<>(List.of("java"));
StudyNote note = new StudyNote("record", tags);
tags.add("external-change");
System.out.println("wrong-tag-count=" + note.tags().size());
}
}원칙은 record 생성자에서 가변 입력의 소유권을 확정하는 것입니다.
독립적인 불변 스냅샷이 목적이라면 List.copyOf를 사용합니다.
원소 자체가 가변이라면 원소까지 불변 값으로 바꾸거나 깊은 복사 정책을 추가합니다.
컴팩트 생성자와 불변식
컴팩트 생성자는 매개변수 목록을 반복하지 않고 입력 검사와 정규화 로직을 작성합니다. 본문이 끝나면 컴파일러가 구성 요소 필드를 할당하므로 매개변수를 정규화된 값으로 바꿀 수 있습니다.
import java.util.List;
public final class ValidatedStudyNoteRecord {
private record StudyNote(String title, int minutes, List<String> tags) {
private StudyNote {
title = title == null ? "" : title.trim();
if (title.isEmpty()) {
throw new IllegalArgumentException("title");
}
if (minutes <= 0) {
throw new IllegalArgumentException("minutes");
}
tags = List.copyOf(tags);
}
StudyNote withMinutes(int newMinutes) {
return new StudyNote(title, newMinutes, tags);
}
}
public static void main(String[] args) {
StudyNote original = new StudyNote(" record ", 30, List.of("java", "value"));
StudyNote extended = original.withMinutes(45);
System.out.println("original=" + original);
System.out.println("extended=" + extended);
}
}record가 자동으로 모든 불변식을 보장하는 것은 아닙니다.
컴팩트 생성자를 비워 두면 null과 잘못된 숫자도 저장됩니다.
생성 가능한 모든 경로가 같은 입력 검사를 통과하도록 정규 생성자를 중심에 둡니다.
값 동등성의 키·비교 활용
record의 동등성은 같은 record 타입과 모든 구성 요소 값이 같은지 비교합니다.
캐시 키, 명령, DTO, 이벤트, 계산 결과에 적합합니다.
배열 구성 요소는 배열 참조 동등성을 사용하므로 값 동등성이 필요하면 List나 사용자 정의 동등성 구현을 검토합니다.
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
public final class RecordValueEquality {
private record DailyKey(long userId, LocalDate date) {}
public static void main(String[] args) {
DailyKey stored = new DailyKey(7, LocalDate.of(2026, 7, 14));
DailyKey lookup = new DailyKey(7, LocalDate.of(2026, 7, 14));
Map<DailyKey, Integer> minutes = new HashMap<>();
minutes.put(stored, 60);
System.out.println("equal=" + stored.equals(lookup));
System.out.println("minutes=" + minutes.get(lookup));
}
}데이터베이스 엔터티처럼 영속 식별자와 가변 생명 주기가 핵심이라면 record를 무조건 사용하지 않습니다.
엔터티에서 외부로 내보내는 불변 투영 결과나 API 응답에는 record가 잘 맞습니다.
지역 record로 표현한 중간값
record는 최상위·중첩 타입뿐 아니라 지역 타입으로도 선언할 수 있습니다.
Stream에서 여러 값의 관계를 보존할 때 Map.Entry나 임시 배열 대신 의미 있는 지역 record를 사용합니다.
import java.util.Comparator;
import java.util.List;
public final class LocalRecordAggregation {
private record Session(String topic, int minutes) {}
static String longestTopic(List<Session> sessions) {
record Ranked(String topic, int minutes) {}
return sessions.stream()
.map(session -> new Ranked(session.topic(), session.minutes()))
.max(Comparator.comparingInt(Ranked::minutes))
.map(Ranked::topic)
.orElse("none");
}
public static void main(String[] args) {
List<Session> sessions =
List.of(
new Session("record", 35),
new Session("sealed", 50),
new Session("module", 40));
System.out.println("longest=" + longestTopic(sessions));
}
}record의 제약과 선택 기준
record는 다른 클래스를 상속할 수 없지만 인터페이스는 구현할 수 있습니다.
구성 요소 필드 외에 인스턴스 필드를 추가할 수 없고 구성 요소의 상태가 객체 전체를 설명해야 합니다.
메서드와 static 멤버는 추가할 수 있습니다.
- 불변 명령·조회 결과·이벤트·값 객체에 우선 검토합니다.
- 생성 이후 필드가 바뀌어야 하는 생명 주기 객체에는 일반 클래스가 낫습니다.
- 구성 요소가 많아지면 도메인 타입으로 묶어 생성자 의미를 줄입니다.
- 직렬화 스키마로 공개하면 구성 요소 추가·이름 변경의 호환성을 따집니다.
- 생성된 접근자 이름은
title()이며 JavaBeangetTitle()이 필요한 프레임워크와 호환성을 확인합니다.
record를 사용하면 자동으로 깊은 불변인가요?
아닙니다. 구성 요소 참조의 재할당만 막힙니다. 가변 컬렉션, 배열, 가변 도메인 객체를 구성 요소로 두면 내부 내용은 변할 수 있습니다. 생성 시 스냅샷을 만들고 접근자가 가변 상태를 노출하지 않게 해야 합니다.
연습 문제
주차와 일별 학습 시간 목록을 가진 record를 만들고 total을 계산하세요.
호출자가 원본 목록을 변경해도 스냅샷에는 반영되지 않아야 합니다.
해설 보기
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public final class WeeklyStudySnapshotSolution {
private record DailyStudy(LocalDate date, int minutes) {}
private record WeeklySnapshot(LocalDate weekStart, List<DailyStudy> days) {
private WeeklySnapshot {
days = List.copyOf(days);
}
int totalMinutes() {
return days.stream().mapToInt(DailyStudy::minutes).sum();
}
}
public static void main(String[] args) {
ArrayList<DailyStudy> source = new ArrayList<>();
source.add(new DailyStudy(LocalDate.of(2026, 7, 13), 40));
source.add(new DailyStudy(LocalDate.of(2026, 7, 14), 50));
WeeklySnapshot snapshot = new WeeklySnapshot(LocalDate.of(2026, 7, 13), source);
source.clear();
System.out.println("days=" + snapshot.days().size());
System.out.println("total=" + snapshot.totalMinutes());
}
}종료 기준은 원본을 clear한 뒤에도 일수는 2, total은 90인 것입니다.
DailyStudy도 record이므로 도달 가능한 원소의 상태까지 고정됩니다.