이번 포스팅에서는 자바 17버전에 정식으로 들어오게 된 record 클래스를 살펴보려 한다.
record 클래스란?
record 클래스는 데이터를 보다 간결하게 표현하기 위해 도입된 Java의 특별한 클래스다. 일반적인 클래스를 작성할 때는 필드, 생성자, equals()
, hashCode()
, toString()
등의 메서드를 직접 정의해야 하지만, record 클래스를 사용하면 이러한 과정을 생략하고도 동일한 기능을 갖춘 클래스를 선언할 수 있다.
예를 들어, Rectangle(double length, double width)
라는 레코드 클래스를 선언하면, Java 컴파일러가 length
와 width
를 필드로 가지는 private final
변수와, 이를 반환하는 접근자 메서드, 그리고 equals()
, hashCode()
, toString()
을 자동으로 생성해준다.
예를 들어, 다음과 같은 record 클래스가 있다고 하자.
record Rectangle(double length, double width) { }
이 클래스는 다음 코드와 동일하다.
public final class Rectangle {
private final double length;
private final double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
double length() { return this.length; }
double width() { return this.width; }
// equals() 및 hashCode() 메서드는 동일한 타입이고
// 같은 값을 가진 필드를 포함하는 경우 객체를 동일하다고 판단한다.
public boolean equals(...) { ... }
public int hashCode(...) { ... }
// toString() 메서드는 모든 필드 이름과 값을 포함한 문자열을 반환한다.
public String toString() { ... }
}
record 클래스는 기본적으로 “데이터를 담는 용도”로 설계되었기 때문에 불변(immutable)하게 동작하며, 모든 필드는 final
로 선언된다. 따라서 한 번 생성된 record 객체의 필드는 변경할 수 없다. 이를 통해 불변성을 보장하면서도 간결한 데이터 모델을 만들 수 있다.
record 클래스의 주요 특징
record 클래스의 선언 방식은 일반적인 클래스와 다르다. 기본적으로 다음과 같은 구성 요소를 포함한다.
- 클래스 이름
- (선택 사항) 제네릭 타입 매개변수
- 헤더(레코드 컴포넌트 목록)
- (선택 사항) 본문
record 클래스를 선언하면 다음과 같은 요소가 자동으로 생성된다.
- 각 컴포넌트에 대한 멤버
private final
필드가 생성된다.- 필드 값을 반환하는
public
접근자 메서드가 자동 생성된다.
- 자동 생성되는 메서드
- 컴포넌트 값을 필드에 할당하는 정식 생성자(Canonical Constructor)
- 같은 타입이며 값이 동일한 두 객체를 동일하다고 판단하는
equals()
- 객체를 해시 코드 값으로 변환하는
hashCode()
- 객체의 필드 값을 문자열로 변환하는
toString()
record 클래스는 final
record 클래스는 암묵적으로 final
로 선언되기 때문에 다른 클래스가 이를 상속할 수 없다. 이는 record 클래스의 불변성을 유지하고 데이터 모델을 단순하게 유지하기 위한 설계다. 예를 들어, 다음과 같은 코드는 허용되지 않는다.
record Parent(int value) { }
class Child extends Parent { } // 오류 발생
record 클래스의 생성자
record 클래스의 생성자는 자동으로 생성되지만, 필요에 따라 직접 선언할 수도 있다. 예를 들어, length
와 width
가 0 이하일 경우 예외를 발생시키도록 생성자를 선언할 수 있다.
record Rectangle(double length, double width) {
public Rectangle(double length, double width) {
if (length <= 0 || width <= 0) {
throw new IllegalArgumentException(
String.format("Invalid dimensions: %f, %f", length, width));
}
this.length = length;
this.width = width;
}
}
하지만 record 클래스에서는 보다 간결한 형태의 **압축 생성자(Compact Constructor)**를 사용할 수도 있다. 이 경우 생성자의 매개변수를 명시적으로 선언하지 않아도 된다.
record Rectangle(double length, double width) {
public Rectangle {
if (length <= 0 || width <= 0) {
throw new IllegalArgumentException(
String.format("Invalid dimensions: %f, %f", length, width));
}
}
}
압축 생성자를 사용하면 필드 값이 자동으로 초기화되므로 this.length = length;
와 같은 코드가 필요 없다.
record 클래스에서 명시적인 메서드 정의
자동으로 생성되는 접근자 메서드나 equals()
, hashCode()
, toString()
등의 메서드를 직접 재정의할 수도 있다. 예를 들어, length()
메서드를 직접 구현하여 값을 출력하는 기능을 추가할 수도 있다.
record Rectangle(double length, double width) {
public double length() {
System.out.println("Length is " + length);
return length;
}
}
만약 equals()
, hashCode()
, toString()
을 직접 구현하는 경우, Java에서 기본 제공하는 것과 동일한 기능을 유지하도록 작성하는 것이 좋다.
record 클래스의 정적(static) 요소
record 클래스에서는 정적 필드, 정적 메서드, 정적 초기화 블록을 사용할 수 있다. 예를 들어, Rectangle
클래스에 황금 비율(Golden Ratio)을 사용하는 정적 필드를 추가하고, 이를 활용하는 정적 메서드를 구현할 수 있다.
record Rectangle(double length, double width) {
static double goldenRatio;
static {
goldenRatio = (1 + Math.sqrt(5)) / 2;
}
public static Rectangle createGoldenRectangle(double width) {
return new Rectangle(width, width * goldenRatio);
}
}
하지만 record 클래스에서는 인스턴스 변수(비-정적 필드)나 인스턴스 초기화 블록을 사용할 수 없다.
record Rectangle(double length, double width) {
BiFunction<Double, Double, Double> diagonal; // 오류 발생
{
diagonal = (x, y) -> Math.sqrt(x * x + y * y); // 오류 발생
}
}
record 클래스에서 인터페이스 구현
record 클래스는 인터페이스를 구현할 수 있다.
record Customer(String name) implements Billable { }
중첩 record 클래스와 로컬 record 클래스
record 클래스 내부에는 중첩된 record 클래스를 선언할 수도 있다.
record Rectangle(double length, double width) {
record RotationAngle(double angle) {
public RotationAngle {
angle = Math.toRadians(angle);
}
}
}
또한, 메서드 내부에서 로컬 record 클래스를 선언하여 사용할 수도 있다. 예를 들어, 특정 월의 판매량을 집계하는 로컬 record 클래스를 선언하여 코드의 가독성을 높일 수 있다.
record Merchant(String name) { }
record Sale(Merchant merchant, LocalDate date, double value) { }
public class MerchantExample {
List<Merchant> findTopMerchants(List<Sale> sales, List<Merchant> merchants, int year, Month month) {
record MerchantSales(Merchant merchant, double sales) {}
return merchants.stream()
.map(merchant -> new MerchantSales(
merchant, this.computeSales(sales, merchant, year, month)))
.sorted((m1, m2) -> Double.compare(m2.sales(), m1.sales()))
.map(MerchantSales::merchant)
.collect(Collectors.toList());
}
}
로컬 record 클래스는 자동으로 static이므로 메서드 내부의 지역 변수에 접근할 수 없다.
record 클래스와 JavaBeans 패턴 비교
기존 JavaBeans 패턴에서는 필드 값을 저장하기 위해 getter
/setter
메서드를 직접 구현해야 했다. 하지만 레코드 클래스는 이를 자동으로 생성하며, 불변성을 유지한다.
- JavaBeans 패턴 예시
public class Rectangle {
private double length;
private double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
public double getLength() { return length; }
public void setLength(double length) { this.length = length; }
}
- record 클래스 사용 시
record Rectangle(double length, double width) { }
위와 같이 getter/setter 없이도 데이터를 간결하게 관리할 수 있다.
record 클래스의 직렬화(Serialization)
record 클래스는 Serializable
인터페이스를 구현하면 직렬화할 수 있지만, 기존 클래스에서 writeObject()
또는 readObject()
메서드를 직접 정의하여 커스터마이징하는 것은 불가능하다. 대신, 역직렬화할 때는 record 클래스의 정식 생성자(canonical constructor) 가 자동으로 호출된다.
import java.io.*;
record Rectangle(double length, double width) implements Serializable {}
public class RecordSerializationDemo {
public static void main(String[] args) {
Rectangle rect = new Rectangle(5.0, 10.0);
// 객체 직렬화 (Serialization)
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("rectangle.ser"))) {
oos.writeObject(rect);
System.out.println("Rectangle 객체가 직렬화되었습니다.");
} catch (IOException e) {
e.printStackTrace();
}
// 객체 역직렬화 (Deserialization)
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("rectangle.ser"))) {
Rectangle deserializedRect = (Rectangle) ois.readObject();
System.out.println("Deserialized Rectangle: " + deserializedRect);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
}
위 코드에서는 Rectangle
record 클래스를 Serializable
로 선언한 후, ObjectOutputStream
을 사용하여 객체를 파일(rectangle.ser
)에 직렬화한다. 이후, ObjectInputStream
을 이용해 해당 파일에서 객체를 읽어와 역직렬화한다. 이 과정에서 기존의 writeObject()
나 readObject()
메서드를 정의하지 않아도, record 컴포넌트 값을 기반으로 한 정식 생성자가 자동으로 실행되어 원래 객체가 복원된다.