도입

단순하게 데이터를 한쪽에서 다른 한쪽으로 전달하기 위해서만 사용되는 데이터 전송 객체(혹은 DTO)를 생각해봅시다. 이런 객체를 사용하는 이유는 다양한 집계 연산을 수행한 후의 결과를 담아두거나, 외부 시스템과 통신 시에 필요하지 않은 데이터를 제거하여 대역폭 사용량을 줄이기 위해, 세부 구현을 노출시키지 않기 위해서, 혹은 변경되지 말아야 하는 API 설계 상의 이유 등 다양한 이유가 있을 수 있습니다. 이를 제대로 구현하기 위해서는 (롬복이나 IDE의 도움을 받을 수도 있지만) 아래와 같이 게터(getter 혹은 accessor), equals(), hashCode(), toString() 처럼 계속 똑같은 구조의 코드를 반복해서 작성해야 했습니다.

public class BookDto {  
    private String title;  
    private String author;  
    private String isbn;  
    private String publisher;  

    public String getTitle() {  
        return title;  
    }  

    public String getAuthor() {  
        return author;  
    }
    // ... 수많은 코드 ...
    @Override  
    public int hashCode() {  
        return Objects.hash(title, author, isbn, publisher);  
    }
}

따라서 이렇게 수십 줄의 지루한 코드를 계속해서 적거나 귀찮아서 일부 메서드를 생략하거나 대충 구현하는 것 대신에, 여기서 소개할 레코드를 사용하면 이러한 작업을 아래 코드 한 줄로 줄일 수 있습니다다. 정말 간단하지 않나요?

public record BookDto(String title, String author, String isbn, String publisher) { }

정의

자바 14 이후부터 레코드(Record)라는 구문이 프리뷰 기능으로 추가되었고, 자바 16부터는 공식 기능이 되었습니다. 열거형과 마찬가지로 자바 클래스의 특별한 한 종류라고 바라볼 수 있습니다. 마치 클래스의 생성자와 같이 매개변수를 나열하면 되는데, 여기서는 이를 컴포넌트(component)라고 부릅니다.

record 레코드명(컴포넌트1, 컴포넌트2, ...) { }

클래스와의 비교

위에서 레코드는 클래스의 특별한 한 종류라고 했습니다. 그러면 클래스와 비교했을 때 어떤 부분이 다른 걸까요? JEP 359에서 확인할 수 있는 내용은 다음과 아래와 같습니다.

  • 레코드는 다른 클래스를 상속받을 수 없다.
  • 레코드에는 인스턴스 필드를 선언할 수 없다. 다르게 말하면 정적 필드는 가능하다.
  • 레코드를 abstract로 선언할 수 없으며 암시적으로 final로 선언된다.
  • 레코드의 컴포넌트는 암시적으로 final로 선언된다.

이번에는 클래스와 비슷한 점을 나열하면 다음과 같습니다.

  • 클래스 내에서 레코드를 선언할 수 있다. 중첩된 레코드는 암시적으로 static으로 선언된다.
  • 제네릭 레코드를 만들 수 있다.
  • 레코드는 클래스처럼 인터페이스를 구현할 수 있다.
  • new 키워드를 사용하여 레코드를 인스턴스화할 수 있다.
  • 레코드의 본문(body)에는 정적 필드, 정적 메서드, 정적 이니셜라이저, 생성자, 인스턴스 메서드, 중첩 타입(클래스, 인터페이스, 열거형 등)을 선언할 수 있다.
  • 레코드나 레코드의 각 컴포넌트에 애노테이션을 달 수 있다.

이 레코드를 클래스로 바꿔보면 대략 아래와 같은 모습일 것입니다.

// 레코드
public record Book(String title, String author, String isbn) { }

// 클래스
// 암시적으로 추상 클래스인 java.lang.Record를 상속받는다.
public final class Book extends java.lang.Record {
    // 레코드의 각 컴포넌트는 내부에서 private final인 인스턴스 필드로 선언된다.
    private final String title;
    private final String author;
    private final String isbn;

    // 레코드 내부에서 표준 생성자(canonical constructor)가 만들어진다.
    // 암시적으로 선언된 표준 생성자의 접근 제어자는 레코드의 접근 제어자와 동일하다.
    public Book(String title, String author, String isbn) {
        super();
        this.title = title;
        this.author = author;
        this.isbn = isbn;
    }

    // 기본 구현 toString(), hashCode(), equals()은 원하면 변경할 수 있다.
    @Override
    public final String toString() {
        // 내부 구현의 정확한 문자열 포맷은 향후 변경될 수도 있다.
        return "Book[" + this.title + ", " + this.author + ", " + this.isbn + "]";
    }

    // 암시적 구현은 동일한 컴포넌트로부터 생성된 두 레코드는 해시 코드가 동일해야 한다.
    @Override
    public final int hashCode() {
        // 구현에 사용되는 정확한 알고리즘은 정해지지 않았으며 향후 변경될 수 있다.
        int result = title == null ? 0 : title.hashCode();  
        result = 31 * result + (author == null ? 0 : author.hashCode());  
        result = 31 * result + (isbn == null ? 0 : isbn.hashCode());  
        return result;  
    }

    // 암시적 구현은 두 레코드의 모든 컴포넌트가 서로 동일하면 true를 반환한다.
    @Override
    public final boolean equals(Object o) {
        // 구현에 사용되는 정확한 알고리즘은 정해지지 않았으며 향후 변경될 수 있다.
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Book book = (Book) o;
        return Objects.equals(title, book.title) && Objects.equals(author, book.author) && Objects.equals(isbn, book.isbn);
    }

    // 컴포넌트명과 동일한 게터(getter)가 선언된다.
    public String title() {
        return this.title;
    }

    public String author() {
        return this.author;
    }

