레코드(Records)로 불변 데이터 모델링하기
레코드(Records)로 불변 데이터 모델링하기
Java 언어는 불변 클래스(immutable class)를 만드는 여러 방법을 제공해요. 아마 가장 직관적인 방법은 final 클래스를 만들고, final 필드와 그 필드를 초기화하는 생성자를 두는 거예요. 그런 클래스의 예를 볼게요.
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
이 요소들을 작성하고 나면 필드의 접근자(accessor)를 추가해야 해요. toString() 메서드도 추가하고, 아마 equals()와 hashCode() 메서드도 추가하겠죠. 이걸 전부 손으로 쓰는 건 꽤 지루하고 오류가 나기 쉬워요. 다행히도 IDE가 이 메서드들을 대신 생성해줘요.
이 클래스의 인스턴스를 한 애플리케이션에서 다른 애플리케이션으로 옮겨야 한다면(네트워크로 보내거나 파일 시스템을 통해) 이 클래스를 직렬화 가능하게 만드는 것도 고려해볼 수 있어요. 그렇게 하려면 이 클래스의 인스턴스가 어떻게 직렬화되는지에 대한 정보를 추가해야 할 수도 있어요. JDK는 직렬화를 제어하는 여러 방법을 제공해요.
결국 여러분의 Point 클래스는, 파일에 써야 하는 두 정수의 불변 묶음을 모델링하기 위해, 대부분 IDE가 생성한 코드로 채워진 백 줄짜리 클래스가 될 수도 있어요.
이걸 바꾸기 위해 **레코드(records)**가 JDK에 추가됐어요. 레코드는 이 모든 것을 한 줄의 코드로 주거든요. 레코드의 상태만 선언하면 나머지는 컴파일러가 생성해줘요.
본문
골치 아픈 코드를 구해주는 레코드
레코드는 이 코드를 훨씬 단순하게 만들어주려고 있어요. Java SE 14부터 다음 코드를 작성할 수 있어요.
public record Point(int x, int y) {}
이 한 줄의 코드가 여러분을 위해 다음 요소들을 만들어줘요.
- 두 개의 int 타입 필드
x와y를 가진 불변 클래스예요. - 이 두 필드를 초기화하는 **정규 생성자(canonical constructor)**를 가져요.
toString(),equals(),hashCode()메서드가 컴파일러에 의해 만들어졌어요. IDE가 생성했을 것과 대응하는 기본 동작이죠. 이 메서드들의 직접 구현을 추가하면 이 동작을 필요에 따라 수정할 수 있어요.Serializable인터페이스를 구현할 수 있어,Point인스턴스를 네트워크나 파일 시스템을 통해 다른 애플리케이션으로 보낼 수 있어요. 레코드가 직렬화·역직렬화되는 방식은 몇 가지 특별한 규칙을 따르는데, 그건 이 튜토리얼의 끝에서 다룰게요.
레코드는 IDE의 도움 없이도 데이터의 불변 묶음을 만드는 것을 훨씬 단순하게 만들어요. 레코드의 컴포넌트를 수정할 때마다 컴파일러가 equals()와 hashCode() 메서드를 자동으로 갱신해주기 때문에 버그의 위험도 줄어들어요.
레코드의 클래스
레코드는 class 키워드 대신 record 키워드로 선언하는 클래스예요. 다음 레코드를 선언해볼게요.
public record Point(int x, int y) {}
레코드를 만들 때 컴파일러가 만들어주는 클래스는 final이에요. 이 클래스는 java.lang.Record 클래스를 확장해요. 그래서 여러분의 레코드는 어떤 클래스도 확장할 수 없어요.
레코드는 인터페이스는 몇 개든 구현할 수 있어요.
레코드의 컴포넌트 선언하기
레코드 이름 바로 뒤에 오는 블록 (int x, int y)은 Point라는 레코드의 컴포넌트를 선언해요. 레코드의 각 컴포넌트에 대해 컴파일러는 그 컴포넌트와 같은 이름의 private final 필드를 만들어요. 레코드에는 컴포넌트를 몇 개든 선언할 수 있어요.
이 예에서 컴파일러는 선언한 두 컴포넌트에 대응해 int 타입의 private final 필드 x와 y를 두 개 만들어요.
이 필드들과 함께, 컴파일러는 컴포넌트마다 **접근자(accessor)**를 하나씩 생성해요. 이 접근자는 컴포넌트와 같은 이름을 가지며 그 값을 돌려주는 메서드예요. 이 Point 레코드의 경우 생성되는 두 메서드는 다음과 같아요.
public int x() {
return this.x;
}
public int y() {
return this.y;
}
이 구현이 애플리케이션에서 잘 동작한다면 아무것도 추가할 필요가 없어요. 하지만 자신만의 접근자 메서드를 정의할 수도 있어요. 특정 필드의 방어적 복사본을 돌려줘야 할 때 유용할 수 있죠.
컴파일러가 마지막으로 생성해주는 요소는 Object 클래스의 toString(), equals(), hashCode() 메서드를 재정의(override)한 것들이에요. 필요하면 이 메서드들도 직접 재정의할 수 있어요.
레코드에 추가할 수 없는 것들
레코드에 추가할 수 없는 것이 세 가지 있어요.
- 레코드에는 인스턴스 필드를 선언할 수 없어요. 컴포넌트에 대응하지 않는 인스턴스 필드는 추가할 수 없어요.
- 필드 초기화자(field initializer)를 정의할 수 없어요.
- 인스턴스 초기화자(instance initializer)를 추가할 수 없어요.
초기화자가 있는 정적 필드와 정적 초기화자는 만들 수 있어요.
레코드를 정규 생성자로 구성하기
컴파일러는 여러분을 위해 **정규 생성자(canonical constructor)**라는 생성자도 만들어요. 이 생성자는 레코드의 컴포넌트를 인자로 받아 그 값을 레코드 클래스의 필드에 복사해요.
이 기본 동작을 재정의해야 하는 상황이 있어요. 두 가지 사용 사례를 살펴볼게요.
- 레코드의 **상태를 검증(validate)**해야 할 때
- 가변 컴포넌트의 방어적 복사본을 만들어야 할 때
컴팩트 생성자(Compact Constructor) 사용하기
레코드의 정규 생성자를 재정의하는 데는 두 가지 다른 문법을 쓸 수 있어요. 컴팩트 생성자 또는 정규 생성자 자체를 쓰는 거예요.
다음 레코드가 있다고 해볼게요.
public record Range(int start, int end) {}
이런 이름의 레코드라면 end가 start보다 크다고 기대할 수 있겠죠. 레코드에 컴팩트 생성자를 작성해 검증 규칙을 추가할 수 있어요.
public record Range(int start, int end) {
public Range {
if (end <= start) {
throw new IllegalArgumentException("End cannot be lesser or equal 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 (start < 0) {
this.start = 0;
} else {
this.start = start;
}
if (end > 100) {
this.end = 100;
} else {
this.end = end;
}
if (end <= start) {
throw new IllegalArgumentException("End cannot be lesser or equal than start");
}
}
}
이 경우 여러분이 작성한 생성자는 레코드의 필드에 값을 대입해야 해요.
레코드의 컴포넌트가 불변이 아니라면, 정규 생성자와 접근자 양쪽에서 그 컴포넌트의 방어적 복사본을 만드는 것을 고려해야 해요.
아무 생성자나 정의하기
정규 생성자를 호출하기만 한다면 레코드에 어떤 생성자든 추가할 수 있어요. 문법은 다른 생성자로 생성자를 호출하는 전형적인 문법과 같아요. JDK 25 기준으로는 제약과 함께 this() 호출 앞에 몇 가지 명령문을 추가할 수 있어요.
다음 State 레코드를 살펴볼게요. 세 컴포넌트로 정의돼 있어요.
- 이 주(state)의 이름
- 이 주의 수도 이름
- 비어 있을 수도 있는 도시 이름 목록
도시 목록의 방어적 복사본을 저장해서, 이 레코드의 밖에서 수정되지 않도록 보장해야 해요.
이것은 정규 생성자를 매개변수를 방어적 복사본으로 재대입하는 컴팩트 형태로 재정의하면 할 수 있어요.
이 도시 목록을 받지 않는 생성자가 애플리케이션에 유용할 수 있어요. 그건 주(state) 이름과 수도 이름만 받는 또 다른 생성자가 될 수 있어요. 이 두 번째 생성자는 정규 생성자를 호출해야 해요.
그다음 도시 목록을 넘기는 대신, 도시를 가변 인자(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() 메서드는 인자로 받는 컬렉션에 null 값을 허용하지 않는다는 점을 참고하세요.
레코드의 상태 얻기
레코드에는 접근자를 추가할 필요가 없어요. 컴파일러가 해주거든요. 레코드는 컴포넌트마다 접근자 메서드를 하나씩 가지며, 그 메서드는 컴포넌트의 이름을 가져요.
이 튜토리얼 첫 부분의 Point 레코드는 접근자 메서드를 두 개 가져요. 대응하는 컴포넌트의 값을 돌려주는 x()와 y()죠.
하지만 자신만의 접근자를 정의해야 하는 경우도 있어요.
예를 들어 앞선 섹션의 State 레코드가 생성 과정에서 cities 목록의 수정 불가능한 방어적 복사본을 만들지 않았다고 해볼게요. 그러면 접근자에서 그렇게 해서 호출자가 내부 상태를 변형하지 못하게 해야 해요.
이 방어적 복사본을 돌려주도록 State 레코드에 다음 코드를 추가할 수 있어요.
public List<String> cities() {
return List.copyOf(cities);
}
레코드 직렬화하기
레코드는 그 클래스가 Serializable을 구현한다면 직렬화·역직렬화될 수 있어요. 다만 제약이 있어요.
- 기본 직렬화 과정을 대체하는 데 쓸 수 있는 시스템 중 어느 것도 레코드에는 사용할 수 없어요.
writeObject()와readObject()메서드를 만들어도 효과가 없고,Externalizable을 구현해도 마찬가지예요. - 레코드는 다른 객체를 직렬화하는 프록시 객체로 사용될 수 있어요.
readResolve()메서드가 레코드를 반환할 수 있고, 레코드에writeReplace()를 추가하는 것도 가능해요. - 레코드를 역직렬화할 때는 항상 정규 생성자를 호출해요. 그래서 이 생성자에 추가한 모든 검증 규칙이 역직렬화할 때도 강제돼요.
이 덕분에 레코드는 애플리케이션에서 **데이터 전송 객체(data transport object)**를 만들기에 아주 좋은 선택이에요.
실제 사용 사례에서 레코드 사용하기
레코드는 많은 맥락에서 쓸 수 있는 다재다능한 개념이에요.
첫 번째는 애플리케이션의 객체 모델에서 데이터를 나르는 것이에요. 레코드를 설계된 용도 그대로, 즉 불변 데이터 운반체로 사용할 수 있어요.
**로컬 레코드(local record)**를 선언할 수 있기 때문에 코드의 가독성을 높이는 데도 쓸 수 있어요.
다음 사용 사례를 볼게요. City와 State라는 두 엔티티가 레코드로 모델링돼 있어요.
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()
));
이 히스토그램의 최댓값을 얻는 것은 다음의 일반 코드예요. 이 예는 동점(tie)을 처리하지 않는다는 점을 참고하세요. 여러 주가 같은 도시 수를 갖고 있다면, 단일 주를 가진 NumberOfCitiesPerState 레코드 하나만 보고될 거예요.
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();
이제 코드가 의미 있는 방식으로 최댓값을 추출해요. 코드가 더 읽기 좋고, 이해하기 쉽고, 오류가 나기 어려우며, 장기적으로 유지보수가 더 쉬워지죠.