Development/JPA

[Spring Data JPA] Java의 record 객체를 @EmbeddedId로 사용할 때 Could not set value of type 문제가 발생하는 경우

개발하는 곰돌이 2024. 8. 2. 22:03

목차

    들어가기 전에

    자바로 프로젝트를 진행하다 보면 불변 객체를 쉽게 만들고 equalshashcode를 바로 만들어주면서 일반 클래스와 구분하기가 좋다는 점에서 값 객체 클래스를 만들 때는 레코드 클래스를 애용하고 있습니다.

     

    그러다보니 엔티티 클래스에서 복합 기본키를 갖게 될 때 record 객체를 @EmbeddedId로 사용하려고 했는데 레포지토리에 엔티티를 저장하는 과정에서 오류가 발생해서 관련 내용을 정리해보려고 합니다.

     

    레코드 클래스에 대한 내용은 이전에 어느정도 정리를 해놨습니다.

     

    [Java] Java 14부터 추가된 Record 타입과 Kotlin의 Data Class 비교

    목차 들어가기 전에 자바 14부터 코틀린의 Data 클래스와 유사한 Record라는 클래스가 추가되어 자바 16에서 정식으로 지원되기 시작했다. 그동안 자바에서 DTO 등의 클래스를 만들 때는 Lombok의 도움

    colabear754.tistory.com

    문제의 상황

    문제가 발생한 환경은 스프링부트 3.1.12이며 대략적인 문제의 상황은 이렇습니다.

     

    아래와 같이 PersonId 객체를 @EmbeddedId로 갖고 있는 엔티티가 있다고 가정해보겠습니다.

    @Entity
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @AllArgsConstructor
    @Getter
    public class Person {
        @EmbeddedId
        private PersonId id;
        private String address;
    }

    그리고 PersonId와 Address는 임베디드 타입이기 때문에 @Embeddable이 붙은 레코드 클래스로 작성되어 있습니다.

    @Embeddable
    public record PersonId(
            String name,
            String phoneNumber
    ) implements Serializable {}

    간단한 테스트 코드를 작성해서 테스트를 해봅니다.

    @Test
    void 저장하기() {
        // given
        Person person = new Person(new PersonId("홍길동", "010-1234-5678"), "테스트 주소");
        // when
        personRepository.save(person);
        // then
        Person savedPerson = personRepository.findAll().get(0);
        assertThat(savedPerson.getId().name()).isEqualTo("홍길동");
        assertThat(savedPerson.getId().phoneNumber()).isEqualTo("010-1234-5678");
        assertThat(savedPerson.getAddress()).isEqualTo("테스트 주소");
    }

    그런데 다른 부분도 아니고 레포지토리에 데이터를 저장하는 과정에서 테스트가 실패했습니다. 레코드 타입인 PersonId의 name에 해당하는 setter가 없다고 하네요.

     

    평범하게 final인 필드를 가진 엔티티는 이런 문제가 없는데 뭔가 이상합니다.

    대체 뭐가 원인인데

    결론부터 이야기하자면 하이버네이트에서 발생한 일종의 버그이며 엔티티에 @Embedded인 레코드 객체 필드가 있을 때 발생할 수 있다고 합니다.

    문제 상황에서 JPA가 어떻게 동작하길래?

    이 문제에 대해 자세히 알아보기 위해 Spring Data JPA에 대해 조금 알아둬야할 점이 있었습니다.

     

    Spring Data JPA는 save 또는 saveAll로 레포지토리에 엔티티를 저장하기 전에 저장하고자 하는 엔티티가 새로운 엔티티인지 판단해서 새로운 엔티티라면 persist를, 이미 존재하는 엔티티라면 merge를 호출하게 됩니다.

     

    이 때 새로운 엔티티를 판단하는 기준은 기본키로 설정된 필드에 따라서 달라집니다.

    • 기본키 필드가 자바 원시 타입인 경우에는 숫자 값이 0일 때
    • 기본키 필드가 객체인 경우에는 null일 때

    Spring Data JPA는 해당 엔티티를 새로운 엔티티로 판단합니다.

     

    글의 상황과 같이 자동으로 값을 할당할 수 없는 복합 기본키를 가진 엔티티는 당연히 기본키를 직접 할당해서 save를 하게 될테니 merge가 호출되는 거죠.

     

    여기서 이미 존재하는 엔티티라고 판별되어 merge가 호출되면 데이터를 INSERT 하기 전에 save의 인자로 전달된 엔티티의 ID로 SELECT 쿼리를 날려 정말로 존재하는 엔티티인지 확인합니다.

     

    이 SELECT의 결과가 없으면 INSERT를 호출해서 엔티티를 DB에 저장하고, SELECT의 결과가 있으면 UPDATE를 호출해서 DB에 저장되어 있던 값을 인자로 전달된 엔티티의 내용대로 변경합니다.

    Persistable 구현하기

    이런 경우처럼 객체 타입의 복합 기본키를 직접 할당하는 경우라면 엔티티의 기본키가 null이 될 수 없기 때문에 새로 생성된 엔티티라고 하더라도 merge가 실행될 수밖에 없다고 생각할 수 있습니다.

     

    이럴 때를 위해 엔티티 클래스에서 Persistable이라는 인터페이스를 구현할 수 있습니다.

    Persistable은 getIdisNew가 존재합니다. 여기서 눈여겨 봐야할건 isNew인데, 객체가 영속 상태인지 새로운 객체인지를 반환한다고 합니다.

     

    isNew를 적절히 재정의한다면 객체 타입의 기본키를 직접 할당하는 경우에도 새로 생성된 엔티티를 명확하게 판별할 수 있습니다.

     

    이를 위해 Person을 조금 수정해줍니다.

    @Entity
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @Getter
    public class Person implements Persistable<PersonId> {
        @EmbeddedId
        private PersonId id;
        private String address;
        @CreationTimestamp
        private LocalDateTime createdAt;
    
        public Person(PersonId id, String address) {
            this.id = id;
            this.address = address;
        }
    
        @Override
        public boolean isNew() {
            return createdAt == null;
        }
    }

    엔티티가 새로운 객체인지 판별하기 위해 DB에 저장된 시간을 나타내는 createdAt@CreationTimestamp로 추가했습니다.

     

    createdAt은 하이버네이트에서 알아서 넣어줄 것이기 때문에 기존의 @AllargsConstructor를 제거하고 idaddress만 지정하는 생성자를 만들어줍니다.

     

    그리고 DB에서 꺼낸게 아닌, 새로 생성된 엔티티는 createdAtnull일 것이기 때문에 isNew를 이에 맞게 재정의합니다.

     

    원래라면 getId도 재정의가 되어야 하지만 기본키 필드의 이름이 id이고 Lombok으로 작성된 id의 Getter와 이름이 동일하기 때문에 굳이 재정의를 하지 않았습니다.

     

    이제 다시 테스트 코드를 실행해봅니다.

    이번에는 테스트를 통과했습니다.

     

    persist가 실행됐을 때는 아무 문제도 없는 것을 보니 영속 상태인 엔티티라고 판단되어 실행된 merge가 문제라는걸 알 수 있네요.

    해결 방법

    merge가 실행되는 경우에 문제가 발생하니까 위의 예시처럼 Persistable을 구현하면서 isNew를 적절히 재정의해서 merge 실행을 방지하거나 아예 하이버네이트를 문제가 수정된 6.3.1 버전 이상으로 변경을 해서 근본적인 문제를 차단할 수 있습니다.

     

    스프링부트 3.2.0버전부터 하이버네이트 6.3.1 이상 버전을 사용하기 때문에 스프링부트 버전 자체를 올릴 수 있는 상황이라면 그냥 스프링부트 버전을 올려버리면 됩니다.

     

    만약 스프링부트 버전을 올릴 수 없는 상황이라면 build.gradledependencies에 아래 내용을 추가해서 하이버네이트의 버전을 적절하게 변경해줄 수 있습니다.

    implementation 'org.hibernate.orm:hibernate-core:6.3.1.Final'

    다만 하이버네이트의 버전이 6.4.3일 경우 동일한 문제가 발생하기 때문에 해당 버전은 피하는게 좋습니다.

    참조 링크

     

    Quarkus 3.7.2 Hibernate throws "IllegalAccessException: Can not set final java.util.UUID field" · quarkusio quarkus · Discussi

    Hi all (especially @yrodiere I guess), while trying to update from 3.7.1 to 3.7.2, I'm getting exceptions like: org.hibernate.PropertyAccessException: Could not set value of type [java.util.UUID]: ...

    github.com

     

    Hibernate 6 and Java records unable so persist

    Hi all, I’m using the recently added support for Java records as Embeddable, things seem to work file when querying data, but when I try to save, things break. It seems that Hibernate is trying to set the final fields if the record…? I’m not sure. En

    discourse.hibernate.org

     

    [JPA] 복합키 매핑하기 (@EmbeddedId, @MapsId, isNew())

    JPA에서 식별자를 둘 이상 사용하려면 별도의 식별자 클래스를 만들어야한다. 이 경우, 식별 관계를 매핑하기 위해 실수했던 부분과 직접 ID값을 할당할 때 발생할 수 있는 문제점에 대해 기록하

    rachel0115.tistory.com

     

    [JPA] 변경 감지와 병합(merge)

    지난번에 진행했던 hello shop 프로젝트에서 상품 수정 시 사용됐던 merge() 라는 친구를 기억하나 혹시~😎? JPA에서 준영속 엔티티를 수정하는 방법에는 2가지가 있는데, 이것이 매우 중요하다고 강

    velog.io