본문 바로가기

Programing/Framework

[WIL] Spring Boot 3.4.0 - JPA 통합테스트 깨짐 이슈

스프링 부트를 3.3.5에서 3.4.0으로 올렸다.

총 4293개의 테스트 케이스중에 15개의 에러, 1개의 실패가 발생했다.

 

1개의 실패는 spring-web의 DefaultResponseErrorHandler 코드의 변경으로 이루어졌다.

3.3.5 업데이트글과 마찬가지로 getErrorMessage의 메시지 구성이 변경되어 깨지는 것이었다.

Spring Cloud의 의존 버전이 2024.0.0가 아닌 2024.0.0-RC1라서 아쉽다.

 

그외 테스트 실패가 아닌 에러가 발생한 부분이 있었다.

ObjectOptimisticLockingFailureException 예외가 던져져서 발생했는데 왜 낙관적인 낙 실패가 발생했는지 이해가 되지 않았다.

그런데 모두 에러가 발생한 것이 아니고 일부만 실패를 했다.

에러 메시지

Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) ...

공통점: @Id 필드를 넣어준 테스트 코드에서 실패

JPA 엔티티는 @Id 필드를 필수적으로 가진다.

일부 테스트 코드에서 해당 필드를 null 혹은 0으로 넣어주지 않고 저장(save)하는 부분에서 예외가 발생했음을 알았다.

 

좀 더 구체적으로 설명하면 스프링 데이터에서는 엔티티가 새로운 것인지 병합(merge)이 필요한지에 따라서 다른 로직을 탄다.

package org.springframework.data.jpa.repository.support;

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {

  @Override
  @Transactional
  public <S extends T> S save(S entity) {

    Assert.notNull(entity, "Entity must not be null");

    if (entityInformation.isNew(entity)) {
      entityManager.persist(entity);
      return entity;
    } else {
      return entityManager.merge(entity);  // -> SessionImpl
    }
  }

 

새로 저장이 되는 값인데도 ID 필드의 값이 있으니 persist가 아닌 merge 를 수행하려고 한 것이다.

 

스프링 부트의 버전과 하이버네이트 버전

  • spring boot 3.3.5: hibernate-core-6.5.3.Final.jar
  • spring boot 3.4.0: hibernate-core-6.6.2.Final.jar

하이버네이트 6.5.3에서 6.6.2로 올라가면서 그동안 TODO 주석으로 있던 부분이 해제가 되었다.

Hibernate 6.5.3.Final 

package org.hibernate.event.internal;

public class DefaultMergeEventListener
		extends AbstractSaveEventListener<MergeContext>
		implements MergeEventListener {


  protected void entityIsDetached(MergeEvent event, Object copiedId, Object originalId, MergeContext copyCache) {
    // ..
    if ( result == null ) {
      //TODO: we should throw an exception if we really *know* for sure
      //      that this is a detached instance, rather than just assuming
      //throw new StaleObjectStateException(entityName, id);

 

Hibernate 6.6.2.Final 

package org.hibernate.event.internal;

public class DefaultMergeEventListener
		extends AbstractSaveEventListener<MergeContext>
		implements MergeEventListener {


  protected void entityIsDetached(MergeEvent event, Object copiedId, Object originalId, MergeContext copyCache) {
    // ..
    if ( result == null ) {
      LOG.trace( "Detached instance not found in database" );
      // we got here because we assumed that an instance
      // with an assigned id and no version was detached,
      // when it was really transient (or deleted)
      final Boolean knownTransient = persister.isTransient( entity, source );
      if ( knownTransient == Boolean.FALSE ) {
        // we know for sure it's detached (generated id
        // or a version property), and so the instance
        // must have been deleted by another transaction
        throw new StaleObjectStateException( entityName, id );
      }

 

이 변경으로 DETACHED 상태인 객체를 저장하려고 하면 StaleObjectStateException가 발생하게 바뀌었다.

이 StaleObjectStateException 예외는 ObjectOptimisticLockingFailureException에 래핑되어 던져진다.

package org.hibernate.internal;

public class ExceptionConverterImpl implements ExceptionConverter {

  // ...
  @Override
  public RuntimeException convert(HibernateException exception, LockOptions lockOptions) {
    if ( exception instanceof StaleStateException ) {
      final PersistenceException converted = wrapStaleStateException( (StaleStateException) exception );
      rollbackIfNecessary( converted );
      return converted;
    }
        // ...

  protected PersistenceException wrapStaleStateException(StaleStateException exception) {
    if ( exception instanceof StaleObjectStateException ) {
      final StaleObjectStateException sose = (StaleObjectStateException) exception;
      final Object identifier = sose.getIdentifier();
      if ( identifier != null ) {
        try {
          final Object entity = sharedSessionContract.internalLoad( sose.getEntityName(), identifier, false, true);
          if ( entity instanceof Serializable ) {
            //avoid some user errors regarding boundary crossing
            return new OptimisticLockException( exception.getMessage(), exception, entity );
          }
          else {  // 여기 !
            return new OptimisticLockException( exception.getMessage(), exception );
          }
        }
        catch (EntityNotFoundException enfe) {
          return new OptimisticLockException( exception.getMessage(), exception );
        }
      }
      else {
        return new OptimisticLockException( exception.getMessage(), exception );
      }
    }
    else {
      return new OptimisticLockException( exception.getMessage(), exception );
    }
  }

 

나랑 비슷하게 현상을 겪은 사람이 있는 것 같아서 stack overflow에도 답변을 추가해놓았다.

https://stackoverflow.com/questions/79228209/spring-boot-3-4-0-lets-integration-tests-with-jpa-hibernate-fail/79229425#79229425

 

관련 티켓