Post

자바의 신문물 Record

자바의 신문물 Record

개요

image

어느샌가부터 자바에는 Record라는 신기한 타입이 생겼다. 처음에는 무슨 음반(?) 이라고 생각했으나, 다시 생각해보니 Row의 의미를 가진 타입이라고 생각했다.

컴퓨터 과학에서 레코드(record, struct)는 기본적인 자료 구조이다. 데이터베이스나 스프레드시트)의 레코드는 보통 로우(row)라고 부른다. 레코드는 각기 다른 자료형에 속할 수 있는 필드의 모임이며, 보통 고정 숫자나 시퀀스로 이루어져 있다. 레코드의 필드들은 특히 객체 지향 프로그래밍에서 멤버(member)로도 부른다.

위키백과 레코드

예를 들면, 파이썬에 있는 tuple과 유사한 자료구조이다. 이름은 record이지만, 자바이기 때문에 그 기반은 class이다. record는 자바 14에서 처음 베타 형태로 공개되었고, 자바 16에서 정식 스펙이 되었다.

어디에 쓰는 물건인고?

개발하다보면 수 많은 DTO와 VO를 만든다. 이 과정에서, 정말 많은 보일러플레이트 코드를 작성하기도 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Person {  
    private final String name;  
    private final String address;  
    private final int age;  
  
    public Person(String name, String address, int age) {  
        this.name = name;  
        this.address = address;  
        this.age = age;  
    }  
  
    public String getName() {  
        return name;  
    }  
  
    public String getAddress() {  
        return address;  
    }  
  
    public int getAge() {  
        return age;  
    } 
  
    @Override
    public boolean equals(Object object) {  
        if (this == object) return true;  
        if (object == null || getClass() != object.getClass()) return false;  
        Person person = (Person) object;  
        return age == person.age && Objects.equals(name, person.name) && Objects.equals(address, person.address);  
    }
  
    @Override
    public int hashCode() {
        return Objects.hash(name, address, age);
    } 
}

불변의 Person 객체를 만들려면 기본적으로 이만큼의 코드가 필요하다. 물론 Lombok 라이브러리에 의존하면 다음과 같이 줄일 수 있다.

1
2
3
4
5
6
7
8
9
@Getter  
@RequiredArgsConstructor  
@EqualsAndHashCode
@ToString
public class Person {  
    private final String name;  
    private final String address;  
    private final int age;    
}

Lombok에 의존하여 획기적으로 줄였다. Lombok의존하여 말이다. Lombok 라이브러리에 의존해서 코드를 짜는 것이 너무 당연한 것이 되어버렸고, 코틀린에서는 Data class를 통해 이러한 복잡성, 의존성을 없애버렸다.

코틀린 문법을 구경할 때 부러웠던 것이 Data class였는데, 자바에도 상응하는 문법이 나왔다.

Personrecord로 똑같은 기능을 하도록 만들면 어떻게 되는 지 코드로 알아보자.

1
public record Person(String name, String address, int age) {}

끝이다.

사용

1
2
public record Person(String name, String address, int age) {  
}

코드에는 아무것도 없어보이지만, LombokGetter, RequiredArgsConstructor, EqualsAndHashCode, ToString에 해당하는 코드들이 모두 자동으로 작성된다. 즉, 우리 눈에는 보이지 않지만 컴파일 시에 바이트 코드로 만들어준다.

Getter

1
2
3
4
5
6
7
8
9
10
Person person = new Person("leoh", "Seoul", 20);  
  
System.out.println("name = " + person.name());  
System.out.println("address = " + person.address());  
System.out.println("age = " + person.age());

### 결과
name = leoh
address = Seoul
age = 20

우리는 보통 Getter를 생성할 떄에는 get변수명 이라는 메서드명을 컨벤션으로 사용한다. record는 변수명 그 자체를 getter메서드명으로 사용한다. record는 불변이고, Setter는 있을 수 없기 때문에 그렇지 않을까 예상해본다.

Equals, HashCode

효과적인 불변 객체의 비교를 위해, equalshashcode를 오버라이드 해줘야 한다. record를 사용하면 이 역시 컴파일할 때 자동으로 생성해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Person person1 = new Person("leoh", "Seoul", 20);  
Person person2 = new Person("leoh", "Seoul", 20);  
Person person3 = new Person("nimoh", "Seoul", 21);  
  
System.out.println(person1.equals(person2));
System.out.println(person1.hashCode() == person2.hashCode()); 
System.out.println(person1.equals(person3));
System.out.println(person1.hashCode() == person3.hashCode());

### 결과
true
true
false
false

recordequalshashcode를 오버라이딩 하지 않았지만, 모든 필드가 동일한 경우 동등하다는 equals가 오버라이딩 되어있다.

ToString

자바에서 클래스를 만들면, 기본적으로 toString()이 존재한다. 이 기본 메서드는 인스턴스 그 자체에 대한 정보를 출력한다.

