@Version 애너테이션에 의한 TransientPropertyValueException 발생

4 분 소요


RECOMMEND POSTS BEFORE THIS

0. 들어가면서

동시성 문제를 해결하기 위해 낙관적인 락(optimistic lock) 방식으로 구현한 코드에서 문제가 발생했다. JPA 구현체가 엔티티(entity)를 영속성 컨텍스트에 저장하고 인스턴스를 반환하는 과정에서 몇 가지 조건에 의해 의도와 다르게 동작하면서 예외가 발생했다.

1. Problem Context

문제가 발생한 코드와 상황을 최대한 유사하게 재현하였다. ParentEntity 클래스를 살펴보자. 낙관적 락을 위해 versionNo 필드를 추가한다.

  • 타입은 Long 래퍼(wrapper) 클래스를 사용한다.
  • 낙관적 락이 동작하도록 @Version 애너테이션을 추가한다.
  • 기본값을 0으로 설정한다.
package blog.in.action.domain;

import jakarta.persistence.*;
import lombok.Getter;

@Getter
@Entity
public class ParentEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private String state;
    @Version
    private Long versionNo = 0L;

    public ParentEntity() {
        state = "CREATED";
    }
}

ChildEntity 클래스를 살펴보자. 부모 객체를 일대일 관계로 참조하고 있다.

package blog.in.action.domain;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor(staticName = "create")
@Entity
public class ChildEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private String state;
    @OneToOne
    private ParentEntity parentEntity;

    public void update() {
        state = "UPDATED";
    }
}

위 두 엔티티를 사용해 문제 상황을 재현해 보자. 문제 상황은 간단한 테스트 코드로 재현한다. @Transactional(propagation = Propagation.NOT_SUPPORTED) 애너테이션은 @DataJpaTest 애너테이션에 적용된 트랜잭션으로부터 테스트 로직의 트랜잭션을 분리하기 위해 추가한다. 테스트 코드는 아래 순서대로 실행된다.

  1. 부모 엔티티를 저장한다.
  2. 부모 엔티티를 자식 엔티티에 전달해 둘 사이의 관계를 연결한다.
  3. 자식 엔티티의 상태를 변경한다.
  4. 자식 엔티티를 저장한다.
    • InvalidDataAccessApiUsageException 예외가 발생한다.
    • 원인은 TransientPropertyValueException 예외이다.
  5. 부모 엔티티의 아이디 값이 널(null)이다.
package blog.in.action;

import blog.in.action.domain.ChildEntity;
import blog.in.action.domain.ParentEntity;
import blog.in.action.repository.ChildRepository;
import blog.in.action.repository.ParentRepository;
import org.hibernate.TransientPropertyValueException;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import static org.junit.jupiter.api.Assertions.*;

@DataJpaTest(
        properties = {"spring.jpa.hibernate.ddl-auto=create-drop"}
)
public class ActionInBlogTest {

    @Autowired
    private ParentRepository parentRepository;

    @Autowired
    private ChildRepository childRepository;

    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    @Test
    void zeroDefaultValueForVersionNo_throwTransientPropertyValueException() {

        var parentEntity = new ParentEntity();
        parentRepository.save(parentEntity);


        var childEntity = ChildEntity.create(null, "CREATED", parentEntity);
        childEntity.update();


        var throwable = assertThrows(InvalidDataAccessApiUsageException.class, () -> childRepository.save(childEntity));
        assertInstanceOf(TransientPropertyValueException.class, throwable.getRootCause());
        assertNull(parentEntity.getId());
    }
}

테스트를 실행해 보면 자식 엔티티에 대한 insert 쿼리가 수행되지 않는다.

Hibernate: select next value for parent_entity_seq
Hibernate: insert into parent_entity (state,version_no,id) values (?,?,?)
Hibernate: select next value for child_entity_seq

2. Problem Analysis

엔티티에 필드를 하나 추가하면서 정상적으로 동작하던 비즈니스 로직에서 예외가 발생하기 시작했다. 원인을 살펴보기 전에 엔티티의 라이프사이클(lifecycle)에 대해 간단히 정리할 필요가 있다.

  • New
    • 엔티티를 새로 생성한 상태
    • 애플리케이션 메모리에만 존재하며 엔티티 매니저에 의해 관리되지 않는다.
  • Managed
    • 엔티티 매니저에 의해 영속성 컨텍스트에서 관리되는 상태이다.
  • Detached
    • 엔티티 매니저에 의해 관리되다가 영속성 컨텍스트에서 제외된 상태이다.
  • Removed
    • 엔티티를 데이터베이스에서 삭제하겠다고 표시한 상태이다.
https://gunlog.dev/JPA-Persistence-Context/


이제 어떤 요소가 오류를 유발했는지 연관된 코드를 살펴보자. 먼저 spring-data-jpa 라이브러리의 SimpleJpaRepository 클래스는 다음과 같이 구현되어 있다.

  • 전달받은 엔티티 객체가 new 상태라면 영속화(persist)한다. 영속화 후 전달받은 객체를 그대로 반환한다.
  • 전달받은 엔티티 객체가 관리 중인 상태라면 영속성 컨텍스트에 병합(merge)한다. 병합 후 결과를 반환한다.
@Repository
@Transactional(
    readOnly = true
)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {

    @Transactional
    public <S extends T> S save(S entity) {
        Assert.notNull(entity, "Entity must not be null");
        if (this.entityInformation.isNew(entity)) {
            this.em.persist(entity);
            return entity;
        } else {
            return this.em.merge(entity);
        }
    }
}

