Java 16 Record 💿: 10분 안에 익히는 레코드 레시피 적극 활용

Java 16 Record 💿: 10분 안에 익히는 레코드 레시피 적극 활용

📚 할머니의 에어프라이어로도 10분 안에 익힐 Record: The New Type of Class for Immutable Instance

🇰🇷 Kor: 지금 보는 중!
🗺 Eng: Not yet.
🇯🇵 Jap: まだです。⚠️


💿 What is the Record?

💿 레코드불변 객체 생성을 위한 클래스 유형입니다.
💿 쉽게 말해 모든 필드가 final 필드가 되죠.

만화풍으로 된 이 그림에서는 왼쪽 남자가 엘피 레코드판을 하나 들고 "다시 녹음할 수 있을까요?"라고 물어보고 있다. 오른쪽에 있는 썬글라스를 쓰고 콧수염이 있는 남자는 "새로운 레코드판으로는요."라며 설명하고 있고, 그의 앞에는 "캐낫 모더파이" 즉 "변경할 수 없음."이라는 글씨가 써 있다.

record는 비교적 높은 버전에서 공개된 기능인 만큼 꽤 쉽습니다.
이러저러한 보일러 플레이트를 작성할 필요도 없구요. 코드를 보죠.

// @AllArgsConstructor가 자동으로 생성됩니다. (모든 필드를 인자로 갖는 생성자)
// Getter가 각 필드에 붙게 됩니다.
public record MyRecord(String name, Integer age) {}
// All fields are `final`
// @Override ... toString() automatically.
// @Override ... hashCode() and equals() automatically.

// ⚠️ No builder automatically.

눈에 띄는 것은 소괄호에 필드를 작성하고 있다는 것입니다. 레코드 선언은 마치 생성자를 작성하는 듯한 생김새를 띠고 있고, 실제로 동일한 생성자가 생성되죠.

이를 포함해 레코드 클래스는 다음 특징을 갖습니다.

  • 선언한 모든 필드에 대한 인자를 갖고 있는 생성자(전체 인자 생성자)가 존재합니다.

  • record모든 필드는 final로 선언됩니다. (가장 중요한 특징)

  • 모든 필드에 대해 getter가 생성됩니다. (네이밍 컨벤션 ex: name(), age())

  • toString(), equals(), hashCode() 등도 적절히 생성됩니다.

그간 자바 진영의 유명한 라이브러리인 lombok을 통해 이 작업들이 쉽게 수행되었습니다. record 클래스에서는 그중 대다수를 생략할 수 있습니다.

Usage of Record

💿 생성

MyRecord record = new MyRecord("John", 21);

특이한 것은 자바가 하위호환에 대한 의식 때문인지 record를 일부에서 식별자로 사용할 수 있도록 한다는 것입니다. 즉 변수명을 record로 사용할 수도 있죠. 🤔 그러나 자바의 버전이 빠르게 오르고 있고, 이러한 허용에 대해 (비록 오랫동안 보장될 것 같더라도) 맹신을 삼가고 싶기 때문에 record라는 변수명을 일부러 사용하는 것은 삼가면 좋겠습니다.

MyRecord myRecord = new MyRecord("John", 21);

💿 Getter

적용된 네이밍 컨벤션

  • getFieldName() (X)

  • fieldName() (O)

불변 객체이기 때문에 setter는 없습니다.

String name = myRecord.name(); // returns "John"

💿 toString

toString(), hashCode(), equals() 등도 자동으로 적절히 생성되어 있습니다.

System.out.println(myRecord); // MyRecord[name=John, age=21]

💿 콤팩트 생성자

레코드 클래스에는 소괄호가 없는 특별한 생성자가 있습니다. 사실 독립적인 일반 생성자는 아닙니다. 생성자 파라미터를 검증하고 수정할 수 있는 '콤팩트 생성자'라는 보조 생성자입니다.

public record MyRecord(String name) {
    // Compact Constructor
    public MyRecord {
        Objects.requireNonNull(name); // Check arguments
        name = name.strip(); // Preprocess arguments
    }
}

위 콤팩트 생성자에서 취급한 변수 name은 필드 name이 아니라 생성자 매개변수 name입니다. 무슨 말이냐 하면, 필드 name final이기 때문에 여기에서 여러 차례 대입을 할 수 없는 반면, 매개변수 name은 가변성을 띠기 때문에 몇 차례 전처리에 사용할 수 있다는 말이죠.

🏗
콤팩트 생성자(검증 및 전처리) → 전체 인자 생성자(필드에 대입) → 🚀 인스턴스 생성 완료!

