개발/JPA

@Version을 사용하지 않는데 ObjectOptimisticLockingFailureException 등장?!

Debin 2025. 1. 9.
반응형

예측하지 못한 예외 등장?!

 

Kotlin + Spring Boot + Spring Data Jpa 등 다양한 기술을 사용한 프로젝트를 진행하고 있다.

 

어느 평화로운 날..

 

갑자기 프로젝트의 스프링 부트 버전에서 Tomcat 취약점이 발견되어 최신 버전 3.4.1로 스프링 부트 버전을 올렸다.

 

버전을 올리고 테스트 코드를 실행하면서 잘 동작하던 코드에서 ObjectOptimisticLockingFailureException이 발생했다.

로그를 보면 StateObjectStateException이 먼저 발생하고 해당 에러를 OptimisticLocking 예외로 감쌌다.

 

프로젝트에서는 현재 낙관적 락(@Version)을 사용하지 않고 있어서 당황했다.

기존 스프링 부트 버전으로 다운 그레이드하면 예외를 던진 코드는 정상적으로 잘 동작했다.

 

호기심이 생겨 한 번 분석해보았는데... 글로 남겨 보겠다.

 

문제가 된 코드를 극단적으로 표현하면 아래와 같다.

//@GeneratedValue는 strategy로 GenerationType.SEQUENCE를 선택
//id는 Long? 타입이다
repo.save(Order(id = 0))

 

일단 최근에 자바 잘 읽는 법 책을 거의 다 읽었는데, 책에서 배운대로 바로 프로파일링을 적용해보았다.

 

인텔리제이 프로파일러를 활용했다.

프로파일러를 확인해보니 SessionImpl 클래스 fireOnMerge 메서드에서 예외가 발생했다.

 

왜 Merge로 진행이 됐지?!

 

kotlin에서 Long?는 Nullable을 허용하므로 Long 타입으로 인식된다.

Long의 경우는 null을 허용하지 않으므로 primitive 타입으로 인식된다.

 

아래는 새로운 엔티티인지 판별하는 AbstractEntityInformation 코드다.

 

 

Long 타입으로 들어가므로 id == null 구문을 수행하는데, 0 == null 이므로 false가 나와 merge 로직을 수행하게 된다.

 

결론적으로 Long?는 null을 넣어야 isNew 메서드의 값이 true이고, Long은 0을 넣어야 isNew의 메서드 값이 true이다.

 

디버깅..

 

fireOnMerge 메서드에서 계속 디버깅을 하니 DefaultMergeEventListener 클래스의 merge 메서드에 도달했다.

 

현재 엔티티의 상태는 id는 존재하는데 영속성 컨텍스트에는 없으므로 DETACHED 상태다.

그래서 this.entityIsDetached(..) 로직을 타게 된다.

 

 

그럼 아래에서 우리가 본 StateObjectStateException을 발견할 수 있다.

이제 버전을 올리기 전과 후의 DefaultMergeEventListner의 entityIsDetached 메서드의 코드를 비교해보자.

 

hibernate 6.6.4 버전에서의 코드다.

 

 

아래는 기존 hibernate 6.4.4 코드다.

 

 

hibernate 6.6.4에서 다음과 같은 코드가 추가됐다.

 

if (result == null) {
    //여기부터
    LOG.trace("Detached instance not found in database");
    Boolean knownTransient = persister.isTransient(entity, source);
    if (knownTransient == Boolean.FALSE) {
        throw new StaleObjectStateException(entityName, id);
    }
    //여기까지 새로 추가

    this.entityIsTransient(event, clonedIdentifier, copyCache);
}

 

데이터베이스에 엔티티에 해당하는 데이터가 없으므로 result는 null이다.

 

persister.isTransient에서는 내부적으로 AbstractEntityPersister 클래스의 isTransient 메서드를 수행한다.

우리는 @Version을 사용하지 않고, Id도 존재하므로 아래와 같은 코드에 멈춰선다.

 

Boolean result = this.identifierMapping.getUnsavedStrategy().isUnsaved(id);

 

identifierMapping의 구현체
strategy의 구현체

 

여기서 isUnsaved 로직은 아래와 같다.

 

//this.value는 null이다. id는 현재 0
public Boolean isUnsaved(Object id) {
    return id == null || id.equals(this.value); 
}

 

0과 null은 다르므로 여기서 false가 리턴되고 이어서 result가 false가 리턴되며 StaleObjectException이 발생한다.

발생한 StaleObjectException 예외를 ExceptionTranslationInterceptor와 같은 친구들이 OptimisticLockException으로 변환해준다.

 

아마 저 IdentifierValue를 커스텀해서 isTransient 메서드의 리턴 값을 true로 만들면 기존 처럼 insert가 되지 않을까 하는 추측을....

 

이제 우리는 어느 부분이 바뀌어서 예외가 발생했는지 파악했다. 

 

그렇다면 왜 바뀐걸까?!

답은 hibernate 6.6.x 버전 노트에서 확인할 수 있다.

https://docs.jboss.org/hibernate/orm/6.6/migration-guide/migration-guide.html

 

타이틀은 Merge versioned entity when row is deleted 이다.

 

GPT가 해석해준 내용은 다음과 같다.

 

이전에는 분리(detached)된 엔티티를 병합(merge)할 때, 데이터베이스에 해당 행(row)이 존재하지 않는 경우(예: 다른 트랜잭션에서 해당 객체가 삭제된 경우) SQL INSERT 문이 실행되었습니다. 그러나 이러한 동작은 예기치 않은 것이며, 낙관적 잠금(Optimistic Locking)의 규칙을 위반하는 것으로 간주되었습니다.
이제 특정 조건을 충족하는 경우, 엔티티가 확실히 분리된 상태이고 데이터베이스에 일치하는 행이 없을 때 OptimisticLockException이 발생하도록 변경되었습니다. 이러한 판단을 내리려면 엔티티에 다음 조건 중 하나를 충족해야 합니다:
자동 생성된 @Id 필드가 존재하거나,
비-원시(non-primitive) 타입의 @Version 필드가 존재해야 합니다.
만약 엔티티에 위 두 조건 중 어느 것도 해당하지 않는 경우, 새로운 인스턴스와 삭제된 분리된 인스턴스를 구별할 수 없습니다. 따라서 이러한 경우에는 이전 동작(SQL INSERT 실행)에 변화가 없습니다.

 

필자의 생각은 하이버네이트가 올바르게 동작하도록 수정됐다고 생각하며,

결국 개발자가 안일하게 pk 컬럼 id에 0을 넣은 것이 문제였다.

 

이런 문제를 막기 위해서는 코드 리뷰를 통해 더 교차 검증이 들어가야할 것 같다.

 

이상으로 포스팅을 마칩니다. 감사합니다.

 

반응형

댓글