본문 바로가기

record 심화와 불변 스냅샷

record가 가변 목록이나 배열을 담을 때 생기는 문제를 방어적 복사로 막고, 컴팩트 생성자로 값을 검사하며, 값 동등성과 지역 record를 언제 쓰는지 배웁니다.

캡슐화로 회원 저장소 완성하기에서 Member를 record로 선언하면서, 구성 요소 목록만 적으면 컴파일러가 private final 필드와 읽기 메서드, 정식 생성자, equals·hashCode·toString을 만들어 준다는 것을 보았습니다. 그 Member는 바꿀 수 없는 String과 기본형 int만 가졌기 때문에 한번 만든 객체의 내용이 바뀔 길이 없었습니다. 그런데 게시글의 태그 목록이나 점수 배열처럼 구성 요소가 가변 객체라면 이야기가 달라집니다. 필드가 final이어도 그 필드가 가리키는 목록은 얼마든지 바뀔 수 있기 때문입니다.

이 글에서는 먼저 이 문제가 실제로 어떻게 드러나는지 재현하고, 컴팩트 생성자에서 방어적 복사로 막는 방법을 배웁니다. 이어서 같은 생성자에서 값을 검사하고 정리하는 방법, 값으로 비교되는 record를 맵의 키로 쓰는 방법, 메서드 안에서만 쓰는 지역 record를 차례로 살펴봅니다. record는 Java 16에서 정식 기능이 되었으므로(JEP 395) 이 교재의 기준인 JDK 25에서는 별도 옵션 없이 씁니다.

record의 final은 목록 안까지 지켜 주지 않습니다

다음 프로그램의 BoardNote는 제목과 태그 목록을 담는 record입니다. main은 태그 목록을 만들어 record에 넘긴 뒤 두 가지 방법으로 그 목록을 건드립니다. 하나는 record를 만든 쪽이 자기가 넘겨준 목록에 태그를 더하는 것이고, 다른 하나는 record를 받은 쪽이 읽기 메서드 tags()로 꺼낸 목록에 태그를 더하는 것입니다.

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

public final class ShallowRecordImmutabilityBug {
    private record BoardNote(String title, List<String> tags) {}

    public static void main(String[] args) {
        ArrayList<String> tags = new ArrayList<>(List.of("java"));
        BoardNote note = new BoardNote("record", tags);

        tags.add("external-change");        // 만든 쪽이 넘겨준 목록을 계속 바꾼다
        note.tags().add("accessor-change"); // 꺼낸 쪽이 읽기 메서드의 결과를 바꾼다

        System.out.println("tags=" + note.tags());
    }
}

main은 두 변경을 모두 마친 뒤에 note.tags()를 한 번만 찍습니다. 두 변경 가운데 어느 쪽이 note 안에 나타나는지 보세요.

tags=[java, external-change, accessor-change]

record를 만든 뒤에 일어난 두 변경이 모두 note 안의 태그 목록에 나타났습니다. 생성자가 목록을 복사하지 않고 참조값만 필드에 저장했기 때문입니다. 아래 표처럼 main의 지역 변수 tags와 note의 필드, note.tags()가 돌려준 값은 모두 힙에 있는 같은 ArrayList 하나를 가리킵니다. 이 목록을 L이라고 부르겠습니다.

단계목록 L의 내용L을 가리키는 참조
new BoardNote("record", tags) 직후[java]main의 tags, note의 필드
tags.add("external-change") 뒤[java, external-change]위와 같음
note.tags().add("accessor-change") 뒤[java, external-change, accessor-change]위와 같음, tags()가 돌려준 값도 L

record 구성 요소의 final이 막는 것은 필드에 다른 목록을 다시 대입하는 일뿐입니다. 필드가 가리키는 목록의 내용을 바꾸는 add는 final과 상관없이 실행됩니다. final 참조와 객체 변경에서 final 참조 변수로 확인한 것과 같은 규칙입니다. 이처럼 객체 자신의 필드는 바뀌지 않지만 필드가 가리키는 객체는 바뀔 수 있는 상태를 얕은 불변(shallow immutability)이라고 부릅니다.

정리하면 문제가 된 경로는 두 가지입니다. 넘겨준 쪽이 원본을 계속 쥐고 있는 입력 경로와, 읽기 메서드가 내부 목록을 그대로 내주는 출력 경로입니다. 둘 다 막아야 record가 만들어진 순간의 값을 그대로 간직하는 스냅샷(snapshot)이 됩니다.

컴팩트 생성자에서 수정할 수 없는 사본 만들기

두 경로를 한 번에 막는 방법은 생성자에서 목록을 List.copyOf로 복사하는 것입니다. 다음 프로그램은 앞 예제의 record에 컴팩트 생성자를 더했고, main도 같은 두 변경을 시도합니다. 다만 두 번째 변경은 예외가 날 것을 예상하고 try로 감쌌습니다.

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

