Java Record - Spring에서의 사용 사례와 함께

Gongmeda·2023년 11월 8일
4
post-thumbnail

Record란?

Java Record는 bolierplate code를 줄이는 것을 목표로 하는 특별한 형태의 클래스 선언 방법입니다.

Java 14에서 소개되고 Java 16부터 정식 스펙으로 도입되었습니다.

Record의 특징

구조

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가 제공해주는 기능들을 살펴보면 다음과 같습니다.

  1. 상속 불가능 처리 (final class)
  2. 필드 private final 처리 및 Setter 미구현을 통한 불변성 제공
  3. Getter 구현
  4. equals() 구현
  5. hashCode() 구현
  6. 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과 다른 Getter 메소드명

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());

컴팩트 생성자 (Compact Constructor)

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);
    }
}

그 외

  • 다른 클래스는 상속 받을 수 없지만, 인터페이스를 구현할 수는 있음
  • static 메소드, static 필드 선언 가능
  • 중첩 클래스 사용 가능
  • 제너릭 타입으로 지정 가능

Spring에서 Record의 사용 사례

Configuration Properties

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;
	
    // ...
}

Request / Response DTO

기존에 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();
    }
}

참고

profile
백엔드 깎는 장인

0개의 댓글