[Spring Data JPA] Java의 record 객체를 @EmbeddedId로 사용할 때 Could not set value of type 문제가 발생하는 경우
목차
들어가기 전에
자바로 프로젝트를 진행하다 보면 불변 객체를 쉽게 만들고 equals
와 hashcode
를 바로 만들어주면서 일반 클래스와 구분하기가 좋다는 점에서 값 객체 클래스를 만들 때는 레코드 클래스를 애용하고 있습니다.
그러다보니 엔티티 클래스에서 복합 기본키를 갖게 될 때 record 객체를 @EmbeddedId
로 사용하려고 했는데 레포지토리에 엔티티를 저장하는 과정에서 오류가 발생해서 관련 내용을 정리해보려고 합니다.
레코드 클래스에 대한 내용은 이전에 어느정도 정리를 해놨습니다.
문제의 상황
문제가 발생한 환경은 스프링부트 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은 getId
와 isNew
가 존재합니다. 여기서 눈여겨 봐야할건 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
를 제거하고 id
와 address
만 지정하는 생성자를 만들어줍니다.
그리고 DB에서 꺼낸게 아닌, 새로 생성된 엔티티는 createdAt
이 null
일 것이기 때문에 isNew
를 이에 맞게 재정의합니다.
원래라면getId
도 재정의가 되어야 하지만 기본키 필드의 이름이id
이고 Lombok으로 작성된id
의 Getter와 이름이 동일하기 때문에 굳이 재정의를 하지 않았습니다.
이제 다시 테스트 코드를 실행해봅니다.
이번에는 테스트를 통과했습니다.
persist
가 실행됐을 때는 아무 문제도 없는 것을 보니 영속 상태인 엔티티라고 판단되어 실행된 merge
가 문제라는걸 알 수 있네요.
해결 방법
merge
가 실행되는 경우에 문제가 발생하니까 위의 예시처럼 Persistable을 구현하면서 isNew
를 적절히 재정의해서 merge
실행을 방지하거나 아예 하이버네이트를 문제가 수정된 6.3.1 버전 이상으로 변경을 해서 근본적인 문제를 차단할 수 있습니다.
스프링부트 3.2.0버전부터 하이버네이트 6.3.1 이상 버전을 사용하기 때문에 스프링부트 버전 자체를 올릴 수 있는 상황이라면 그냥 스프링부트 버전을 올려버리면 됩니다.
만약 스프링부트 버전을 올릴 수 없는 상황이라면 build.gradle
의 dependencies
에 아래 내용을 추가해서 하이버네이트의 버전을 적절하게 변경해줄 수 있습니다.
implementation 'org.hibernate.orm:hibernate-core:6.3.1.Final'
다만 하이버네이트의 버전이 6.4.3일 경우 동일한 문제가 발생하기 때문에 해당 버전은 피하는게 좋습니다.
참조 링크