public final class DefensiveCopyRecord {
    private record BoardNote(String title, List<String> tags) {
        private BoardNote {
            tags = List.copyOf(tags); // 받는 순간 수정할 수 없는 사본으로 바꾼다
        }
    }

    public static void main(String[] args) {
        ArrayList<String> tags = new ArrayList<>(List.of("java"));
        BoardNote note = new BoardNote("record", tags);

        tags.add("external-change");
        System.out.println("tags=" + note.tags());

        try {
            note.tags().add("accessor-change");
        } catch (UnsupportedOperationException e) {
            System.out.println("accessor-change rejected: " + e.getClass().getSimpleName());
        }
    }
}

첫 줄은 만든 쪽이 원본 목록을 바꾼 뒤의 note.tags()입니다. 둘째 줄은 읽기 메서드가 돌려준 목록에 태그를 더하려 했을 때 catch가 잡은 예외의 이름입니다.

tags=[java]
accessor-change rejected: UnsupportedOperationException

컴팩트 생성자(compact constructor)는 캡슐화로 회원 저장소 완성하기에서 본 것처럼 매개변수 목록을 다시 적지 않는 생성자입니다. 본문 안의 tags는 필드가 아니라 생성자의 매개변수이고, 본문이 정상적으로 끝나면 컴파일러가 그 시점의 매개변수 값을 필드에 대입합니다. 그래서 tags = List.copyOf(tags);처럼 매개변수에 사본을 다시 대입해 두면 필드에는 원본이 아닌 사본이 저장됩니다.

출력의 두 줄은 두 경로가 모두 막혔음을 보여 줍니다. 첫 줄에 external-change가 없는 것은 List.copyOf가 원본과 연결이 끊긴 새 목록을 만들었기 때문입니다. 둘째 줄의 UnsupportedOperationException은 그 사본이 수정할 수 없는 목록(unmodifiable list)이라서 add를 거부했다는 뜻입니다. 읽기 메서드는 여전히 필드를 그대로 돌려주지만, 그 목록은 아무도 바꿀 수 없으니 내줄 때 다시 복사할 필요가 없습니다. Set과 Map 구성 요소도 같은 방식으로 Set.copyOf와 Map.copyOf를 쓰면 됩니다.

List.copyOf에는 알아 둘 성질이 두 가지 있습니다. 첫째, 목록 자체나 원소 가운데 null이 있으면 NullPointerException을 던집니다. 수정할 수 없는 목록과 정렬 결과 다루기에서 본 것처럼 null 원소를 받지 않도록 설계된 메서드이므로, null 원소를 허용해야 한다면 Collections.unmodifiableList(new ArrayList<>(tags))처럼 복사와 감싸기를 따로 해야 합니다. 둘째, 복사하는 것은 목록의 구조뿐입니다. 원소가 String처럼 불변이면 이것으로 충분하지만, 원소가 상태를 바꾸는 메서드를 가진 객체라면 그 원소는 원본 목록과 여전히 공유됩니다. 그럴 때는 원소도 record 같은 불변 타입으로 바꾸거나 원소까지 하나씩 복사해야 합니다.

배열 구성 요소는 읽기 메서드에서도 복사해야 합니다

배열에는 '수정할 수 없는 배열'이 없습니다. 그래서 생성자에서 복사하는 것만으로는 출력 경로가 막히지 않고, 읽기 메서드를 직접 선언해 내줄 때도 복사해야 합니다. final 참조와 객체 변경에서 본 Team record에 이 방법을 적용하면 다음과 같습니다.

src/ArrayComponentRecord.java
import java.util.Arrays;

public final class ArrayComponentRecord {
    private record Team(String name, int[] scores) {
        private Team {
            scores = scores.clone(); // 받을 때 복사
        }

        @Override
        public int[] scores() {
            return scores.clone(); // 내줄 때 복사
        }
    }

    public static void main(String[] args) {
        int[] input = {90, 80};
        Team team = new Team("java", input);
        input[0] = 0;
        team.scores()[1] = 0;
        System.out.println("scores=" + Arrays.toString(team.scores()));

        Team copy = new Team(team.name(), team.scores());
        System.out.println("equals=" + team.equals(copy));
    }
}

main은 생성자에 넘긴 배열과 scores()가 돌려준 배열을 각각 바꾼 뒤 점수를 찍습니다. 둘째 줄은 같은 값으로 새로 만든 copy와 원래 team을 equals로 비교한 결과입니다.

scores=[90, 80]
equals=false

