Spring

StaleObjectStateException 오류를 구실로 Hibernate 소스 코드 분석하기

지화자_ 2025. 1. 10. 21:16

티켓 예매 테스트 코드를 작성하던 중 save() 메서드에서 StaleObjectStateException 에러가 발생했다.

 

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

다른 트랜잭션에 의해서 레코드(Row)가 변경됐다고? 난 분명 다른 트랜잭션을 Test 코드에 넣지 않았는데..?

그럼 unsaved-value mapping? 이건 무슨 말일까 hibernate 공식 문서를 참고해봤다.

 

 

공식 문서

https://docs.jboss.org/hibernate/core/3.6/reference/en-US/html/mapping.html

 

 

정리하면 detached 상태와 구분하기 위해 unsaved된 새로운 인스턴스를 가리키는 Id 속성이다.

즉, Transient 상태인지 구분하기 위한 기준 Id라고 이해하면 쉽다.

 

즉, 설정을 통해 지정된 id 값에 대해서만 Transient로 판단한다는 뜻이다.

그럼 hbm.xml 파일에 id 값을 설정하여 mapping을 해야한다는 말일까?

 

정확한 동작과정을 이해하기 위해 테스트 코드에 대한 무한 Debug를 시행했다.

 

 

Test 코드

@Test
    public void createTicket() {
        Ticket ticket = Ticket.createTicketWithStock(1L, 10L); // Id, Stock
        ticketRepository.save(ticket);
        assertThat(ticket.getId()).isEqualTo(1L);
    }

 

save()메서드에서 오류가 발생한 것을 확인했고 Entity를 확인했더니

@Entity
@Getter
@NoArgsConstructor
public class Ticket {

    @Id 
    @GeneratedValue(strategy = GenerationType.AUTO) // <- 이녀석이 원인
    private Long id;
    ...
    }
    
    public static Ticket createTicketWithStock(Long id, Long totalStock) {
        return new Ticket(id, totalStock, totalStock );
    }

 

@GeneratedValue가 선언된 상태에서 Id 값을 새로 할당하여 넣은 것이 문제라고 추측했다. 결론적으로 지우면

해결되는 것을 바로 확인했지만, 그 이유를 알기 위해 Hibernate 코드를 열어봤다.

 

 

Hibernate 소스 코드 분석하기

SimpleJpaReopository의 save() 메서드 

 

해당 엔티티가 isNew()라면, persist()를 실행 아닐 경우 merge()를 실행하는 구조이다.

isNew()메서드는 id값의 자료형을 기준으로 판단한다. 메서드를 자세히 보자.

 

 

티켓 Id는 Long으로 선언됐고 이는 Wrapper 클래스로 기본형타입이 아니었다.

내가 할당한 값은 1L이었기에 isNew()의 반환값은 false이다.

즉, isNew() 메서드에서 persist()가 아닌 merge()로 동작한다.

 

 

BasicEntityIdentifierMappingImpl 내 getIdentifier(Object entity) 메서드

여기서  잠시, getId(entity) 메서드로 들어가면 getIdentifier 메서드로 이동하게 되는데

이는 하이버네이트 프록시를 통해 식별자를 가져오는 과정으로 결론만 말하자면, 데이터베이스에 접근없이 식별자를 가져오려고 하는 로직이다. 당연히 Transient 상태였기에 프록시에 저장된 엔티티 정보가 없으며, 식별자를 가져올 수 없다.

 

 

그럼 이제 isNew()가 false임을 판별했으니, merge() 메서드를 파악해보자.

 

SessionImpl의 merge() 메서드

 

먼저 checkOpen() 메서드는 세션의 열림 혹은 닫힘을 확인한다.

merge() 메서드는 Detached 상태의 객체라면 커밋 시 DB 반영해야 하기 때문에 DB와의 연결을 먼저 확인하는 것이다.

 

 

사실 여기까지만 생각해도 이미 잘못됨을 인지할 수 있다.

영속성 컨텍스트에 들어간 적도 없는 인스턴스를 Detached 상태로 분류했다는 거 자체가 오류가 때문이다.

 

