[Java]자바 record를 entity로?

봄도둑·2022년 7월 15일
23

Spring 개인 노트

목록 보기
3/17

자바 8 이후 버전에 대해 살펴보던 중 제 관심을 가져간 하나의 키워드가 있었습니다.

그 녀석은 바로 record로 자바 14와 15에서 preview로 추가된 이후, 16버전에서 정식 스펙으로 당당히 올라왔습니다.

파이썬의 dictionary, C#의 record, kotlin의 data class처럼 특정 값 타입을 담는데 특화되었다는 record, 보면 볼수록 VO와 DTO로 쓰기 좋은 녀석처럼 보입니다.

그리고 이 글은 슬프게도 저 생각에 빠져서 또 하나의 토이 프로젝트를 박살낼 뻔한 한 사람의 실화에서 시작합니다.

1. record, 너 뭐하는 녀석이야?

1-1. record 등장 배경

본격적으로 들어가기에 앞서 DTO를 생각해봅시다. DTO를 구현하기 위해서는 getter, setter, equals, hashCode, toString 같은 데이터 처리 혹은 특정 연산을 수행하기 위해 오버라이드된 메소드를 반복해서 작성하게 됩니다.

public class Address {
    private String city;
    private String street;
    private String zipCode;

    public Address() {
    }
    
    public Address(String city, String street, String zipCode) {
        this.city = city;
        this.street = street;
        this.zipCode = zipCode;
    }

    public String getCity() {
        return city;
    }

    public String getStreet() {
        return street;
    }

    public String getZipCode() {
        return zipCode;
    }

    public void setCity(String city) {
        this.city = city;
    }

    public void setStreet(String street) {
        this.street = street;
    }

    public void setZipCode(String zipCode) {
        this.zipCode = zipCode;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Address)) return false;
        Address address = (Address) o;
        return Objects.equals(city, address.city) &&
                Objects.equals(street, address.street) &&
                Objects.equals(zipCode, address.zipCode);
    }

    @Override
    public int hashCode() {
        return Objects.hash(city, street, zipCode);
    }
}

address라는 주소에 해당하는 정보를 가지고 있는 단순한 DTO를 만든 코드입니다. 굳이 자세히 보지 않아도 보일러 플레이트 코드가 불필요하게 큰 것을 알 수 있습니다.

(※ 보일러 플레이트 코드 : 최소한의 변경(인자, 혹은 결과 타입)으로 여러 곳에서 재사용 되면 반복적으로 비슷한 형태를 가지고 있는 코드 → getter, setter, equals, hashCode, toString 등이 여기에 해당)

일반적으로 이러한 보일러 플레이트 코드는 lombok 또는 kotlin(보다 정확하게는 data class) 사용해 중복으로 발생하는 코드의 양을 줄입니다.

이렇게 비대해진 보일러 플레이트 코드는 자바가 가지고 있는 단점으로 작용합니다. 우리는 lombok이나 IDE의 도움을 받아 코드를 간결하게 만들 수 있지만, 이는 근본적으로 자바가 가지고 있는 한계를 해결하지 못합니다. 설상가상으로 구글이 kotlin을 공식 지원 언어로 채택하면서 자바의 입지가 크게 흔들리게 됩니다(구글의 공식 문서를 살펴 보면 kotlin의 예제가 먼저 작성된 다음 java 예제가 나옵니다). 자바 진영에서 느낀 위기감은 자바가 가지고 있는 한계를 극복하기 위해 자바를 업그레이드하면서 다양한 기능들을 추가하게 됩니다. 그 기능들 중 하나가 바로 record입니다.

record는 파이썬의 dictionary, C#의 record와 유사한 기능과 역할을 수행하는데, 특정 데이터와 관련 있는 필드를 묶어 놓은 자료 구조로서 기능합니다.

record의 목표는 아래와 같습니다.

  • 객체 지향의 사상에 맞게 데이터를 간결하게 표현하기 위한 방법을 제공

  • 개발자가 동작을 확장하는 것보다 불변 데이터를 모델링하는데 집중하도록 함

  • 데이터 지향 메소드를 자동으로 구현

  • 단, java beans를 대체하기 위한 기술은 아님 + 어노테이션 지향적인 코드를 생성하기 위한 기능도 아님!

  • adress라는 단순한 주소값을 갖고 있는 녀석 하나 만드는데 보일러 플레이트 코드가 비대해지는 결과를 가짐

1-2. record의 특징

1-2-1. record의 구조

public record Gundam(String name, String pilot, String series) {}

record의 구조를 살펴보겠습니다. class 선언 시 들어가는 class 대신 record를 사용합니다. 레코드명(헤더), {바디} 의 구조를 가지는데 헤더에 나열되는 필드를 컴포넌트라고 부릅니다. 위의 예제 코드로 보면 이름은 Gundam, private final 필드를 name, pilot, series를 가진 record라고 볼 수 있습니다.