record 본문에 구성 요소와 이름이 같은 메서드 scores()를 선언하면 컴파일러는 읽기 메서드를 따로 만들지 않고 이 메서드를 씁니다. 이렇게 직접 선언하는 읽기 메서드는 반드시 public이어야 하고 반환 타입도 구성 요소와 같아야 합니다. public을 빼면 invalid accessor method in record Team 오류가 납니다. 출력 첫 줄을 보면 input[0] = 0과 team.scores()[1] = 0이 모두 team에 영향을 주지 못했습니다. 앞의 것은 생성자가 받은 배열을 복제해 두었기 때문이고, 뒤의 것은 scores()가 돌려준 배열이 호출할 때마다 새로 만든 복제본이기 때문입니다.

둘째 줄은 배열 구성 요소의 또 다른 문제를 보여 줍니다. copy는 team의 읽기 메서드 결과로 만들었으니 내용이 완전히 같은데도 equals가 false입니다. record가 만드는 equals는 참조형 구성 요소를 Objects.equals로 비교하는데, 배열의 equals는 내용이 아니라 같은 배열 객체인지를 비교하기 때문입니다. java.lang.Record의 문서는 '읽기 메서드의 결과를 정식 생성자에 넣어 만든 복사본은 원래 record와 equals여야 한다'는 규칙을 요구하는데, 이 Team은 그 규칙을 어깁니다. 배열을 꼭 써야 한다면 equals와 hashCode를 Arrays.equals와 Arrays.hashCode로 다시 정의하고, 기본 toString이 배열 내용 대신 [I@1b6d3586 같은 문자열을 찍으므로 toString도 바꿔야 합니다. 대부분은 처음부터 List<Integer>를 구성 요소로 두고 List.copyOf로 복사하는 편이 훨씬 간단합니다.

컴팩트 생성자로 값을 검사하고 정리하기

복사 말고도 컴팩트 생성자가 맡기 좋은 일이 있습니다. 객체가 만들어지기 전에 입력을 검사하고, 필요하면 저장하기 좋은 형태로 바꾸는 일입니다. 다음 BoardNote는 게시글 메모에 세 가지 규칙을 둡니다. 제목은 앞뒤 공백을 지운 뒤에도 비어 있으면 안 되고, 조회 수는 양수여야 하며, 태그 목록은 사본으로 보관합니다.

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

public final class ValidatedBoardNoteRecord {
    private record BoardNote(String title, int viewCount, List<String> tags) {
        private BoardNote {
            title = title == null ? "" : title.trim();
            if (title.isEmpty()) {
                throw new IllegalArgumentException("title");
            }
            if (viewCount <= 0) {
                throw new IllegalArgumentException("viewCount");
            }
            tags = List.copyOf(tags);
        }

        BoardNote withViewCount(int newViewCount) {
            return new BoardNote(title, newViewCount, tags);
        }
    }

    public static void main(String[] args) {
        BoardNote original = new BoardNote("  record  ", 30, List.of("java", "value"));
        BoardNote extended = original.withViewCount(45);
        System.out.println("original=" + original);
        System.out.println("extended=" + extended);
    }
}

main은 앞뒤에 공백이 있는 제목으로 첫 객체를 만들고, withViewCount(45)로 조회 수만 다른 객체를 하나 더 얻습니다. 두 객체를 record의 toString() 형식으로 찍습니다.

original=BoardNote[title=record, viewCount=30, tags=[java, value]]
extended=BoardNote[title=record, viewCount=45, tags=[java, value]]
정규화와 검사를 마친 값이 record 필드에 저장된다

ValidatedBoardNoteRecord의 컴팩트 생성자가 명시적으로 수행하는 검사를 읽습니다.

필드 저장 전에 검사하는 입력 규칙

정규화와 검사를 마친 값이 record 필드에 저장된다

컴팩트 생성자가 제목을 정규화하고 조회 수·태그를 검사합니다. 이 규칙은 record가 자동으로 만드는 검사가 아닙니다.

제목 title

생성자가 하는 일
null을 빈 문자열로 바꾸고 trim
통과 조건
정규화 뒤 빈 문자열이 아님

조회 수 viewCount

생성자가 하는 일
0 이하이면 입력 거절
통과 조건
양수

태그 tags

생성자가 하는 일
List.copyOf로 목록 구조 고정
통과 조건
목록과 각 원소가 null이 아님

withViewCount도 같은 생성자를 거칩니다. main의 30 → 45는 유효 입력이며 실패 입력을 실행한 기록은 없습니다.

생성자 본문은 위에서 아래로 실행되므로 순서가 곧 규칙입니다. 첫 줄은 null 제목을 빈 문자열로 바꾸고 앞뒤 공백을 지웁니다. 그다음에 빈 제목인지 검사하므로, 공백만 있는 제목 " "도 빈 제목으로 거절됩니다. 만약 검사를 먼저 하고 trim을 나중에 했다면 " "는 비어 있지 않으니 검사를 통과하고, 저장되는 제목은 빈 문자열이 되었을 것입니다. 값을 정리(정규화)한 다음에 검사해야 하는 이유입니다.

조회 수 검사와 태그 복사가 그 뒤를 잇고, 이 단계를 모두 통과해야 컴파일러가 넣어 둔 필드 대입이 실행됩니다. 중간에 예외가 나면 new 식은 객체를 돌려주지 않으므로, 규칙을 어긴 BoardNote는 프로그램 어디에도 존재할 수 없습니다. 위 그림은 세 구성 요소마다 생성자가 하는 일과 통과 조건을 정리한 것입니다. 이 검사들은 record라서 저절로 생긴 것이 아니라 생성자에 직접 적은 코드라는 점을 기억해 두세요. 컴파일러가 만들어 주는 정식 생성자는 받은 값을 그대로 필드에 넣을 뿐입니다.

출력 첫 줄의 title=record는 " record "가 정리된 결과입니다. 둘째 줄을 만든 withViewCount(45)는 필드를 바꾸는 대신 같은 제목과 태그, 새 조회 수로 new BoardNote(...)를 호출합니다. 그래서 original은 30 그대로 남고, 새 객체도 생성자를 거치므로 45가 양수인지 다시 검사받습니다. 이처럼 일부 값만 바꾼 새 객체를 돌려주는 메서드를 흔히 with 메서드라고 부르며, 불변 값과 with 메서드에서 본 방식과 같습니다. 이 main은 올바른 값만 넣으므로, 잘못된 값이 어느 단계에서 거절되는지는 연습 문제 2에서 직접 확인합니다.

더 깊이: 정식 생성자가 아닌 생성자와 Java 25의 변화

record에도 매개변수 목록이 다른 생성자를 더 선언할 수 있습니다. 다만 그런 생성자는 반드시 this(...)로 다른 생성자를 호출해야 하고, 그 호출을 따라가면 결국 정식 생성자(canonical constructor)에 도달해야 합니다. 어떤 생성자로 만들든 정식 생성자의 검사를 빠짐없이 거치게 하려는 규칙입니다. 예를 들어 BoardNote(String title) { this(title, 1, List.of()); }를 추가하면 제목만으로 메모를 만들 수 있고, 이렇게 만든 객체도 컴팩트 생성자의 검사를 똑같이 받습니다.

Java 24까지는 프리뷰 기능을 켜지 않는 한 this(...) 호출이 생성자의 첫 문장이어야 했습니다. 그래서 문자열을 나눈 결과로 정식 생성자를 호출하려면 정적 팩터리 메서드를 따로 두는 것이 보통이었습니다. Java 25에서 정식 기능이 된 유연한 생성자 본문(JEP 513)부터는 this(...) 앞에 문장을 둘 수 있습니다. 앞쪽 문장에서 아직 만들어지는 중인 객체의 필드를 읽거나 인스턴스 메서드를 부를 수는 없지만, 매개변수를 가공하고 지역 변수를 선언하는 것은 됩니다. 정식 생성자 안에서는 지금도 this(...)나 super(...)를 호출할 수 없습니다.

Java 25 이상: this(...) 앞에 문장 두기
record Range(int start, int end) {
    Range {
        if (start > end) {
            throw new IllegalArgumentException(start + " > " + end);
        }
    }

    Range(String text) {
        String[] parts = text.split("-"); // Java 25부터 this(...) 앞에 둘 수 있다
        this(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
    }
}

new Range("3-7")은 문자열을 나눈 뒤 정식 생성자를 호출하므로 Range[start=3, end=7]이 되고, new Range("7-3")은 정식 생성자의 검사에서 거절됩니다. 이 코드는 JDK 25 이상에서만 컴파일됩니다. 그보다 낮은 버전에서는 split 호출을 static Range parse(String text) 같은 정적 메서드로 옮기고, 그 안에서 new Range(...)를 호출하면 됩니다.

값이 같으면 같은 키: record를 맵의 키로 쓰기

record의 equals는 같은 record 타입인지와 모든 구성 요소가 같은지를 비교하고, hashCode도 모든 구성 요소로 계산합니다. Object 클래스와 equals·hashCode·toString에서 직접 짝을 맞춰 작성했던 두 메서드를 컴파일러가 대신 만들어 주는 셈입니다. 그래서 record는 '사용자 7의 7월 14일'처럼 여러 값을 묶은 키를 만들기에 알맞습니다.

src/RecordValueEquality.java
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> viewCount = new HashMap<>();
        viewCount.put(stored, 60);

        System.out.println("equal=" + stored.equals(lookup));
        System.out.println("viewCount=" + viewCount.get(lookup));
    }
}