SessionImpl 내 fireMerge(MergeEvent event) 메서드

이제 병합 과정을 처리하는 이벤트-리스너 구조에 진입한다. 이때 MergeEventListener 인터페이스 내에서 트랜잭션 동기화, 변경되지 않은 작업이 있는지 확인을 거친 뒤에 MergeEventListener내 onMerge 메서드를 실행한다.

 

DefaultMergeEventListener 내 onMerge, doMerge, entityIsDetached 메서드

 

onMerge 메서드엔 MergeContext를 함께 가져와 엔티티를 중복으로 처리하지 않도록 한다.

 

onMerge 내부로 가보자.

 

onMerge()메서드 내용은 프록시 여부를 확인하는 로직이다. 이는 데이터베이스와의 연결, 지연 로딩 관련된 엔티티인지 확인하는 과정으로, original은 영속성 컨텍스트에서 관리하는 기존의 영속상태 엔티티이다. 같은 ID를 갖는 엔티티(original)이 존재하는 지 확인한다. 이를 통해 새로운 엔티티를 만들지 않고 기존 영속 엔티티에 변경사항을 저장하는 것이다.

 

 

이후, 표시한 doMerge()메서드가 실행된다.

 

doMerge() 메서드 내에 merge() 메서드가 실행된다.

 

DefaultMergeEventListener 내 merge(), isTransient() 메서드

 

메서드 내부가 너무 길어 결론만 얘기하면 entityState 내 getEntityState 메서드가 실행되고

그 결과로 내가 save()한 인스턴스에 상태는Detached라고 판별하게 된다.

 

 

그럼 이제 예외처리를 발생시키는 entityIsDetached 메서드로 가보자.

 

결국 isTransient 메서드 실행 결과가 FALSE가 나왔다는 얘기다. 그럼 isTransient를 살펴보자.

 

getIdentifier의 경우 하이버네이트 프록시에 접근하여 데이터를 가져오지 못하면 인스턴스 id를 바로 가져오게 된다.

해당 메서드를 실행했을 때 id 값이 1로 찍힌 것을 확인할 수 있다. 

 

즉, Id값이 이미 할당되어 있다면, Transient로 인식하지 않는 것이다.

 

더 밑으로 내려가보자.

 

 

이제 우리가 위에서 처음 확인했던 isUnsaved() 메서드가 나오며, unsaved에 대한 얘기가 나온다.

value 값에 할당된 게 없으니 mapping할 Id를 찾을 수 없고

이는 result에 false를 할당하게 되며 에러로 이것이 에러로 이어진 것이다.

 

오류 발생 이유

다시 에러 메시지를 보자

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

 

Id값을 이미 할당한 상태로 save 메서드를 호출할 경우 isNew 메서드에서 false로 판단되고, 이는 곧 persist()가 아닌 merge()메서드를 실행하게 된다. 이는 결국 영속성 컨텍스트에서 해당 인스턴스를 Detached 상태, 즉 이전에 영속성 컨텍스트에서 관리했던 대상으로 인식하게 되는데, 이 과정에서 데이터베이스나 하이버네이트 프록시를 거쳐 해당 Id에 대한 데이터를 점검하지만, 이것이 없다는 것을 확인했으며, 이는 결국 Id 값이 변했거나 (updated) 삭제된 (deleted) 형태로 인식하게 된다는 것이다.

또한, unsaved-mapping의 value 값을 설정해 놓은 건 있는지 확인하는 과정을 거쳐서 결국 저 에러문구가 나오게 된 것.

 

 

만약 @GenerateValue 값을 삭제한다면?

흥미롭게도 @GenerateValue를 삭제할 경우, 테스트는 통과한다 하지만, 여전히 id값을 생성한 상태에서 save 메서드를 호출했기에 merge메서드가 실행된다.

 

그럼 뭐가 달라지는 걸까? 다시 코드를 보자

 

@GenerateValue가 있을 땐, result가 false로 반환된 것을 확인했었다.

