프로그래밍 언어/Java

[Java] Java 14, Record 키워드

코딩하는흑구 2022. 10. 5. 14:45

객체간에 변경할 수 없는(immutable) 데이터를 전달하는 것은 가장 흔하게 웹 어플리케이션을 개발하면서 만날 수 있는 작업입니다. 

Java 14 이전에는 boilerplate code 가 포함된 클래스를 만들어야 했으며 이는 사소한 실수와 복잡한 의도 등에 취약했습니다. Java14가 출시되면서 이제 record 키워드를 사용하여 이런 문제들을 해결할 수 있었습니다.

 

Record

record 키워드 사용하기 이전

대부분의 많은 상황에서 데이터를 전달하는 클래스는 데이터베이스에서 데이터를 꺼내와서 http 통신 너머의 유저에게 데이터를 전달하여 보여주는 역할을 하게 됩니다. 대부분 말씀하시는 DTO가 이런 역할로 볼 수 있습니다.

 

많은 경우 이러한 데이터를 변경하여 전달하기보다 해당 디비 raw 데이터를 가공하여 다른 컬럼으로 값을 집어넣고 또 다른 DTO에 래핑하여 전달하거나 각 Layer마다 컨버팅 작업을 거치게 됩니다.

 

그때 중요한 점이, 해당 객체의 정보가 중간에 불필요하게 변경이 되게 된다면 불변성을 보장할 수 없어 해당 데이터를 받는 클라이언트 개발자 혹은 사용자에게 오류처럼 노출될 수 있다는 것입니다.

 

이러한 불변성을 수행하기 위해서 대부분 아래의 작업을 진행합니다.

  1. private final 필드 추가.
  2. 각 필드에 대한 getter 접근 메소드
  3. 각 필드에 해당하는 인자가 있는 public 생성자
  4. 모든 필드가 일치할 때 동일한 클래스의 객체에 대해 true를 반환하는 equals 메소드
  5. 모든 필드가 일치할 때 동일한 값을 반환하는 hashCode 메소드
  6. 클래스의 이름과 각 필드의 이름과 해당 값을 포함하는 toString 메소드

예를 들어 아래의 Person 클래스를 보시죠.

public class Person {

    private final String name;
    private final String address;

    // 혹은 빌더 사용.
    public Person(String name, String address) {
        this.name = name;
        this.address = address;
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, address);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        } else if (!(obj instanceof Person)) {
            return false;
        } else {
            Person other = (Person) obj;
            return Objects.equals(name, other.name)
              && Objects.equals(address, other.address);
        }
    }

    @Override
    public String toString() {
        return "Person [name=" + name + ", address=" + address + "]";
    }

    // 이후로 getter 클래스.
}

클래스를 보게되면 두가지 문제점을 볼 수 있습니다.

  1. 많은 boilerplate code
  2. 클래스의 목적의 모호함(너무 많은 메소드 내용 때문에 클래스의 목적이 정보전달인지 알기 어려움)

이러한 클래스를 설계할때마다 너무 많은 메소드들을 작성해야 합니다. 근데 그 작성이 마치 받아쓰기만큼 반복적이라는 것이 문제입니다. Person이 아닌 다른 클래스 수백개를 설계할 때도 동일한 작업을 해야할텐데.. 매우 귀찮은 일이 아닐 수 없습니다..

 

인텔리제이 IDE 를 활용하게 된다면 별도로 구현을 작성해주는 기능이 있겠지만 어디까지나 현재 기준으로만 작성되어 추가 필드가 생기게 된다면 이를 다시 작성해주어야 하므로 매우 비효율적입니다.

 

그나마 Lombok이라는 라이브러리를 활용하여 이러한 boilerplate code를 줄일 수 있지만 여전히 클래스 자체는 정보를 담는 용도인지 다른 용도가 있는지 코드의 흐름을 보지 않는 이상 키워드로써 알기는 어렵습니다.(단순히 class 선언만 되어있기 때문에)

 

Lombok 사용 예)

@EqualsAndHashCode
@ToString
@AllArgsConstructor
@Getter
public class Person {
    private final String name;
    private final String address;
}

 