맵에는 stored로 값을 넣고, 꺼낼 때는 다른 객체인 lookup을 씁니다. 둘째 줄이 null이 아니라면 값이 같은 키로 항목을 찾았다는 뜻입니다.

equal=true
viewCount=60

stored와 lookup은 new로 따로 만든 서로 다른 객체입니다. 그런데도 구성 요소인 7과 2026-07-14가 같으므로 equals는 true이고 hashCode도 같은 값이 나옵니다. HashMap은 키의 hashCode로 찾을 위치를 정하고 equals로 최종 확인하므로, 넣을 때 쓴 객체가 아니어도 값이 같은 키로 60을 찾아냅니다. 또 구성 요소인 long과 LocalDate가 모두 불변이라서, 맵에 넣은 뒤에 키의 해시값이 달라질 일도 없습니다.

이 마지막 조건이 깨지면 어떻게 되는지 보겠습니다. 다음 TagKey는 태그 목록을 복사하지 않고 그대로 담는 record이고, main은 키를 맵에 넣은 뒤 원본 목록에 태그를 하나 더합니다.

lab/MutableRecordKey.java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public final class MutableRecordKey {
    private record TagKey(List<String> tags) {}

    public static void main(String[] args) {
        List<String> tags = new ArrayList<>(List.of("java"));
        TagKey key = new TagKey(tags);
        Map<TagKey, String> cache = new HashMap<>();
        cache.put(key, "cached");

        tags.add("record");
        System.out.println("get=" + cache.get(key));
        System.out.println("size=" + cache.size());
    }
}

