스프링 부트를 3.3.5에서 3.4.0으로 올렸다.
총 4293개의 테스트 케이스중에 15개의 에러, 1개의 실패가 발생했다.
1개의 실패는 spring-web의 DefaultResponseErrorHandler 코드의 변경으로 이루어졌다.
3.3.5 업데이트글과 마찬가지로 getErrorMessage의 메시지 구성이 변경되어 깨지는 것이었다.
- 이슈: https://github.com/spring-projects/spring-framework/pull/28958
- 커밋: https://github.com/spring-projects/spring-framework/commit/f85c4e1ea714281c03287d1448dab9efcf81fc34
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에도 답변을 추가해놓았다.
관련 티켓
'Programing > Framework' 카테고리의 다른 글
Spring Cloud 2024.0.0 릴리스 (0) | 2024.12.05 |
---|---|
[Spring] Boot update 3.3.4 to 3.3.5, MethodArgumentTypeMismatchException 변경 사항 확인 by 테스트 코드 (0) | 2024.11.25 |
[Spring] @Retryable과 @Transactional 는 순서에 따라 동작을 다르게 한다? (3) | 2024.10.27 |
[Spring] 중첩된 @Transactional의 readOnly 동작 확인 (0) | 2024.10.26 |
[Spring] @Cacheable - null 값도 캐싱이 되고 있었다. (1) | 2024.10.21 |