여기에서 전처리된 데이터(name)는 전체 인자 생성자로 전달되어, 동일한 이름의 필드에 대입됩니다. 관례적으로 수행하던 this.name = name; 등 동작은 작성할 필요가 없기 때문에 콤팩트한 생성자입니다.

💿 생성자! 특별한 규칙 양상추 치즈 피클 양파까지

💿 모든 레코드 객체는 '전체 인자 생성자(All Arguments Constructor)'에 의해 생성되어야 합니다. 💿

레코드에서 객체의 생성은 반드시 전체 인자 생성자로 수행하게 됩니다. 그러나 다른 생성자를 만들 수 없다는 뜻은 아닙니다. 다른 생성자를 사용할 때는 수행문의 첫 줄에서 this(...) 생성자를 통해 전체 인자 생성자를 호출해야 합니다. (특이한 구성을 갖는 경우 전체 인자 생성자가 아닌 제3의 생성자를 호출할 수도 있는데, 결국엔 어느 단계에서든 전체 인자 생성자를 호출합니다.)

🏗 추가적인 생성자

예를 들어 Map을 받아서 그중 name 속성과 age 속성을 레코드로 빚어낸다고 해 보겠습니다.

import java.util.Map;

public record MyRecord(String name, Integer age) {

    // 추가적인 생성자
    public MyRecord(Map<String, Object> map) {
        this((String) map.get("name"), (Integer) map.get("age"));
        // `this(String, Integer);` instead of `super();`
    }
}

이처럼 추가적인 생성자에서는 실행문의 첫 문장으로 전체 인자 생성자인 this(...); 생성자를 거쳐 객체를 생성합니다.

마치 상속 관계에서 부모 클래스에서 자식 클래스 순으로 객체를 생성해 가기 위해서 각 생성자의 첫 줄에 super(...) 생성자(또는 다른 this(...) 생성자)를 호출하듯, 생성자 호출에 대한 제약으로 볼 수 있습니다. 자바는 객체 생성 순서에 대한 제약이 다른 언어에 비해 강력한 편이기 때문에, 다른 실행문보다 this(...);super(...); 생성자 호출이 선행되어야 합니다.

자바 객체 생성 순서 (부모 클래스부터 자식 클래스 순으로)

수퍼클래스의 생성자에서 서브클래스의 생성자를 거쳐 하나의 인스턴스가 된다는 도식화. 그냥 화살표로 방향 표시를 해 놨다. 화살표 끝에는 사람이자 학생인 하나의 인스턴스라고 쓰여 있다. 그림 오른쪽에는 스튜던트 클래스의 생성자에서 첫 줄에 수퍼 생성자를 호출하여 펄슨 클래스의 생성자를 호출하고 있다는 것을 코드와 화살표로 표현했다.

위 흐름은 new Student();를 수행했을 때 예시입니다. 물론 저 위로 계속 부모 클래스의 계보를 타게 되니 Object 클래스부터 마지막 클래스까지 이처럼 직렬적으로 연결되겠죠.

레코드 클래스의 생성자 동작 순서 (호출 순서와 다름.)

콤팩트 생성자, 전체 인자 생성자, 최초 호출된 생성자 순으로 동작한다는 것을 도식화하였다. 오른쪽에는 각 단계에 대한 코드를 써 두었다. 콤팩트 생성자에서는 오브젝트 쩜 리콰이어스논널 괄호열고 네임 괄호닫고 세미콜론. 그 다음 줄에 네임 은 네임 쩜 스트립 괄호열고 괄호닫고 세미콜론. 전체 인자 생성자에서는 특별히 추가된 코드가 없다고 쓰여 있고, 엔트리 포인트로서의 생성자에서 디스 괄호열고 또 괄호 열고 스트링 괄호 닫고 맵 쩜 겟 괄호열고 큰따옴표열고 네임 큰따옴표닫고 괄호닫고 또 괄호 닫고 세미콜론.

한편 레코드 클래스의 생성자 동작은 이런 순서를 갖습니다. 위에는 영어니까 한궁말로 봐 보죠.

  1. 콤팩트 생성자에서 파라미터를 체크·수정합니다.

  2. 그대로 전체 인자 생성자 연결되어 필드에 값을 담습니다. 이때 모든 필드를 채웁니다.

  3. 여기까지 this(...) 호출에 의한 동작이며, 이후 최초 호출되었던 생성자에서 남은 작업을 수행합니다. 남은 작업은 필드를 채우는 것과는 무관할 것입니다. (this.field = value; 동작은 이미 전체 인자 생성자가 수행했고, 모든 필드가 final이기 때문에 이제 값을 변경할 수 없음.)

그래서 실질적으로 인스턴스 전체 인자 생성자 때 이미 완성된 셈이죠.