key는 tags와 같은 목록을 가리키므로, tags.add는 맵에 이미 들어간 키의 내용도 바꿉니다. 그런 뒤 같은 key로 항목을 찾고, 맵에 남은 항목 수를 찍습니다.

get=null
size=1

맵에 넣을 때 쓴 바로 그 key 객체로 찾았는데도 null이 나왔습니다. ArrayList의 hashCode는 원소로 계산되므로 태그가 늘어나자 key.hashCode()도 달라졌습니다. HashMap은 넣을 때의 해시값을 기준으로 항목의 자리를 정해 두었기 때문에, 바뀐 해시값으로는 그 항목을 찾지 못합니다. 그렇다고 항목이 사라진 것도 아니어서 size는 1입니다. 아무도 꺼낼 수 없는 항목이 맵에 남은 셈입니다. TagKey에 앞 절의 컴팩트 생성자 tags = List.copyOf(tags);를 넣으면 키가 만들어진 뒤로 해시값이 바뀌지 않으므로 이 문제가 사라집니다. 그러니 맵의 키나 HashSet의 원소로 쓸 record라면 가변 구성 요소의 방어적 복사가 반드시 필요합니다.

반대로 record의 동등성이 맞지 않는 데이터도 있습니다. 데이터베이스의 회원 엔티티처럼 식별자가 같으면 내용이 바뀌어도 같은 대상으로 봐야 하는 객체는 '모든 값이 같으면 같다'는 record의 규칙과 맞지 않습니다. 게다가 JPA 같은 영속성 명세는 엔티티 클래스가 final이 아니고 인수 없는 생성자를 가질 것을 요구하므로 record를 엔티티로 쓸 수도 없습니다. 그런 객체는 일반 클래스로 두고, 화면이나 API 응답으로 내보낼 때 그 순간의 값을 record에 담아 넘기는 방식이 잘 어울립니다.

메서드 안에서만 쓰는 중간값: 지역 record

record는 메서드 안에서도 선언할 수 있습니다. 스트림으로 데이터를 가공하다 보면 '제목과 계산한 점수'처럼 두세 값을 잠시 함께 들고 다녀야 할 때가 있습니다. 이럴 때 Map.Entry나 배열 대신 그 메서드 안에서만 보이는 지역 record를 쓰면 값마다 이름이 붙습니다. 다음 프로그램은 게시글마다 '조회 수 + 댓글 수 × 10'으로 점수를 매겨 점수가 가장 높은 제목을 찾습니다.

src/LocalRecordAggregation.java
import java.util.Comparator;
import java.util.List;

public final class LocalRecordAggregation {
    private record PostMetric(String title, int viewCount, int commentCount) {}

    static String hottestTitle(List<PostMetric> posts) {
        record Scored(String title, int score) {}

        return posts.stream()
                .map(post -> new Scored(post.title(), post.viewCount() + post.commentCount() * 10))
                .max(Comparator.comparingInt(Scored::score))
                .map(Scored::title)
                .orElse("none");
    }

    public static void main(String[] args) {
        List<PostMetric> posts =
                List.of(
                        new PostMetric("record", 35, 4),
                        new PostMetric("sealed", 50, 1),
                        new PostMetric("module", 40, 2));
        System.out.println("hottest=" + hottestTitle(posts));
        System.out.println("hottest(empty)=" + hottestTitle(List.of()));
    }
}