    public String isbn() {
        return this.isbn;
    }
}

덧붙여서 필요하다면 로컬 클래스와 마찬가지로 로컬 레코드를 선언할 수도 있습니다. 로컬 레코드는 아래와 같이 로컬 클래스와 같이 메서드의 본문에 정의된 레코드를 말합니다. 

public class Foo {
    public void doSomething() {
        // ...
        record Bar(...) {
            // ...
        }
        // ...
    }
}

컴팩트 생성자(Compact Constructor)

만약 별도의 초기화 로직이 필요하다면 레코드 안에 표준 생성자를 만들 수도 있습니다. 이 생성자의 형태는 우리가 늘 봤던 형태라 익숙할 것입니다. 물론 내부에는 final로 선언된 인스턴스 필드가 있어서 생성자 안에서 모두 초기화해야 합니다.

public record Book(String title, String author, String isbn) {    
    // 물론 이렇게 다른 생성자를 추가할 수도 있다.
    public Book(String title, String isbn) {
        this(title, "Unknown", isbn);
    }

    public Book(String title, String author, String isbn) {
        // 조금 더 복잡한 초기화 로직 ...
    }
}

여기서 이러한 표준 생성자 말고도 컴팩트 생성자를 사용할 수도 있습니다. 아래와 같이 생성자 매개변수를 받는 부분이 사라진 형태입니다. 개발자가 일일이 명시적으로 인스턴스 필드를 초기화하지 않아도 컴팩트 생성자의 마지막에 초기화 구문이 자동으로 삽입됩니다. 그리고 표준 생성자와는 달리 컴팩트 생성자 내부에서는 인스턴스 필드에 접근을 할 수가 없으며, 접근하려고 하면 "final 변수 'x'에 값을 할당할 수 없습니다."와 같은 에러 메시지를 볼 수 있습니다.

public record Book(String title, String author, String isbn) {
    // public Book(String title, String author, String isbn) { ... }과 동일
    public Book {
        Objects.requireNonNull(title);
        Objects.requireNonNull(author);
        Objects.requireNonNull(isbn);
        // this.title = title;
        // this.author = author;
        // this.isbn = isbn;
    }

    // 여전히 아래와 같이 표준 생성자와 컴팩트 생성자를 혼용해서 쓸 수 있다.
    public Book(String title, String isbn) {
        this(title, "Unknown", isbn);
    }
}

따라서 컴팩트 생성자에는 컴포넌트로 넘어온 객체를 불변으로 만들거나, 불변식(invariant)을 만족하는지 검사하는(예를 들어서 위와 같이 null이 넘어오지는 않았는지) 등의 작업을 하기에 적합합니다. 메일링 리스트에서 발견한 설계자의 의도는 다음과 같습니다.

개빈 비어만(Gavin Bierman)이 말하는 컴팩트 생성자의 의도

보통 레코드 클래스에 표준 생성자를 명시적으로 제공해야 하는 이유는 인수의 값을 검증하거나 정규화하기 위함입니다. 레코드 클래스 선언의 가독성을 높이기 위해서 이러한 검증/정규화용 코드만 필요한 새로운 형태의 소형화된(compact) 표준 생성자 선언을 제공합니다. 예시는 다음과 같습니다.

record Rational(int num, int denom) { 
	Rational {
    	// 기약분수로 만들기 위해서 분모와 분자를 최대공약수(GCD)로 나눈다.
		int gcd = gcd(num, denom);
		num /= gcd;
		denom /= gcd;
	}
}

컴팩트 생성자 선언의 의도는 생성자 본문에 검증/정규화용 코드만 넣어야 한다는 것입니다. 나머지 초기화 코드는 컴파일러가 자동으로 수행합니다. 매개변수 목록은 레코드의 컴포넌트 목록에서 가져오기 때문에 컴팩트 생성자 선언에 필요하지 않습니다. 달리 말하면, 위의 선언은 기존 생성자의 형태를 사용하는 아래의 선언과 같습니다.

record Rational(int num, int denom) { 
	Rational(int num, int demon) {
		// 검증/정규화(Validation/Normalization)
		int gcd = gcd(num, denom);
		num /= gcd;
		denom /= gcd;
		// 초기화
		this.num = num;
		this.denom = denom;
	}
}

레코드 간 비교

두 레코드를 비교하는 간단한 예시를 잠깐 살펴봅시다. 비교 연산자(==)를 사용하면 참조(reference)를 비교하기 때문에 equals() 메서드를 사용해야 한다는 점을 잊지 맙시다.

public class RecordExamples {
    public static void main(String[] args) {
        // 클래스와 마찬가지로 new 연산자를 통해 레코드의 인스턴스를 생성한다.
        Book bookA = new Book("Book A", "Author A");
        Book bookB = new Book("Book B", "Author B");

        // 두 레코드의 간단한 비교
        System.out.println("bookA.hashCode() = " + bookA.hashCode());
        System.out.println("bookB.hashCode() = " + bookB.hashCode());
        if (bookA.equals(bookB)) {
            System.out.println("bookA와 bookB는 서로 같습니다.");
        }
        System.out.println();

        bookB = new Book("Book A", "Author A");
        System.out.println("bookA.hashCode() = " + bookA.hashCode());
        System.out.println("bookB.hashCode() = " + bookB.hashCode());
        if (bookA.equals(bookB)) {
            System.out.println("bookA와 bookB는 서로 같습니다.");
        }
        System.out.println();

        // toString() 출력 살펴보기
        System.out.println("bookA = " + bookA);
        System.out.println("bookB = " + bookB);

        // 게터로 출력하기
        System.out.println("bookA.title() = " + bookA.title());
        System.out.println("bookA.author() = " + bookA.author());
    }

    /* static */ record Book(String title, String author) { }
}