유념할 것은 this(...) 생성자가 첫 줄에 와야 하기 때문에, this(...); 생성자에 넘겨 줄 데이터 처리가 필요하더라도 this(...);의 소괄호 내에서 모든 처리를 마쳐야 한다는 것입니다. 만약 복잡한 처리가 필요하다면 함수의 사용을 고려해 보세요.


🛠 어떻게 커스텀할까요? My Convention

용례마다 다르기 때문에 절대적인 관례로 삼을 수 없어서 '일반적인 경우'에 대한 개인적 컨벤션을 소개하려고 합니다.

🔧 Builder는 쓰는 게 신상에 좋습니다

🤷‍♀️🤔 레코드는 전체 인자 생성자(All Args Constructor)를 통해 생성되기 때문에, 나중에 필드를 추가하면 전체 인자 생성자가 쓰이는 모든 곳을 수정해야 할 수도 있습니다. 기존에 쓰이던 생성자는 더 이상 없기 때문이죠.

🚫 아니면 기존에 쓰이던 생성자를 명시적으로 작성하고 this(...);로 새 전체 인자 생성자에게 값을 넘겨 인스턴스를 생성하도록 임시 방편을 둘 수도 있겠죠. 이렇게 필드가 또 추가되면 생성자를 또 추가하고, 필드가 또 추가되면 생성자를 또 추가하고 그렇게 젠가를 쌓아도 됩니다. 🤦‍♀️🤦‍♂️ 레코드로 편리함을 누리다 보니 종속성을 높인 것이죠.

// 🚫 Not Suggested Pattern 🚫

public record EditedRecord(
        String field, // 원래 이것만 있었다가
        String newField, // 어느날 이게 추가되고
        String newNewField // 다른 날 이것도 추가됐다면
) {
    // old old constructor: 처음 레코드 때 사용되었던 생성자.
    public EditedRecord(String field) {
        this(field, null, null);
    }

    // old constructor: 얼마 전까지 전체 인자 생성자로 사용되었을 생성자
    public EditedRecord(String field, String newField) {
        this(field, newField, null);
    }

    // 나중에 필드를 또 늘린다면 이제 세 필드에 대한 생성자도 작성해야 하겠죠. 🤦‍♀️🤦‍♂️
    // public EditedRecord(String, String, String) {}
}

✌️ 또는 이렇게 빌더를 다는 습관을 써도 됩니다.

import lombok.Builder;

@Builder
public record SignUpResponseDto(String token) {}

이러면 나중에 필드를 추가해도 멀쩡합니다! 👍

이렇게만 하면 추가된 필드는 모두 null로 담기겠죠. (전체 인자 생성자는 빌더가 알아서 사용하니까요.)

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import lombok.Builder;

@Builder
public record SignUpResponseDto(
        @JsonProperty("access_token")
        @JsonInclude(Include.NON_EMPTY)
        String token,

        @JsonInclude(Include.NON_DEFAULT)
        Boolean shouldPass2Fa
) {}

기존에 이 레코드를 사용하고 있던 곳에서 빌더를 사용했다면, 추가된 필드를 무시할 수 있습니다.

// 👍✨ 새 필드를 추가해도 기존 코드를 반드시 고칠 필요는 없습니다.
return SignUpResponseDto.builder()
        .token("THIS IS AN EXAMPLE TOKEN")
        .build();

// 나중에 추가된 필드 shouldPass2Fa는 이때 `null`로 처리되죠.

🏗✨ 빌더 + 콤팩트 생성자로 기본값 주기

원한다면 콤팩트 생성자를 통해 기본값을 할당할 수 있습니다. 콤팩트 생성자의 주요 활용 중 하나입니다.

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;

@Builder
public record SignUpResponseDto(
        @JsonProperty("access_token")
        @JsonInclude(Include.NON_EMPTY)
        String token,

        @JsonInclude(Include.NON_DEFAULT)
        Boolean shouldPass2Fa
) {
    public SignUpResponseDto { // 콤팩트 생성자를 통해 기본값 추가
        if (shouldPass2Fa == null) {
            shouldPass2Fa = false; // 원한다면 이런 대입을 추가할 수 있습니다.
        }
    }
}

🧐 한국인 바쁜 사수 특 "모르겠으면 이렇게 하세요.": 모르겠으면 래퍼 클래스 쓰세요!

우리는 참조 타입이 필요하거나, nullable 취급이 필요할 때 래퍼 클래스를 사용할 수 있죠. 예를 들어 Nullable이 필요하면 Integer 타입을 사용하고, Not null이면 int 타입을 사용할 수도 있어요.