두 번째 호출은 빈 목록을 넘겨, 점수를 매길 게시글이 하나도 없는 경우를 확인합니다. hottestTitle은 어느 경우든 문자열을 돌려주므로 두 줄 모두 예외 없이 찍힙니다.

hottest=record
hottest(empty)=none

Scored는 hottestTitle 안에서만 쓰이는 타입입니다. 첫 map 단계에서 게시글마다 제목과 점수를 담은 Scored를 만들고, max는 score()로 비교하며, 마지막 map이 이긴 쪽의 title()을 꺼냅니다. 점수는 record가 35 + 4 × 10 = 75, sealed가 50 + 1 × 10 = 60, module이 40 + 2 × 10 = 60입니다. 그래서 조회 수만 보면 가장 많은 sealed가 아니라 record가 첫 줄에 나옵니다. 빈 목록이면 max가 빈 Optional을 돌려주므로 둘째 줄처럼 orElse의 none이 출력됩니다. 같은 일을 Map.Entry<String, Integer>로 하면 getKey()와 getValue()가 무엇을 뜻하는지 코드를 거슬러 올라가 확인해야 하지만, Scored는 이름만으로 뜻이 드러납니다.

지역 record에는 지역 클래스와 변수 캡처에서 본 지역 클래스와 다른 점이 하나 있습니다. 지역 record는 암묵적으로 static이어서 자신을 감싼 메서드의 지역 변수를 캡처하지 못합니다. 예를 들어 hottestTitle 안에 int bonus = 10;을 선언하고 Scored의 메서드에서 bonus를 읽으면 javac는 non-static variable bonus cannot be referenced from a static context 오류를 냅니다. record의 상태는 구성 요소만으로 정해져야 하므로, 바깥 변수를 붙잡아 눈에 보이지 않는 상태가 생기는 길을 막아 둔 것입니다. 필요한 값은 구성 요소로 넘기면 됩니다.

record로 만들 수 없는 것과 고르는 기준

record의 간결함은 몇 가지 제약을 받아들인 결과입니다. record는 암묵적으로 java.lang.Record를 상속하므로 extends로 다른 클래스를 상속할 수 없고, 암묵적으로 final이라 다른 클래스가 record를 상속할 수도 없습니다. 인터페이스는 구현할 수 있습니다. 또 구성 요소 말고는 인스턴스 필드를 선언할 수 없고 인스턴스 초기화 블록도 둘 수 없는데, 객체의 상태가 구성 요소만으로 완전히 설명되어야 한다는 뜻입니다. 정적 필드와 정적 메서드, 인스턴스 메서드, 중첩 타입은 자유롭게 추가할 수 있습니다. 이 제약을 바탕으로 record를 고를지 판단하는 기준은 다음과 같습니다.

  • 명령, 조회 결과, 이벤트, 값 객체처럼 만들어진 뒤 바뀌지 않는 데이터에는 record를 먼저 고려합니다.
  • 만들어진 뒤에도 필드가 바뀌어야 하는 객체나 식별자로 같은지를 판단하는 엔티티에는 일반 클래스가 맞습니다.
  • 구성 요소가 많아 생성자 인수의 순서가 헷갈린다면, 관련된 값끼리 작은 record로 묶어 구성 요소 수를 줄입니다.
  • 읽기 메서드 이름은 getTitle()이 아니라 title()입니다. JavaBeans 규칙의 get 메서드를 찾는 라이브러리와 함께 쓴다면 record를 지원하는지 먼저 확인합니다.
  • JSON 같은 형식으로 외부에 내보내는 record는 구성 요소 이름이 그대로 필드 이름이 되는 경우가 많습니다. 구성 요소의 이름을 바꾸거나 추가하기 전에 받는 쪽과의 호환성을 따져 봅니다.

더 깊이: 역직렬화도 정식 생성자를 거칩니다

Java 직렬화(Serializable)로 저장한 record를 다시 읽으면, 스트림에 기록된 구성 요소 값으로 정식 생성자를 호출해 객체를 만듭니다. 그래서 컴팩트 생성자에 넣은 검사와 방어적 복사가 역직렬화된 객체에도 그대로 적용됩니다. 일반 클래스는 역직렬화할 때 자기 생성자를 거치지 않고 필드를 채우기 때문에, 같은 보호를 하려면 readObject 메서드에서 검사와 복사를 다시 작성해야 했습니다. 구성 요소만으로 상태를 정한다는 record의 제약이 여기서도 이득이 되는 셈입니다.

