Java Record는 bolierplate code를 줄이는 것을 목표로 하는 특별한 형태의 클래스 선언 방법입니다.
Java 14에서 소개되고 Java 16부터 정식 스펙으로 도입되었습니다.
public record TestRecord(String name, int age) {}
Record는 일반적인 클래스와 다르게 선언과 함께 필드(컴포넌트)를 나열하여 선언합니다.
IntelliJ에서는 Record를 일반 클래스로 변환하는 리팩토링 기능을 제공합니다.
위 TestRecord
를 변환해보면 아래와 같습니다.
public final class TestRecord {
private final String name;
private final int age;
public TestRecord(String name, int age) {
this.name = name;
this.age = age;
}
public String name() {
return name;
}
public int age() {
return age;
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
var that = (TestRecord) obj;
return Objects.equals(this.name, that.name) &&
this.age == that.age;
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
@Override
public String toString() {
return "TestRecord[" +
"name=" + name + ", " +
"age=" + age + ']';
}
}
변환된 클래스 코드를 통해 Record가 제공해주는 기능들을 살펴보면 다음과 같습니다.
equals()
구현hashCode()
구현toString()
구현여기서 추가로 살펴봐야 할 부분 몇 가지를 짚고 넘어가겠습니다.
equals()
동작 방식일반 클래스는 기본적으로 객체의 주소값을 기준으로 equals()
가 구현되어 있는 반면, Record는 이를 Override하여 필드의 값을 모두 비교하도록 구현한 것을 확인할 수 있습니다.
이는 아래의 코드를 통해 실제로 확인할 수 있습니다.
public final class TestClass {
private final String name;
private final int age;
public TestClass(String name, int age) {
this.name = name;
this.age = age;
}
public String name() {
return name;
}
public int age() {
return age;
}
}
TestClass class1 = new TestClass("name", 1);
TestClass class2 = new TestClass("name", 1);
System.out.println(class1.equals(class2)); // false
TestRecord record1 = new TestRecord("name", 1);
TestRecord record2 = new TestRecord("name", 1);
System.out.println(record1.equals(record2)); // true
Lombok은 JavaBeans API specification에서 제공하는 bean-standard 표준을 따르는 get~
prefix를 붙이는 네이밍을 기본으로 사용하고 있습니다. (설정을 통해 prefix를 해제할 수는 있습니다)
하지만 Record의 경우 이를 따르지 않습니다.
위에서 변환된 코드를 보면 Getter가 모두 필드명과 동일하게 생성된 것을 확인할 수 있습니다.
따라서 아래와 같이 사용해야 함을 유의해야 합니다.
TestRecord record = new TestRecord("name", 1);
System.out.println(record.age());
System.out.println(record.name());
Record의 표준 생성자를 호출하면 자동으로 함께 실행되는 코드를 작성할 수 있는 기능입니다.
표준 생성자와는 달리 컴팩트 생성자 내부에서는 인스턴스 필드에 접근할 수 없다는 특징이 있습니다.
아래와 같이 validation을 위한 용도로 사용할 수 있습니다.
public record TestRecord(String name, int age) {
public TestRecord {
if (age < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
}
}
TestRecord record = new TestRecord("name", -1);
/* IllegalArgumentException 예외 발생
Exception in thread "main" java.lang.IllegalArgumentException: Age cannot be negative
at org.example.TestRecord.<init>(TestRecord.java:6)
at org.example.Main.main(Main.java:5)
*/
Java 17 부터는 언어 차원에서 Reflection을 사용한 필드 조작을 방지하고 있습니다. 이는 Record의 불변성을 더욱 강화시켜주는 특징입니다.
아래의 코드로 클래스와 Record의 차이를 확인할 수 있습니다.
TestClass clss = new TestClass("name", 1);
Class<TestClass> cls = (Class<TestClass>) Class.forName("org.example.TestClass");
Field clsName = cls.getDeclaredField("name");
clsName.setAccessible(true);
clsName.set(clss, "new name");
System.out.println(clss.name()); // new name
TestRecord record = new TestRecord("name", 1);
Class<TestRecord> recordClass = (Class<TestRecord>) Class.forName("org.example.TestRecord");
Field recordName = recordClass.getDeclaredField("name");
recordName.setAccessible(true);
recordName.set(record, "new name"); // IllegalAccessException 예외 발생
System.out.println(record.name());
완전한 불변성을 보장을 하기 위한 팁
이러한 특징에도 불구하고 불변성이 깨질 수 있는 케이스가 있습니다.
객체를 필드로 주입 받는 경우인데요. 대표적으로 Collection 타입의 객체를 주입 받는 경우가 있습니다.
public record Parent(String name, int age, List<String> children) {}
List<String> children = new ArrayList<>();
children.add("john");
Parent parent = new Parent("name", 1, children);
parent.children().add("jane");
System.out.println(parent.children()); // [john, jane]
이렇듯 객체의 참조를 받아와 조작하면 불변성이 깨질 수 있습니다.
이를 방지하기 위해 아래와 같이 표준 생성자를 Override하고, 직접 Collection을 불변으로 처리하여 방지할 수 있습니다.
public record Parent(String name, int age, List<String> children) {
public Parent(String name, int age, List<String> children) {
this.name = name;
this.age = age;
this.children = Collections.unmodifiableList(children);
}
}
Configuration 속성들을 주입 받아 사용할 수 있습니다.
불변성을 보장하며 nested된 구조여도 구성하기가 편하기 때문에 좋습니다.
# application.yml
app:
name: TestApp
password: 1234
page:
name: PageName
@ConfigurationProperties(prefix = "app")
public record ConfigRecord(String name, int password, Page page) {
public record Page(String name) {}
}
@RestController
public class TestController {
@Autowired
ConfigRecord configRecord;
// ...
}
기존에 Lombok을 사용할 때와 다르게 불필요한 어노테이션에 대한 걱정을 없앨 수 있습니다.
직렬화, 역직렬화에 모두 사용 가능하며 Validation 어노테이션도 정상적으로 동작하기 때문에 적절한 용도입니다.
또한, IntelliJ에서 빌드툴을 IntelliJ IDEA로 설정할 시 발생하는 DTO 클래스에 기본 생성자가 없을 시에 @JsonCreator
와 @JsonProperty
가 필요한 문제 또한 발생하지 않아 매우 최적화된 용도라 할 수 있습니다.
public record RequestRecord(
@NotNull @Size(min = 4) String name,
@NotNull @Email String email
) {}
@RestController
public class TestController {
@PostMapping("test1")
public String postRequest(@Valid @RequestBody RequestRecord requestRecord) {
return requestRecord.toString();
}
}