또한 이러한 boilerplate code의 노출때문에 실제 이 클래스의 목적이 정보전달인지 비즈니스 로직이 포함된 메소드가 있는 클래스인지 매우 모호합니다.

 


record 키워드 사용하기 이후

Java 14 이후로는 반복적인 데이터 클래스를 record 키워드를 사용하여 대체할 수 있습니다. record는 immutable 한 데이터 클래스이며 필드의 데이터 타입과 이름만 필요로 합니다.

 

public record Person (String name, String address) {}

record 를 선언하는 방법은 다음과 같습니다. class 키워드 대신 record 키워드로 변경되었습니다. 클래스명 옆에 마치 코틀린 생성자처럼 필드를 선언해줍니다.

 

equals, hashCode, toString, public 생성자를 생성해주면서, 변수들은 private final 키워드로 취급됩니다.

 

1) 생성자

record를 사용하면 각 필드에 대한 인수가 생성된 public 생성자가 생성됩니다.

Person 클래스의 경우 생성자는 아래와 선언된 것과 똑같습니다.

public Person(String name, String address) {
    this.name = name;
    this.address = address;
}

사용하는 방법도 같습니다.

public static void main(String[] args) {
    Person person = new Person("흑구", "흑구 주소");
}

 

2) getter

이전 getter 메소드는 getXXX 처럼 get이라는 prefix를 포함하여 호출했어야 하지만 record는 필드명과 동일한 메소드명으로 호출할 수 있습니다.

public static void main(String[] args) {
    Person person = new Person("흑구", "흑구 주소");

    System.out.println(person.name());
    System.out.println(person.address());
    
    // 흑구
    // 흑구 주소
}

 

3) equals 

또한 equals 메소드가 생성됩니다. 인자로 넘어온 객체가 동일한 객체이고 모든 해당 필드의 값이 동일하다면 true를 반환합니다.

public static void main(String[] args) {
    String name = "흑구";
    String address = "흑구 주소";
    Person person1 = new Person(name, address);
    Person person2 = new Person(name, address);

    System.out.println(person1.equals(person2));
    System.out.println(Objects.equals(person1, person2));
    
    // true
    // true
}

자체 equals와 Objects 유틸 클래스의 equals 메소드가 동일하게 동작한다.

public static void main(String[] args) {
    String name = "흑구";
    String address = "흑구 주소";
    Person person1 = new Person(name, address);
    Person person2 = new Person(name, address + "2");

    System.out.println(person1.equals(person2));
    System.out.println(Objects.equals(person1, person2));

    // false
    // false
}

다른 값을 가진다면 false를 리턴한다.

 

 

4) hashCode

equals 메소드와 유사하게 해당 hashCode 메소드도 생성됩니다. hashCode 메소드는 두 객체의 모든 필드값이 일치하는 경우 두 Person 객체에 대해 동일한 값을 반환합니다.

public static void main(String[] args) {
    String name = "흑구";
    String address = "흑구 주소";
    Person person1 = new Person(name, address);
    Person person2 = new Person(name, address);

    System.out.println(person1.hashCode());
    System.out.println(person2.hashCode());
}

필드값중 하나라도 다르다면 hashCode 값이 달라집니다.

 

5) toString

record의 이름을 포함하는 문자열을 생성하는 toString 메소드 입니다. 결과는 아래와 같습니다.

public static void main(String[] args) {
    String name = "흑구";
    String address = "흑구 주소";
    Person person = new Person(name, address);

    System.out.println(person);
    System.out.println(person.toString());

    // Person[name=흑구, address=흑구 주소]
    // Person[name=흑구, address=흑구 주소]
}

 

 


record 생성자

모든 인수를 인자를 받는 public 생성자가 동작하는 동안 생성자 실행에 대한 커스터마이징을 할 수 있습니다. 이러한 커스터마이징은 주로 유효성 검사를 하기 위한 작업으로 최대한 심플하게 넘겨야 합니다.

 

예를 들어, 아래처럼 Person 레코드에 제공된 name과 address가 null값이 아닌지 확인할 수 있습니다.

public record Person(String name, String address) {
    public Person {
        Objects.requireNonNull(name);
        Objects.requireNonNull(address);
    }
}

 

 

인자 목록을 제공하여 새로운 생성자를 만들 수 있습니다.