1
2
3
4
5
6
PersonNoRecord personNoRecord = new PersonNoRecord("leoh", "Seoul", 20);  // record X
  
System.out.println(personNoRecord);

### 결과
com.leoh.javaspringpractice.record.pojo.nonrecords.PersonNoRecord@107088f1

record는 이 toString()을 오버라이딩 해준다.

1
2
3
4
5
Person person1 = new Person("leoh", "Seoul", 20); // record O
System.out.println(person1);

### 결과
Person[name=leoh, address=Seoul, age=20]

Constructor

Compact Constructor

앞서 얘기했듯이, record는 모든 필드를 final로 눈에 보이지 않는 생성자를 통해 매핑해준다. 이 때, 매핑할 생성자는 우리가 자바코드로 존재하지 않기 때문에 값에 대한 유효성 검사를 할 수 없다.

record는 이를 위해 Compact Constructor라는 고유 생성자 문법을 사용할 수 있다. Compact Constructor를 사용해 기존 자바의 생성자와는 다르게, 매개변수를 넣을 수 없다. 이미 매개변수가 들어왔다고 치고, 실제 초기화 로직 수행 전 데이터 전처리를 도와준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public record Person(String name, String address, int age) {  
  
    public Person {  
        if (!StringUtils.hasText(name)) {  
            name = "unknownName";  
        }  
        if (!StringUtils.hasText(address)) {  
            address = "unknownAddress";  
        }  
        if (age < 0) {  
            age = 0;  
        }    

		// 초기화 실행
    }  
  
}

Constructor

image

당연하게도 위와 같이 매개변수를 모두 포함하는 생성자는 만들 수 없다. 눈에 보이지는 않지만, 이미 존재하는 생성자이다. 하지만, 매개변수가 다르다면 생성자를 생성할 수 있다.

1
2
3
public Person(String name) {  
    this(name, "unknownAddress", 0);  
}

Static

record에서 static변수와 메서드 역시 사용할 수 있다.

static 변수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public record Person(String name, String address, int age) {  
  
    public static final String UNKNOWN_NAME = "unknownName";  
    public static final String UNKNOWN_ADDRESS = "unknownAddress";  
  
    public Person {  
        if (!StringUtils.hasText(name)) {  
            name = UNKNOWN_NAME;  
        }  
        if (!StringUtils.hasText(address)) {  
            address = UNKNOWN_ADDRESS;  
        }  
        if (age < 0) {  
            age = 0;  
        }    }  
  
}

static 메서드

1
2
3
public static Person emptyPerson() {  
    return new Person(UNKNOWN_NAME, UNKNOWN_ADDRESS, 0);  
}

메서드

그냥 메서드도 사용할 수 있다.

1
2
3
4
5
6
public PersonEntity toEntity() {  
    return PersonEntity.builder()  
            .name(name)  
            .address(address)  
            .age(age).build();  
}

스프링에서 record

나는 주로 DTO를 requestDTOresponseDto의 용도로 사용한다. record를 처음 접하고 걱정했던 것이, 컨트롤러에서 record의 직렬화, 역직렬화를 잘 호환해줄까? 라는 걱정이었다.

1
public record MemberRequest(String name, String email, String userId, int age) {}
1
2
3
4
5
6
7
8
9
@GetMapping("/basic")  
public MemberResponse getRecord(MemberRequest memberRequest) {  
    return new MemberResponse(memberRequest.name(), memberRequest.userId(), memberRequest.age());  
}  
  
@PostMapping("/basic")  
public MemberResponse postRecord(@RequestBody MemberRequest memberRequest) {  
    return new MemberResponse(memberRequest.name(), memberRequest.userId(), memberRequest.age());  
}

잘 호환된다.

결론

record는 이전부터 존재에 대해 알고는 있었지만, 크게 관심을 가지지 않아 뭔 지도 잘 몰랐고 사용해본 적이 없었다. 이번에 오프라인에서 모각코를 진행하면서 같이 한 분이 record에 대해 간략하게 알려주셨다. 내 사이드 프로젝트에서 DTO를 만들 때마다 Lombok 으로 붙일 애노테이션을 생각하는 게 참 귀찮았는데, record를 알게되어서 참 다행이라 생각했다.

조금 아쉬운 점은 record는 상속이 안된다. 또, 불변객체이기 때문에 당연할 수도 있는데 빌더패턴을 사용할 수 없다.

record를 알게된 이상 굳이 기존 자바 클래스로 DTO를 만들 필요가 전혀 없어졌다.자바 17이상을 사용하는 코드에서는 웬만하면 DTO에 record를 사용하려고 한다. 내가 사용하는 기술이 업데이트되고, 버전이 올라갔을 때 무엇이 추가되었고 뭐가 좋아졌는 지 확인해보는 습관을 가질 필요성을 느꼈다.

Reference

  1. https://www.baeldung.com/java-record-keyword
  2. https://www.baeldung.com/java-record-vs-lombok
This post is licensed under CC BY 4.0 by the author.