하지만, @GenerateValue가 없을 경우 IdentifierValue 값의 변화로 인해 아래의 isUnsaved 메서드가 실행되고 result에 null 값이 반환된다.

 

그리고 이는 IdentifierValue 값에 대해 UNDEFINED 값을 할당받았기 때문인데,

.

이때 생성전략이 없는 경우엔 allowAssigngedIdentifiers() 즉, 생성 전략이 있을 때와 달리, 애플리케이션에서 미리 Id 할당이 가능하다.

 

생성전략이 없다면 simpleValue의 NullValue 값이 null인 것을 확인할 수 있었고 이로인해 identifiedValue가 undefined로 저장된 것을 확인할 수 있었다.

 

 

ID 생성 전략이 없는 경우

  1. 애플리케이션이 ID를 직접 할당할 수 있음
    • allowAssignedIdentifiers() 메서드를 통해 Hibernate가 직접 ID를 할당할 수 있는지 확인한다.
    • 즉, Hibernate는 엔티티의 ID를 애플리케이션이 직접 설정할 수도 있다고 판단한다.
  2. SimpleValue의 nullValue가 null로 설정됨
    • ID 생성 전략이 없는 경우, SimpleValue 객체의 nullValue 속성은 null로 설정된다.
    • 이는 Hibernate가 해당 ID의 저장 여부를 판단할 기준이 없다는 것을 의미한다.
  3. IdentifierValue가 UNDEFINED로 저장됨
    • IdentifierValue는 Hibernate가 ID의 상태(저장 여부)를 판단하는 기준을 제공한다.
    • ID 생성 전략이 없을 경우, Hibernate는 IdentifierValue.UNDEFINED를 사용하여 ID가 저장된 적 있는 값인지 판단할 수 없도록 설정한다.
    • 즉, isUnsaved() 메서드가 null을 반환하여, ID 저장 여부를 명확히 결정하지 않는다.
  4. ID가 null인지 여부로만 판별
    • isUnsaved()가 ID가 null인지 여부만으로 판단하여, ID가 null이면 새 객체(Transient), 값이 있으면 Detached 상태로 간주한다.

 

ID 생성 전략이 있는 경우 (@GeneratedValue 설정됨)

  1. ID 생성의 책임이 Hibernate로 넘어감
    • @GeneratedValue가 설정된 경우, Hibernate는 ID 값을 직접 생성해야 하므로, 애플리케이션이 ID를 직접 할당하는 것을 허용하지 않는다.
  2. SimpleValue에서 identifierGeneratorStrategy가 설정됨
    • ID 생성 전략에 따라 SimpleValue.identifierGeneratorStrategy가 설정된다.
    • 예를 들어:
      • GenerationType.IDENTITY → identity
      • GenerationType.SEQUENCE → sequence
      • GenerationType.AUTO → Hibernate가 적절한 전략 선택
  3. IdentifierValue가 ANY 또는 특정 ID 생성 전략에 따라 다르게 설정됨
    • Hibernate는 ID가 저장된 값인지 확인하기 위해 IdentifierValue를 UNDEFINED가 아닌 다른 값으로 설정한다.
    • 예를 들어, ID가 자동 생성되는 경우 Hibernate는 ID가 아직 생성되지 않았다고 판단할 수 있는 전략을 사용한다.
  4. ID 저장 여부를 명확히 판단 가능
    • Hibernate는 isUnsaved() 로직을 통해 ID의 저장 여부를 보다 정확하게 판별할 수 있다.
    • 예를 들어, GenerationType.IDENTITY의 경우, Hibernate는 데이터베이스에서 ID를 생성해야 하므로, ID가 없으면 새 엔티티(Transient)로 간주하고, 있으면 Detached로 판단한다.

 

 

결론

  • ID 생성 전략이 없으면 Hibernate는 ID가 null인지 여부만으로 저장 여부를 판단하고, 애플리케이션이 직접 ID를 할당할 수 있다.
  • 반면 ID 생성 전략이 있으면 Hibernate가 ID 생성을 책임지며, 저장 여부를 더 정확히 판별할 수 있다.