Java 언어에서는 여러 가지 방법으로 불변 클래스를 생성할 수 있습니다. 아마도 가장 간단한 방법은 최종 필드와 이러한 필드를 초기화하는 생성자를 포함하는 최종 클래스를 만드는 것입니다. 다음은 이러한 클래스의 예입니다.

public class Point {
    private final int x;
    private final int y;
 
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

이제 이러한 요소를 작성했으므로 필드에 대한 접근자를 추가해야 합니다. 또한 toString() 메서드와 equals()와 함께 hashCode() 메서드를 추가할 수 있습니다. 이 모든 것을 손으로 작성하는 것은 매우 지루하고 오류가 발생하기 쉽지만, 다행히도 IDE가 이러한 메서드를 생성해 줍니다.

이 클래스의 인스턴스를 네트워크나 파일 시스템을 통해 전송하여 한 애플리케이션에서 다른 애플리케이션으로 전달해야 하는 경우 이 클래스를 직렬화할 수 있게 만드는 것도 고려할 수 있습니다. 그렇게 할 경우 이 클래스의 인스턴스를 직렬화하는 방법에 대한 몇 가지 정보를 추가해야 할 수 있습니다. JDK는 직렬화를 제어하는 여러 가지 방법을 제공합니다.

결국, 파일에 기록해야 하는 두 정수의 불변 집계를 모델링하기 위해 Point 클래스는 수백 줄에 달할 수 있으며, 대부분 IDE에서 생성된 코드로 채워질 수 있습니다.

이를 변경하기 위해 레코드가 JDK에 추가되었습니다. 레코드는 코드 한 줄로 이 모든 기능을 제공합니다. 레코드의 상태를 선언하기만 하면 나머지는 컴파일러가 자동으로 생성합니다.

 

레코드 호출로 지원 요청하기

레코드는 이 코드를 훨씬 더 간단하게 만들 수 있도록 도와줍니다. Java SE 14부터는 다음 코드를 작성할 수 있습니다.

public record Point(int x, int y) {}

이 한 줄의 코드는 다음과 같은 요소를 생성합니다.