public record Person(String name, String address) {
    public Person {
        Objects.requireNonNull(name);
        Objects.requireNonNull(address);
    }

    public Person(String name) {
        this(name, "Unknown");
    }
    
}

 

클래스 생성자와 마찬가지로 필드는 this 키워드를 사용하여 참조할 수 있으며 파라미터는 필드이름과 일치합니다.

 

생성된 public 생성자와 동일한 파라미터를 선언하여 생성자를 생성할 수는 있지만 각 필드를 수동으로 처리해줘야합니다.

public record Person(String name, String address) {
    public Person(String name, String address) {
        this.name = name;
        this.address = address;
    }
}

(굳이..)

 

또한 파라미터가 없는 생성자를 선언하고 기본 public 생성자와 일치하는 파라미터 목록이 있는 생성자를 선언하면 컴파일 에러가 발생합니다. 따라서 아래의 코드는 컴파일이 되지 않습니다.

public record Person(String name, String address) {
    public Person {
        Objects.requireNonNull(name);
        Objects.requireNonNull(address);
    }

    public Person(String name, String address) {
        this.name = name;
        this.address = address;
    }
}

 


정적 변수 및 메소드(static 필드)

일반 Java 클래스와 마찬가지로 record 클래스에 정적(static) 변수와 메소드를 작성할 수 있습니다. 사용하는 방식은 일반 클래스와 크게 상이하지 않습니다.

public record Person(String name, String address) {
    public static String UNKNOWN_NAME = "UNKNOWN_NAME";
    public static String UNKNOWN_ADDRESS = "UNKNOWN_ADDRESS";
}

또한 클래스와 동일한 구문을 사용하여 정적 메소드를 선언할 수 있습니다. 아래의 메소드는 정적 메소드로 인스턴스를 리턴합니다.

public record Person(String name, String address) {
    public static String UNKNOWN_NAME = "UNKNOWN_NAME";
    public static String UNKNOWN_ADDRESS = "UNKNOWN_ADDRESS";
    
    public static Person unnamed(String address) {
        return new Person(UNKNOWN_NAME, address);
    }
    
    public static Person unAddress(String name) {
        return new Person(name, UNKNOWN_ADDRESS);
    }
}

record 이름을 활용하여 정적 변수와 정적 메소드 모두를 참조할 수 있습니다.

public static void main(String[] args) {
    String name = "흑구";
    String address = "흑구 주소";
    Person unNamed = Person.unnamed(address);
    Person unAddress = Person.unAddress(name);

    System.out.println(Person.UNKNOWN_NAME);
    System.out.println(Person.UNKNOWN_ADDRESS);
    System.out.println(unNamed);
    System.out.println(unAddress);
    
    //UNKNOWN_NAME
    //UNKNOWN_ADDRESS
    //Person[name=UNKNOWN_NAME, address=흑구 주소]
    //Person[name=흑구, address=UNKNOWN_ADDRESS]
}

 

 


필자 생각.

 

해당 기능이 왜 생성되었을까를 생각해보다가.. DTO 와 VO라는 개념에 대해서 생각해보기도 하고 코틀린의 data 클래스에 대해서 생각해보기도 하니 나름대로 결론을 내었다.

 

코틀린에서도 data 클래스를 생성한 이유가 별도의 목적성을 표기하기 위함이었으므로 자바에서도 이러한 클래스 사용 목적에 대한 표기를 언어적 차원에서 제공했으면 하는 수요가 있지 않았을까 싶었다.

 

논리적인 개념으로만 DTO, VO 이런식으로 데이터 클래스의 성질을 나뉘었을 뿐 물리적 언어적 차원에서 나뉜것은 아니기 때문에 다른 개발자에 의해,, 코드 수정에 의해 충분히 목적이 변질될 수 있고, 나 또한 그런상황에서 편리함에 취해 마구마구 변경했던 과거들이 있었기 때문에 해당 문법의 등장 배경을 생각해보지 않을 수 없었다.

 

완전하게 제어되지는 못하겠지만 충분히 배워 사용해보면 코드개선, 코드리뷰, 클래스의 사용용도 등등 좋은 쓰임새가 있을 것 같다.