🙋‍♀️ 그런데 너무 강한 제약으로 나중에 작업이 불편할 일이 없도록! 타입은 항상 Integer로 정하고, null 여부 등 제약은 콤팩트 생성자에서만 관리하는 건 어떨까요?

public recrod MyRecord(String name, Integer age) {
    public MyRecord {
        Objects.requireNonNull(name);
        Objects.requireNonNull(age);
    }
}

나중에 이 필드들에 대한 제약을 변경해서 nullable로 취급하거나, 아니면 기본값을 부여하기로 정책을 추가했다면, 기존에 기본 타입보다는 래퍼 클래스를 사용해 두었을 때 제약을 훨씬 편하게 수정할 수 있을 겁니다.

public recrod MyRecord(String name, Integer age) {
    public MyRecord {
        Objects.requireNonNull(age);

        if (name == null || name.isBlank()) {
            name = "noname";
        }

        name = name.strip();
    }
}

제약 조건 관리 외에도 참조 타입으로 관리하는 이점이 더 있습니다. 다음은 몇 가지 예시입니다.

  • 데이터를 JSON 문자열로 직렬화할 때 일부 데이터가 JSON 문자열에서 자동으로 제외되도록, 애노테이션이나 메시지 포매터를 통해 작업해 둔다면 기본 타입보다는 참조 타입이 더 많은 기술과 호환되며 관리하기 편합니다.
    (예를 들어 gson은 기본적으로 null 데이터를 직렬화에서 제외합니다. jacksonnull이거나 기본값이면 제외하는 옵션을 추가할 수 있습니다. 두 기술 중 무엇을 쓰더라도 null일 때 제외할 수 있으니 null로 제어할 가능성을 열어 두는 참조 타입이 호환성에서 낫겠죠.)

  • 반대로 역직렬화를 하거나 또는 빌더 등 추가적인 수단을 통해 생성할 때도 기본 타입의 기본값(0, 0.0, false, '\0' 등)이 아닌 다른 기본값을 원한다면, 파라미터로 전달된 값이 있는지 없는지 null로 구분하고, null일 때 원하는 기본값을 대입하는 것이 좋습니다. (예를 들어, null일 때 Integer 데이터의 기본값을 -1로 하기를 원할 수 있습니다.)

반면 어떤 분들은 더욱 강한 제약을 선호해서 기본 타입을 사용하려고 할 수도 있습니다. 베스트 프랙티스는 항상 관점에 따라 바뀔 수 있습니다.


💿 Record 이럴 때 쓰세요!

  • DTO

    요즘은 데이터 전달 구간에서 일시적으로 객체를 생성한 후 전달을 완료하면 역할을 다하도록 구성하곤 합니다. 이때 불변 객체는 전달 과정에서 변경이 없기 때문에 데이터의 안정성과 신뢰도가 높습니다. 예를 들어 여러 스레드에 여러 작업으로 전달될 때 이 객체의 어떤 필드도 변경될 리 없다고 믿을 수 있죠.

  • Properties Bean

    우리는 @Value(...)의 대체재로 @ConfigurationProperties(...)를 적극적으로 사용할 수 있습니다. 레코드에 대한 바인딩을 지원하고 있기 때문에 레코드로 작성하면 편리합니다. 주로 *.yml, *.properties 파일로 작성하는 구성 속성 파일에서 케밥 케이스(kebab-case)로 작성하고 레코드의 필드에는 카멜 케이스(camelCase)로 작성하면 자동으로 바인딩됩니다.

  • Projection

    우리는 Spring Data JPA를 사용할 때도 필요한 컬럼만 골라서 조회하고 싶을 수 있습니다. 레코드 이전에는 이때 프로젝션이라는, 테이블의 일부 컬럼을 떼어 놓은 개념을 적용하기 위해서 인터페이스를 사용하여 하나하나 getter로 작성하여야 했습니다. 레코드에서는 그럴 필요 없이 컬럼 이름만 카멜 케이스로 작성해 주면 됩니다. 타입만 맞추구요.

❌ 이럴 때 삼가세요!

JPA 엔티티 등 Persistence Entity로는 부적합합니다. 일반적으로 프록시를 생성하여 동작할 때 알맞지 않습니다. 불변성 때문에 프록시에서의 핸들링에 제약이 발생하기 때문이죠.


자료는 모두 DALL·E, excalidraw, Power Point 등을 통해 스스로 만들었습니다. (그래서 영어가 어색할 수 있습니다.) 어색한 영어 문장은 남대문 열렸다고 알려 주듯 살짝 말씀해 주시면 얼른 고쳐 보겠습니다. 그러면 다들 모쪼록 착한 개발 하세요.