  1. 두 개의 필드가 있는 불변 클래스입니다: xy, 타입 int의 두 필드가 있는 불변 클래스입니다.
  2. 이 두 필드를 초기화하기 위한 표준 생성자가 있습니다.
  3. 컴파일러에서 toString(), equals()hashCode() 메서드가 IDE가 생성했을 것과 일치하는 기본 동작으로 생성되었습니다. 필요한 경우 이러한 메서드의 자체 구현을 추가하여 이 동작을 수정할 수 있습니다.
  4. Serializable’(https://docs.oracle.com/en/java/javase/22/docs/api/java.base/java/io/Serializable.html) 인터페이스를 구현하여 네트워크 또는 파일 시스템을 통해 다른 애플리케이션으로 Point 인스턴스를 전송할 수 있습니다. 레코드가 직렬화 및 역직렬화되는 방식은 이 튜토리얼의 마지막 부분에서 다루는 몇 가지 특수 규칙을 따릅니다.

레코드는 IDE의 도움 없이도 변경 불가능한 데이터 집합을 훨씬 더 간단하게 생성할 수 있게 해줍니다. 레코드의 구성 요소를 수정할 때마다 컴파일러가 자동으로 equals()hashCode() 메서드를 업데이트하므로 버그의 위험이 줄어듭니다.

 

레코드의 클래스

레코드는 class 키워드 대신 record 키워드로 선언된 클래스입니다. 다음 레코드를 선언해 보겠습니다.

public record Point(int x, int y) {}

레코드를 생성할 때 컴파일러가 생성하는 클래스는 최종적입니다.

이 클래스는 java.lang.Record 클래스를 확장합니다. 따라서 레코드는 어떤 클래스도 확장할 수 없습니다.

레코드는 얼마든지 인터페이스를 구현할 수 있습니다.

 

레코드의 구성 요소 선언하기

레코드 이름 바로 뒤에 오는 블록은 (int x, int y)입니다. 이것은 Point라는 레코드의 components 를 선언합니다. 컴파일러는 레코드의 각 컴포넌트에 대해 이 컴포넌트와 같은 이름의 비공개 최종 필드를 생성합니다. 레코드에 컴포넌트를 얼마든지 선언할 수 있습니다.

이 예제에서는 컴파일러가 선언한 두 컴포넌트에 해당하는 int 타입의 비공개 최종 필드인 xy 두 개를 생성합니다.

컴파일러는 이러한 필드와 함께 각 컴포넌트에 대해 하나의 accessor 를 생성합니다. 이 접근자는 컴포넌트와 동일한 이름을 가진 메서드이며, 그 값을 반환합니다. 이 Point 레코드의 경우, 생성된 두 개의 메서드는 다음과 같습니다.

public int x() {
    return this.x;
}
 
public int y() {
    return this.y;
}

이 구현이 애플리케이션에 적합하다면 아무것도 추가할 필요가 없습니다. 하지만 자체 접근자 메서드를 정의할 수 있습니다. 특정 필드의 방어적인 복사본을 반환해야 하는 경우에 유용할 수 있습니다.

컴파일러가 생성하는 마지막 요소는 toString(), equals()hashCode() 메서드의 오버라이드인 Object 클래스입니다. 필요한 경우 이러한 메서드에 대한 재정의는 직접 정의할 수 있습니다.

 

레코드에 추가할 수 없는 항목

레코드에 추가할 수 없는 세 가지 항목이 있습니다:

  1. 레코드에 인스턴스 필드를 선언할 수 없습니다. 컴포넌트에 해당하지 않는 인스턴스 필드는 추가할 수 없습니다.
  2. 필드 이니셜라이저를 정의할 수 없습니다.
  3. 인스턴스 이니셜라이저를 추가할 수 없습니다.

이니셜라이저 및 정적 이니셜라이저를 사용하여 정적 필드를 만들 수 있습니다.

표준 생성자를 사용하여 레코드 생성하기

컴파일러는 canonical constructor 라는 생성자도 생성합니다. 이 생성자는 레코드의 구성 요소를 인수로 받아 레코드 클래스의 필드에 해당 값을 복사합니다.

이 default 동작을 재정의해야 하는 상황이 있습니다. 두 가지 사용 사례를 살펴보겠습니다:

  1. 레코드 상태의 유효성을 검사해야 하는 경우
  2. 변경 가능한 컴포넌트의 방어용 복사본을 만들어야 하는 경우.

 

컴팩트 생성자 사용

두 가지 구문을 사용하여 레코드의 표준 생성자를 재정의할 수 있습니다. 컴팩트 생성자 또는 표준 생성자 자체를 사용할 수 있습니다.

다음과 같은 레코드가 있다고 가정해 보겠습니다.

public record Range(int start, int end) {}

해당 이름의 레코드의 경우 endstart보다 클 것으로 예상할 수 있습니다. 레코드에 컴팩트 생성자를 작성하여 유효성 검사 규칙을 추가할 수 있습니다.

public record Range(int start, int end) {
 
    public Range {
        if (end <= start) {
            throw new IllegalArgumentException("End cannot be lesser than start");
        }
    }
}

간결한 정식 생성자는 매개변수 블록을 선언할 필요가 없습니다.

이 구문을 선택하면 컴파일러가 추가한 코드를 통해 레코드의 필드를 직접 할당할 수 없습니다(예: this.start = start). 그러나 매개변수에 새 값을 할당할 수 있으며, 컴파일러에서 생성된 코드가 이러한 새 값을 필드에 할당하므로 동일한 결과를 얻을 수 있습니다.

public Range {
    // set negative start and end to 0
    // by reassigning the compact constructor's
    // implicit parameters
    if (start < 0)
        start = 0;
    if (end < 0)
        end = 0;
}

 

표준 생성자 사용하기

예를 들어 매개변수를 재할당하지 않으려는 경우와 같이 압축되지 않은 형식을 선호하는 경우 다음 예제에서와 같이 표준 생성자를 직접 정의할 수 있습니다.

public record Range(int start, int end) {
 
    public Range(int start, int end) {
        if (end <= start) {
            throw new IllegalArgumentException("End cannot be lesser than start");
        }
        if (start < 0) {
            this.start = 0;
        } else {
            this.start = start;
        }
        if (end > 100) {
            this.end = 10;
        } else {
            this.end = end;
        }
    }
}

이 경우 작성하는 생성자는 레코드의 필드에 값을 할당해야 합니다.

레코드의 구성 요소가 불변이 아닌 경우 표준 생성자와 접근자 모두에 방어적인 복사본을 만드는 것을 고려해야 합니다.

 

생성자 정의하기

이 생성자가 레코드의 표준 생성자를 호출하는 한 레코드에 어떤 생성자라도 추가할 수 있습니다. 이 구문은 다른 생성자와 함께 생성자를 호출하는 클래식 구문과 동일합니다. 모든 클래스의 경우 this()에 대한 호출은 생성자의 첫 번째 문이어야 합니다.

다음 State 레코드를 살펴봅시다. 이 레코드는 세 가지 컴포넌트로 정의되어 있습니다:

  1. 이 주의 이름
  2. 이 주의 수도 이름
  3. 비어 있을 수 있는 도시 이름 목록.

이 레코드 외부에서 수정되지 않도록 도시 목록의 방어용 사본을 저장해야 합니다. 이는 매개변수를 방어 사본에 재할당하는 간결한 형식으로 표준 생성자를 재정의하여 수행할 수 있습니다.

도시를 취하지 않는 생성자를 사용하면 애플리케이션에서 유용합니다. 주 이름과 수도 이름만 사용하는 또 다른 생성자가 될 수 있습니다. 이 두 번째 생성자는 표준 생성자를 호출해야 합니다.

그런 다음 도시 목록을 전달하는 대신 도시를 vararg로 전달할 수 있습니다. 이렇게 하려면 적절한 목록을 사용하여 표준 생성자를 호출해야 하는 세 번째 생성자를 만들 수 있습니다.

public record State(String name, String capitalCity, List<String> cities) {
 
    public State {
        // List.copyOf returns an unmodifiable copy,
        // so the list assigned to `cities` can't change anymore
        cities = List.copyOf(cities);
    }
 
    public State(String name, String capitalCity) {
        this(name, capitalCity, List.of());
    }
 
    public State(String name, String capitalCity, String... cities) {
        this(name, capitalCity, List.of(cities));
    }
 
}

List.copyOf() 메서드는 인자로 받는 컬렉션에 널 값을 허용하지 않는다는 점에 유의하세요.

 

레코드 상태 가져오기

컴파일러가 자동으로 처리하므로 레코드에 접근자를 추가할 필요가 없습니다. 레코드에는 컴포넌트당 하나의 접근자 메서드가 있으며, 이 메서드의 이름은 이 컴포넌트의 이름입니다.

이 튜토리얼의 첫 번째 섹션에 나오는 Point 레코드에는 해당 컴포넌트의 값을 반환하는 x()y()라는 두 개의 접근자 메서드가 있습니다.

하지만 직접 접근자를 정의해야 하는 경우도 있습니다. 예를 들어, 이전 섹션의 State 레코드가 생성 중에 cities 목록의 수정 불가능한 방어 복사본을 생성하지 않았다고 가정하면, 호출자가 내부 상태를 변경할 수 없도록 접근자에서 이를 수행해야 합니다. State 레코드에 다음 코드를 추가하여 이 방어 복사본을 반환할 수 있습니다.

public List<String> cities() {
    return List.copyOf(cities);
}

 

레코드 직렬화하기

레코드 클래스가 Serializable을 구현하는 경우 레코드를 직렬화 및 역직렬화할 수 있습니다. 하지만 제한이 있습니다.

  1. 기본 직렬화 프로세스를 대체하는 데 사용할 수 있는 시스템은 레코드에 사용할 수 없습니다. writeObject()readObject() 메서드를 생성하거나 Externalizable 구현은 아무런 효과가 없습니다.
  2. 레코드는 다른 객체를 직렬화하기 위한 프록시 객체로 사용할 수 있습니다. readResolve() 메서드는 레코드를 반환할 수 있습니다. 레코드에 writeReplace()를 추가하는 것도 가능합니다.
  3. 레코드를 역직렬화하면 항상 정식 생성자를 호출합니다. 따라서 이 생성자에 추가할 수 있는 모든 유효성 검사 규칙은 레코드를 역직렬화할 때 적용됩니다.

따라서 레코드는 애플리케이션에서 데이터 전송 객체를 생성하는 데 매우 적합합니다. JM,K  

실제 사용 사례에서 레코드 사용

레코드는 다양한 상황에서 사용할 수 있는 다용도 개념입니다.

첫 번째는 애플리케이션의 객체 모델에서 데이터를 전달하는 것입니다. 레코드는 불변의 데이터 전달자 역할을 하는 용도로 사용할 수 있습니다.

로컬 레코드를 선언할 수 있으므로 코드의 가독성을 향상시키는 데도 사용할 수 있습니다.

다음 사용 사례를 고려해 보겠습니다. 레코드로 모델링된 두 개의 엔티티가 있습니다: 도시입니다.

public record City(String name, State state) {}
public record State(String name) {}

도시 목록이 있고 도시 수가 가장 많은 주를 계산해야 한다고 가정해 보겠습니다. 먼저 Stream API를 사용하여 각 주의 도시 수가 포함된 히스토그램을 만들 수 있습니다. 이 히스토그램은 Map으로 모델링됩니다.

List<City> cities = List.of();
 
Map<State, Long> numberOfCitiesPerState =
    cities.stream()
          .collect(Collectors.groupingBy(
                   City::state, Collectors.counting()
          ));

이 히스토그램의 최대값을 구하는 제네릭 코드는 다음과 같습니다.

Map.Entry<State, Long> stateWithTheMostCities =
    numberOfCitiesPerState.entrySet().stream()
                          .max(Map.Entry.comparingByValue())
                          .orElseThrow();

이 마지막 코드는 기술적인 코드이며 비즈니스적인 의미는 없습니다. 왜냐하면 Map.Entry 인스턴스를 사용하여 히스토그램의 모든 요소를 모델링하기 때문입니다.

로컬 레코드를 사용하면 이 상황을 크게 개선할 수 있습니다. 다음 코드는 주와 이 주의 도시 수를 집계하는 새 레코드 클래스를 만듭니다. 여기에는 키-값 쌍의 스트림을 레코드 스트림에 매핑하기 위해 Map.Entry의 인스턴스를 매개변수로 사용하는 생성자가 있습니다.

이러한 집계를 도시 수로 비교해야 하므로 팩토리 메서드를 추가하여 이 비교기를 제공할 수 있습니다. 코드는 다음과 같습니다.

record NumberOfCitiesPerState(State state, long numberOfCities) {
 
    public NumberOfCitiesPerState(Map.Entry<State, Long> entry) {
        this(entry.getKey(), entry.getValue());
    }
 
    public static Comparator<NumberOfCitiesPerState> comparingByNumberOfCities() {
        return Comparator.comparing(NumberOfCitiesPerState::numberOfCities);
    }
}
 
NumberOfCitiesPerState stateWithTheMostCities =
    numberOfCitiesPerState.entrySet().stream()
                          .map(NumberOfCitiesPerState::new)
                          .max(NumberOfCitiesPerState.comparingByNumberOfCities())
                          .orElseThrow();

이제 코드에서 의미 있는 방식으로 최대값을 추출합니다. 코드가 더 읽기 쉽고 이해하기 쉬우며 오류가 줄어들고 장기적으로 유지 관리가 더 쉬워집니다.

More Learning