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
클래스부터 마지막 클래스까지 이처럼 직렬적으로 연결되겠죠.
레코드 클래스의 생성자 동작 순서 (호출 순서와 다름.)
한편 레코드 클래스의 생성자 동작은 이런 순서를 갖습니다. 위에는 영어니까 한궁말로 봐 보죠.
콤팩트 생성자에서 파라미터를 체크·수정합니다.
그대로 전체 인자 생성자로 연결되어 필드에 값을 담습니다. 이때 모든 필드를 채웁니다.
여기까지
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
데이터를 직렬화에서 제외합니다.jackson
은null
이거나 기본값이면 제외하는 옵션을 추가할 수 있습니다. 두 기술 중 무엇을 쓰더라도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 등을 통해 스스로 만들었습니다. (그래서 영어가 어색할 수 있습니다.) 어색한 영어 문장은 남대문 열렸다고 알려 주듯 살짝 말씀해 주시면 얼른 고쳐 보겠습니다. 그러면 다들 모쪼록 착한 개발 하세요.