정리

  • record의 구성 요소 필드는 final이지만 그 필드가 가리키는 목록이나 배열의 내용까지 막지는 않습니다. 넘겨준 쪽이 원본을 바꾸는 입력 경로와 읽기 메서드가 내부 객체를 내주는 출력 경로를 모두 막아야 스냅샷이 됩니다.
  • 컴팩트 생성자에서 List.copyOf로 복사하면 원본과의 연결이 끊기고, 사본이 수정할 수 없는 목록이라 읽기 메서드로도 바꿀 수 없습니다. 배열은 생성자와 읽기 메서드 양쪽에서 복사해야 하고 equals도 따로 다뤄야 하므로, 되도록 List를 씁니다.
  • 컴팩트 생성자에서는 값을 정리한 뒤에 검사하고, 필드 대입은 본문이 정상적으로 끝난 뒤에 일어납니다. with 메서드로 만든 새 객체도 같은 검사를 거칩니다.
  • record는 모든 구성 요소로 equals와 hashCode를 만들어 맵의 키로 쓰기 좋지만, 구성 요소가 가변이면 키의 해시값이 바뀌어 항목을 찾지 못하게 됩니다.
  • 지역 record는 메서드 안의 중간값에 이름을 붙여 주며, 암묵적으로 static이라 바깥 지역 변수를 캡처하지 않습니다.

연습 문제

  1. 주 시작 날짜와 일별 조회 수 목록을 가진 WeeklySnapshot record를 만들고 합계를 구하는 totalViews()를 추가하세요. 주 시작일은 2026-07-13이고, 원본 목록에는 7월 13일 40회와 7월 14일 50회가 이 순서로 들어 있습니다. 스냅샷을 만든 뒤 원본 목록을 clear()해도 스냅샷의 일수와 합계는 바뀌지 않아야 합니다. 같은 주 시작일과 빈 목록으로 만든 스냅샷의 결과도 예상해 보세요.
  2. 이 글의 ValidatedBoardNoteRecord에 있는 BoardNote에 다음 네 입력을 넣으면 각각 어떤 예외가 나는지, 몇 번째 단계에서 나는지 예상하세요. A는 제목 " "과 조회 수 0, B는 제목 " sealed "와 조회 수 0, C는 제목 null과 조회 수 10, 빈 태그 목록, D는 제목 "module"과 조회 수 10, 태그 목록 Arrays.asList("java", null)입니다. A와 B의 태그 목록은 List.of("java")입니다.
  3. ArrayComponentRecord의 Team에서 team.equals(copy)가 false인 것이 왜 문제인지 Record.equals 문서의 규칙으로 설명하세요. 그리고 구성 요소를 List<Integer>로 바꾸면 무엇이 달라지는지 쓰세요.

힌트

  1. 컴팩트 생성자에서 목록을 복사하세요. 일별 데이터를 담는 record의 구성 요소가 모두 불변인지도 확인하세요.
  2. 생성자 본문은 위에서 아래로 실행되고, 처음 실패한 단계에서 멈춥니다. 제목 정리가 첫 단계입니다.
  3. copy는 team의 읽기 메서드 결과를 정식 생성자에 넣어 만든 객체입니다.

해설

1. 원본을 지워도 바뀌지 않는 주간 스냅샷

일별 데이터는 DailyBoard record로, 주간 스냅샷은 컴팩트 생성자에서 목록을 복사하는 WeeklySnapshot record로 만듭니다. main의 마지막 두 줄은 빈 목록으로 만든 스냅샷을 확인합니다.