컴파일러는 헤더를 통해 내부 필드를 추론하는데, 이 때 String 타입의 name, pilot, series가 있다는 것을 인식하게 됩니다. 이후 코드에 명시적으로 접근자와 생성자, toString, equals, hashCode를 선언하지 않아도 이에 대한 구현을 자동으로 제공합니다.

단, getter를 사용할 때 getName(), getPilot()로 쓰는 게 아니라 필드명, 즉 컴포넌트의 이름만 사용하면 됨. 즉, name(), pilot() 처럼 필드의 이름으로 사용합니다,

1-2-2. record 특징

  1. record는 불변 객체로 abstract로 선언할 수 없으며 암시적으로 final로 선언됩니다. 한 번 값이 정해지면 setter를 통해 값을 변경할 수 없으며 상속을 할 수 없습니다.
  2. record 내 각 필드(헤더에 나열한 컴포넌트)는 private final로 정의됩니다.
  3. 다른 클래스를 상속 받을 수 없습니다만, 인터페이스로는 구현이 가능합니다. (extends : X, implements : O)
  4. 레코드 내부에 멤버 변수(인스턴스 필드)를 선언할 수 없습니다. 그러나 static 변수는 생성이 가능합니다. 이는 헤더에서 정의한 멤버만을 record에서 관리하기 위함입니다.
  5. 위의 4가지 주요 특징을 제외하고는 자바의 클래스 개발과 동일하게 사용할 수 있습니다.
    1. new 키워드를 통해 객체화 가능
    2. static 메소드, static 필드 선언 가능
    3. 중첩 클래스 사용 가능 및 제너릭 타입으로 지정 가능

1-2-3. body의 재정의

record의 body 부분은 자동으로 생성된 메소드 혹은 새로운 메소드를 추가로 작성할 수 있습니다. 쉽게 생각해 클래스 내부에 작성한다고 보시면 됩니다.

public record Gundam(String name, String pilot, String series) {

    @Override
    public String toString() {
        return "Gundam{" +
                "override_name='" + name + '\'' +
                ", override_pilot='" + pilot + '\'' +
                ", override_series='" + series + '\'' +
                '}';
    }

	public boolean isFreedom() {
        return this.name.contains("freedom");
    }
}

1-2-4. 컴팩트 생성자

컴팩트 생성자는 생성자 매개 변수를 받는 부분이 사라진 형태입니다. 개발자가 명시적으로 인스턴스 필드를 초기화 하지 않아도 컴팩트 생성자의 마지막 부분에 초기화 구문이 자동으로 삽입됩니다. 일반적으로 사용하는 표준 생성자와는 달리 컴팩트 생성자 내부에서는 인스턴스 필드에 접근할 수 없습니다.

이러한 이유로 컴팩트 생성자에는 컴포넌트로 들어온 값을 불변으로 만들거나 불변식이 만족하는지(유효성 체크, 예를 들어 null check) 등의 작업을 하기에 적합합니다.

Gavin Bierman이 말한 컴팩트 생성자의 선언 의도는 생성자 본문에 검증 및 정규화용 코드만 넣어야 한다는 것으로 나머지 초기화 코드는 컴파일러가 자동으로 수행해 개발자는 검증에만 집중할 수 있도록 한 것에 있습니다.

public record Gundam(String name, String pilot, String series) {

    public Gundam {
        Objects.requireNonNull(name);
        Objects.requireNonNull(pilot);
        Objects.requireNonNull(series);
    }
}

이렇게 선언한 컴팩트 생성자는 일반 생성자 쓰듯이 똑같이 사용하면 됩니다.

Gundam freedom = new Gundam("freedom_gundam", "kira yamato", "SEED");

여기까지 보면 이 record라는 녀석은 jpa의 entity로 쓸 수 있지 않을까요? 데이터 전송 객체를 만들 때 나오는 자바의 한계를 극복하는 게 목적인 record를 jpa의 entity로 사용하면 entity의 구조를 단순화 하면서 직관적으로 쓸 수 있지 않을까요?


2. Record를 entity로! → 노놉, DTO로!!

안타깝게도 결론부터 말하자면 record는 entity로 쓸 수 없습니다. 모든 entity를 생각없이 record로 바꾼 후 소개팅 자리 나가는 설레는 마음으로 빌드 했지만 User declared non-static fields id are not permitted in a record 에러를 만났습니다.