코드 중간에 분기를 위한 JpaMetamodelEntityInformation 클래스의 isNew 메서드를 살펴보자.

  • 버전 관련 필드가 있는지 확인한다.
  • 버전 관련 필드가 있다면 해당 타입이 원시(primitive) 타입인지 확인한다.
    • 원시 타입이 아니라면 해당 값이 null이어야 true를 반환한다.
    • 원시 타입이라면 부모 클래스의 isNew 메서드를 호출한다.
  • 부모 클래스의 isNew 메서드는 @Id 애너테이션이 붙은 필드의 값을 확인한다.
public class JpaMetamodelEntityInformation<T, ID> extends JpaEntityInformationSupport<T, ID> {

    public boolean isNew(T entity) {
        if (!this.versionAttribute.isEmpty() && !(Boolean)this.versionAttribute.map(Attribute::getJavaType).map(Class::isPrimitive).orElse(false)) {
            BeanWrapper wrapper = new DirectFieldAccessFallbackBeanWrapper(entity);
            return (Boolean)this.versionAttribute.map((it) -> {
                return wrapper.getPropertyValue(it.getName()) == null;
            }).orElse(true);
        } else {
            return super.isNew(entity);
        }
    }
}

위에서 살펴본 코드를 정리하면 다음과 같다.

  1. 영속성 컨텍스트에 새로 저장할 엔티티 객체인지 판단하는 로직이 다음과 같은 우선순위를 가진다.
  2. 버전 관련 필드가 존재하고 원시 타입이 아닌 경우 해당 필드 값의 null 여부
  3. 버전 관련 필드가 존재하더라도 원시 타입이라면 엔티티 아이디 필드 값의 null 여부
  4. 버전 관련 필드가 없다면 엔티티 아이디 필드 값의 null 여부
  5. new 상태의 객체라면 영속화하고 전달받은 파라미터를 반환한다.
  6. new 상태의 객체가 아니라면 병합하고 결과 객체를 새로 만들어 반환한다.

문제를 유발하는 코드를 살펴보고 원인을 요약하면 다음과 같다.

  1. 엔티티에 버전 관련 필드가 래퍼 클래스 타입으로 추가되었다.
  2. 버전 관련 필드의 값이 null이 아니기 때문에 새로 생성한 객체임에도 병합 작업이 진행된다.
    • 영속화 작업에서는 전달받은 엔티티를 변경하고, 해당 엔티티 객체를 결과로 반환한다.
    • 병합 작업에서는 전달받은 엔티티를 변경하지 않고 쿼리 수행 결과를 새로운 엔티티 객체로 만들어 반환한다.

아래는 버전 필드를 추가하기 전에는 문제가 되지 않던 코드의 흐름이다.


아래는 버전 필드가 추가된 후 문제가 발생한 코드의 흐름이다.

3. Solve the Problem

엔티티 클래스에 추가된 버전 필드의 기본값을 제거하거나 원시 타입을 사용한다.

package blog.in.action.domain;

import jakarta.persistence.*;
import lombok.Getter;

@Getter
@Entity
public class ParentEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private String state;
    @Version
    private Long versionNo; // ok

    public ParentEntity() {
        state = "CREATED";
    }
}

위에서 정의한 엔티티가 정상적으로 동작하는지 테스트 코드로 살펴보자. 마찬가지로 @Transactional(propagation = Propagation.NOT_SUPPORTED) 애너테이션을 사용해 @DataJpaTest 애너테이션에 적용된 트랜잭션으로부터 테스트 로직의 트랜잭션을 분리한다. 테스트 코드는 아래 순서대로 실행된다.

  1. 부모 엔티티를 저장한다.
  2. 부모 엔티티를 자식 엔티티에 전달해 둘 사이의 관계를 연결한다.
  3. 자식 엔티티의 상태를 변경한다.
  4. 자식 엔티티를 저장하면 정상적으로 동작한다.
  5. 부모 엔티티의 아이디는 널(null)이 아니다.
package blog.in.action;

import blog.in.action.domain.ChildEntity;
import blog.in.action.domain.ParentEntity;
import blog.in.action.repository.ChildRepository;
import blog.in.action.repository.ParentRepository;
import org.hibernate.TransientPropertyValueException;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import static org.junit.jupiter.api.Assertions.*;

@DataJpaTest(
        properties = {"spring.jpa.hibernate.ddl-auto=create-drop"}
)
public class ActionInBlogTest {

    @Autowired
    private ParentRepository parentRepository;

    @Autowired
    private ChildRepository childRepository;

    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    @Test
    void defaultValueIsNullForVersionNo_updateStatus() {

        var parentEntity = new ParentEntity();
        parentRepository.save(parentEntity);


        var childEntity = ChildEntity.create(null, "CREATED", parentEntity);
        childEntity.update();


        var result = childRepository.save(childEntity);
        assertEquals("UPDATED", result.getState());
        assertNotNull(parentEntity.getId());
    }
}

테스트를 실행하면 다음과 같은 쿼리 로그를 확인할 수 있다.

  • 자식 엔티티에 대한 insert 쿼리가 정상적으로 수행된다.
Hibernate: select next value for parent_entity_seq
Hibernate: insert into parent_entity (state,version_no,id) values (?,?,?)
Hibernate: select next value for child_entity_seq
Hibernate: insert into child_entity (parent_entity_id,state,id) values (?,?,?)

TEST CODE REPOSITORY

댓글남기기