exercise/WeeklyBoardSnapshotSolution.java
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public final class WeeklyBoardSnapshotSolution {
    private record DailyBoard(LocalDate date, int viewCount) {}

    private record WeeklySnapshot(LocalDate weekStart, List<DailyBoard> days) {
        private WeeklySnapshot {
            days = List.copyOf(days);
        }

        int totalViews() {
            return days.stream().mapToInt(DailyBoard::viewCount).sum();
        }
    }

    public static void main(String[] args) {
        ArrayList<DailyBoard> source = new ArrayList<>();
        source.add(new DailyBoard(LocalDate.of(2026, 7, 13), 40));
        source.add(new DailyBoard(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.totalViews());

        WeeklySnapshot empty = new WeeklySnapshot(LocalDate.of(2026, 7, 13), List.of());
        System.out.println("empty days=" + empty.days().size() + ", total=" + empty.totalViews());
    }
}

main은 스냅샷을 만든 직후 원본 목록 source를 비우고, 그다음에 스냅샷의 일수와 합계를 찍습니다. 첫 두 줄에서 스냅샷이 원본을 비운 영향을 받는지 확인합니다.

days=2
total=90
empty days=0, total=0

source.clear()는 원본 목록만 비우고, 스냅샷이 생성자에서 만들어 둔 사본의 두 원소는 건드리지 못합니다. 그래서 일수는 2, 합계는 40 + 50 = 90입니다. 빈 목록으로 만든 스냅샷은 원소가 없으니 일수와 합계가 모두 0입니다. 이 스냅샷이 완전히 바뀌지 않는 것은 DailyBoard의 구성 요소가 불변인 LocalDate와 기본형 int이기 때문이기도 합니다. record 키워드만으로 얻은 성질이 아니라는 점에 주의하세요. 또 지금의 생성자는 날짜가 중복되는지, 날짜가 그 주 안에 있는지, 조회 수가 음수인지는 검사하지 않습니다. 업무에 그런 조건이 있다면 목록 복사와 함께 생성자에 규칙으로 적어야 합니다.

2. 입력마다 거절되는 단계

네 입력을 차례로 넣고, 만들어지면 그 객체를, 예외가 나면 예외를 출력하는 프로그램입니다. BoardNote는 본문과 같은 컴팩트 생성자를 가지고 있습니다.

exercise/BoardNoteRejections.java
import java.util.Arrays;
import java.util.List;
import java.util.function.Supplier;

public final class BoardNoteRejections {
    private record BoardNote(String title, int viewCount, List<String> tags) {
        private BoardNote {
            title = title == null ? "" : title.trim();
            if (title.isEmpty()) {
                throw new IllegalArgumentException("title");
            }
            if (viewCount <= 0) {
                throw new IllegalArgumentException("viewCount");
            }
            tags = List.copyOf(tags);
        }
    }

    static void attempt(String label, Supplier<BoardNote> creation) {
        try {
            System.out.println(label + " -> " + creation.get());
        } catch (RuntimeException e) {
            System.out.println(label + " -> " + e);
        }
    }

    public static void main(String[] args) {
        attempt("A", () -> new BoardNote("   ", 0, List.of("java")));
        attempt("B", () -> new BoardNote(" sealed ", 0, List.of("java")));
        attempt("C", () -> new BoardNote(null, 10, List.of()));
        attempt("D", () -> new BoardNote("module", 10, Arrays.asList("java", null)));
    }
}

네 입력은 모두 어딘가 잘못되어 있어서, 객체가 찍히는 줄은 없습니다. 각 줄에서 볼 것은 어느 단계의 검사가 먼저 거절했는지입니다.

A -> java.lang.IllegalArgumentException: title
B -> java.lang.IllegalArgumentException: viewCount
C -> java.lang.IllegalArgumentException: title
D -> java.lang.NullPointerException

A는 조회 수도 잘못됐지만, 정리된 제목이 빈 문자열이라 두 번째 단계인 제목 검사에서 먼저 멈춥니다. 조회 수 검사까지 가지 않으므로 메시지는 title입니다. B는 제목이 "sealed"로 정리되어 통과하고, 세 번째 단계인 조회 수 검사에서 거절됩니다. C의 null 제목은 첫 단계에서 빈 문자열로 바뀐 뒤 제목 검사에서 거절됩니다. D는 앞의 검사를 모두 통과하지만 마지막 단계의 List.copyOf가 null 원소를 거부합니다. Arrays.asList는 null 원소를 담을 수 있어서 이 입력을 만들 수 있었습니다. 이 NullPointerException에는 메시지가 없어서 예외 이름만 출력됩니다. 어느 경우든 예외가 난 뒤에는 객체가 만들어지지 않습니다.

3. 배열 구성 요소와 Record.equals의 규칙

copy는 new Team(team.name(), team.scores())로 만들었으므로 Record.equals 문서가 말하는 바로 그 복사본입니다. 문서는 이런 복사본이 원래 record와 equals여야 한다고 요구하는데 결과가 false이므로 규칙을 어깁니다. 그러면 HashSet에 넣어 둔 Team을 내용이 같은 다른 Team으로 찾지 못하는 것처럼, record를 값으로 다루는 코드가 예상대로 동작하지 않습니다. 원인은 생성자와 읽기 메서드가 배열을 복제해서 두 객체가 서로 다른 배열을 갖게 되었는데, 배열의 equals는 같은 배열 객체인지만 보기 때문입니다.

구성 요소를 List<Integer>로 바꾸고 컴팩트 생성자에서 List.copyOf로 복사하면 세 가지가 달라집니다. 사본이 수정할 수 없는 목록이므로 읽기 메서드를 다시 정의할 필요가 없습니다. List의 equals와 hashCode는 원소를 차례로 비교하고 계산하므로, record가 만든 equals와 hashCode가 그대로 내용 기준으로 동작해 문서의 규칙을 지킵니다. 그리고 toString도 [90, 80]처럼 내용을 보여 줍니다.

다음 글

sealed 계층과 상태 분기에서는 record 여러 개를 하나의 닫힌 타입으로 묶습니다. '저장됨·거절됨·실패'처럼 결과의 종류가 정해져 있을 때, 그 가운데 하나를 빠뜨리지 않았는지 컴파일러가 확인하게 만드는 방법입니다.