record는 엔티티가 될 수 없는 이유는 다음과 같습니다.

  • hibernate와 같은 jpa는 프록시 생성을 위해 인수 생성자, non-final 필드, setter 및 non-final 클래스가 없는 엔티티에 의존합니다. 즉, 프록시를 생성하기 위해서 entity는 불변이면 안됩니다.(jpa의 프록시는 일대일 매핑 시 지연 로딩 제공 등 다양하게 쓰입니다.)
  • 쿼리 결과를 매핑할 때 객체를 인스턴스화 할 수 있도록 매개변수가 없는 생성자가 필요합니다. → record는 매개변수가 없는 생성자를 제공하지 않습니다.(record는 불변 객체이기 때문에 setter를 사용할 수 없습니다. 이로 인해 모든 필드의 값을 입력한 후에 생성할 수 있습니다)
  • 접근자 메소드인 getter가 필수 명명 규칙을 따르지 않습니다. record의 getter는 필드명을 그대로 사용하고 있습니다.(name(), pilot() …) → 쿼리 결과 처리 후 수행할 getter, setter에 접근할 수 없습니다.

위에서 상술한 이유로 record는 엔티티가 되기 위한 조건을 만족하지 않습니다. 결국 entity는 표준 java 클래스로 구현해야 해당 기준을 쉽게 충족할 수 있습니다.

그럼 어디다 써먹으라고 추가해준 record는 목적에 맞게 쓸 수 있는 곳이 없는 걸까요? 이 글을 시작하기에 앞서 살펴본 DTO가 있습니다. DB에 저장된 데이터의 전송 객체로서 record는 아주 적합한 형태를 가지고 있습니다.

읽어온 정보를 변경하지 않으려면 record 타입의 DTO가 적격입니다. record는 한 번 값이 정해지고 나면 setter를 통해 값을 변경할 수 없는데, 자바 내부에서 데이터 가공 시 중간에 변질될 우려가 없습니다.

일단 DTO를 사용하면 API에서 도메인 모델을 분리할 수 있는데 백엔드 내부의 entity를 외부에 공개할 필요가 없게 됩니다.

하지만, 여전히 한국의 자바 환경은 8버전이 주를 이루고 있으며 나머지 파이는 11버전이 차지하고 있습니다. record를 제대로 된 스펙으로 사용하려면 16이상의 버전을 사용해야 하는데 버전을 올렸을 때 겪을 리스크가 있습니다. 그러나 세계적인 추이가 점점 새로운 자바 버전의 사용이 늘고 있는 추이기 때문에 훗날을 위해서라도 record를 DTO로 사용하는 것에 대해 친숙해질 필요는 있다고 생각합니다.

(이미지 출처 : jetBrain 자바 보고서 - 전세계 자바 버전 점유율)

이번 경험을 통해 entity의 프록시에 대해 조금만, 아주 조금만 더 생각했더라면 record를 DTO로 사용하는 것이 더 좋다는 것을 일찍이 알지 않았을까 라는 아쉬움이 남습니다. 프록시와 entity의 특징에 대한 고려 없이 프로젝트를 record로 바꿔놓은 걸 생각하면 지금도 많이 부끄럽습니다ㅋㅋㅋ

jpa에 대한 전반적인 이해도가 아직은 부족하다는 걸 깨달을 수 있는 좋은 시간이었고 새로운 기능도 써보면서 자바에 대한 기본기도 착실히 다져가야겠습니다!


*REFERENCE

record 공식 문서 : https://openjdk.org/jeps/395
참고 블로그 : [PMJ] Practical 모던 자바 - 레코드 (Records) - The most admired
[Java] 자바의 레코드(Record) - 책 읽는 개발자_테드
https://blog.hexabrain.net/399
Java - jdk 14 record(레코드) 란?! Data class(데이터 클래스) - 코딩스타트
https://stackoverflow.com/questions/70601508/can-i-use-java-16-record-with-jpa-entity
https://thorben-janssen.com/java-records-hibernate-jpa/
https://thorben-janssen.com/dto-projections/
https://wkorando.github.io/sip-of-java/015.html
https://medium.com/javarevisited/javas-records-vs-kotlin-s-data-classes-b3fef851155a
https://www.reddit.com/r/java/comments/fzuex7/will_records_become_the_standard_way_to_create/
jetBrain 2021 자바 보고서 : https://www.jetbrains.com/ko-kr/lp/devecosystem-2021/java/

profile
배워서 내일을 위해 쓰자

5개의 댓글

comment-user-thumbnail
2023년 3월 13일

hashcoded 와 equals 메서드를 자동으로 정의해주니 value obj와 좀더 어울리는듯

1개의 답글
comment-user-thumbnail
2023년 9월 22일

안녕하세요. 잘 읽었습니다 ㅎㅎ
본문에 대해 간단한 질문하나 드려도 괜찮을까요?
다른 블로그 보면 record가 getter, setter 메소드도 제공한다고 본 거 같은데~
'''
접근자 메소드인 getter가 필수 명명 규칙을 따르지 않습니다. record의 getter는 필드명을 그대로 사용하고 있습니다.(name(), pilot() …) → 쿼리 결과 처리 후 수행할 getter, setter에 접근할 수 없습니다.
'''
이거 예시나 부연설명 부탁드려도 될까용?
본문만 보면 record가 getter, setter 메소드를 지원하지 않는 것 처럼 들려서요~!

1개의 답글