
최근 회사에서 개발을 하다가 StaleObjectStateException를 마주쳤다.
org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [***#57faa1af-a453-46a3-842c-df45bfb1817b]
at org.hibernate.event.internal.DefaultMergeEventListener.entityIsDetached(DefaultMergeEventListener.java:426) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.event.internal.DefaultMergeEventListener.merge(DefaultMergeEventListener.java:214) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.event.internal.DefaultMergeEventListener.doMerge(DefaultMergeEventListener.java:152) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:136) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:89) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:127) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.internal.SessionImpl.fireMerge(SessionImpl.java:854) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.internal.SessionImpl.merge(SessionImpl.java:840) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
at org.springframework.orm.jpa.ExtendedEntityManagerCreator$ExtendedEntityManagerInvocationHandler.invoke(ExtendedEntityManagerCreator.java:364) ~[spring-orm-6.2.9.jar:6.2.9]
at jdk.proxy4/jdk.proxy4.$Proxy138.merge(Unknown Source) ~[na:na]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
at org.springframework.orm.jpa.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler.invoke(SharedEntityManagerCreator.java:320) ~[spring-orm-6.2.9.jar:6.2.9]
at jdk.proxy4/jdk.proxy4.$Proxy138.merge(Unknown Source) ~[na:na]
at org.springframework.data.jpa.repository.support.SimpleJpaRepository.save(SimpleJpaRepository.java:657) ~[spring-data-jpa-3.5.2.jar:3.5.2]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:360) ~[spring-aop-6.2.9.jar:6.2.9]
at org.springframework.data.repository.core.support.RepositoryMethodInvoker$RepositoryFragmentMethodInvoker.lambda$new$0(RepositoryMethodInvoker.java:277) ~[spring-data-commons-3.5.2.jar:3.5.2]
at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:170) ~[spring-data-commons-3.5.2.jar:3.5.2]
at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:158) ~[spring-data-commons-3.5.2.jar:3.5.2]
at org.springframework.data.repository.core.support.RepositoryComposition$RepositoryFragments.invoke(RepositoryComposition.java:515) ~[spring-data-commons-3.5.2.jar:3.5.2]
at org.springframework.data.repository.core.support.RepositoryComposition.invoke(RepositoryComposition.java:284) ~[spring-data-commons-3.5.2.jar:3.5.2]
at... 후략
예외 메시지를 확인해 보면 Row was updated or deleted by another transaction라고 하는데 말 그대로 수정하고자 하는 행이 다른 트랜잭션에 의해 수정됐거나 삭제됐다고 한다.
트랜잭션의 동시성 문제로 발생했다는 뜻인데… 이상하다고 느껴졌다.
1. 오류 사항
이 오류가 발생했던 것은 단순 .save(Entity) 작업을 수행할 때이다.
그나마 기존에 있던 행을 수정하는 로직이었다면 고개를 끄덕일 수 있겠지만 새로운 엔티티를 저장하는 로직이었다. 그리고 테스트 메서드를 작성하여 실행하였기 때문에 동시에 다른 트랜잭션이 처리되었을 리가 만무하다.
우선 save하려고 작성했던 소스코드를 한번 확인해 보자.
...
@Entity
public class EntityExample {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
...
}
엔티티의 키값은 UUID로 @GeneratedValue 어노테이션을 이용하여 insert 할 때 자동으로 생성되도록 하고 있다.
void test(){
EntityExample entityExample = EntityExample.builder()
.id(UUID.randomUUID())
.build();
entityExampleRepository.save(entityExample);
}
그리고 엔티티를 만들 때 id에 직접 랜덤한 UUID를 생성하여 주입하고 저장하도록 한다.
결론적으로 GeneratedValue 어노테이션이 있음에도 직접 수동으로 값을 지정하면 문제가 발생한다.
2. Hibernate 소스코드 분석
그렇다면 왜 이런 오류가 발생했는지 출력된 Stack Trace를 따라서 분석해 보자.
SimpleJpaRepository.save() 분석
일단 첫 번째로 JpaRepository의 구현체인 SimpleJpaRepository Stack을 분석해 보자.
at org.springframework.data.jpa.repository.support.SimpleJpaRepository.save(SimpleJpaRepository.java:657) ...
@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); // <- 657번 라인
}
}
SimpleJpaRepository의 657번 라인에서 오류가 발생했는데 벌써 이상하다. isNew라는 메서드로 저장하고자 하는 엔티티가 새로운 엔티티인지 판별하고 새로운 엔티티라면 persist(insert), 이미 존재하는 엔티티라면 merge(update)를 하는 로직이다.
그런데 새로운 엔티티를 save 한 것임에도 새로운 엔티티라고 판단하지 않고 merge를 하게 되었다. Hibernate에서는 Merge 하려고 했는데 해당 key값에 대한 행이 데이터베이스에 존재하지 않으니 다른 트랜잭션에 의해 삭제됐다고 판단하여 예외를 발생시키는 것이다.
그렇다면 Hibernate는 해당 엔티티를 왜 새로운 엔티티라고 인식하지 못하였을까?
isNew() 판단 기준
이제 두 번째로. isNew(Entity) 메서드를 분석해 보자.
isNew 메서드는 AbstractEntityInformation라는 추상 클래스에서 내용을 확인해 볼 수 있다.
@Override
public boolean isNew(T entity) {
ID id = getId(entity);
Class<ID> idType = getIdType();
if (!idType.isPrimitive()) { // <- 필자가 save한 엔티티의 ID는 UUID(Non-primitive)이다.
return id == null;
}
if (id instanceof Number n) {
return n.longValue() == 0L;
}
throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}
필자가 save 하려고 했던 엔티티의 ID는 UUID로 기본형(Primitive) 자료형이 아니다. 그렇기 때문에 해당 엔티티가 null일 때에만 새로운 엔티티로 인식하게 된다. EntityExample.builder().id(UUID.randomUUID())를 통해 나는 이미 ID 값을 직접 주입했다. 당연히 ID는 null이 아니었고, isNew() 메서드는 false를 반환했다.
이로 인해 merge가 호출되었고, 데이터베이스에 존재하지 않는 ID를 업데이트하려다 StaleObjectStateException가 발생한 것이다.
여기까지만 봐도 우선 문제는 해결했지만 궁금증이 더 남는다.
그렇다면 @GeneratedValue 어노테이션을 붙이지 않고 수동으로 ID를 할당하면 동일한 예외가 발생하여야 하는데 발생하지 않는다. 그렇다면 merge() 메서드의 로직에 다른 무언가가 있을 것이다.
merge() 내부 동작
이제 세 번째로 .merge(entity) 메서드를 분석해 보자.
at org.hibernate.event.internal.DefaultMergeEventListener.entityIsDetached(DefaultMergeEventListener.java:426) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.event.internal.DefaultMergeEventListener.merge(DefaultMergeEventListener.java:214) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.event.internal.DefaultMergeEventListener.doMerge(DefaultMergeEventListener.java:152) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:89) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.internal.SessionImpl.fireMerge(SessionImpl.java:854) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.internal.SessionImpl.merge(SessionImpl.java:840) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
protected void entityIsDetached(MergeEvent event, Object copiedId, Object originalId, MergeContext copyCache) {
...
final Object result = source.getLoadQueryInfluencers().fromInternalFetchProfile(
CascadingFetchProfile.MERGE,
() -> source.get( entityName, clonedIdentifier )
);
...
if ( result == null ) {
final Boolean knownTransient = persister.isTransient( entity, source );
if ( knownTransient == Boolean.FALSE ) {
throw new StaleObjectStateException( entityName, id ); // <- 여기서 예외 발생
}
...
}
...
}
중간에 많은 로직들이 있지만 핵심은 merge 로직 내에서 엔티티 객체 내에 ID 값은 존재하니 Detached 상태로 인식하게 된다. 이때 판별된 엔티티들 중 Transient 상태가 있는지 한번 더 확인한다.
이 Transient 상태를 판별하는 로직이 @GeneratedValue 어노테이션이 있을 때와 없을 때 다소 차이가 있다.
// org.hibernate.persister.entity.AbstractEntityPersister
public Boolean isTransient(Object entity, SharedSessionContractImplementor session) throws HibernateException {
final Object id = getIdentifier( entity, session );
...
// check the id unsaved-value
final Boolean result = identifierMapping.getUnsavedStrategy().isUnsaved( id );
if ( result != null ) {
return result;
}
...
}
// org.hibernate.engine.spi.IdentifierValue
// @GeneratedValue 어노테이션이 있을 때 로직
@Override
public @Nullable Boolean isUnsaved(@Nullable Object id) {
LOG.tracev( "ID unsaved-value: {0}", value );
return id == null || id.equals( value );
}
@GeneratedValue 어노테이션이 있을 때에는 위의 로직이 반영된다.
id는 사용자가 임의 할당하여 null이 아니고 기존에는 저장되었던 적이 없기에 value는 null로 id와 다르게 되어 isUnsaved가 false가 되는 것이다. 그렇기에 isTransient는 false가 되어 실제로는 Transient 상태인 객체이나 Detached 상태인 객체로 인식되어 오류가 발생한다.
// org.hibernate.engine.spi.IdentifierValue
/**
* Assume nothing.
*/
public static final IdentifierValue UNDEFINED = new IdentifierValue() {
@Override
public @Nullable Boolean isUnsaved(Object id) {
LOG.trace( "ID unsaved-value strategy UNDEFINED" );
return null;
}
@Override
public @Nullable Serializable getDefaultValue(Object currentValue) {
return null;
}
@Override
public String toString() {
return "UNDEFINED";
}
};
반대로 @GeneratedValue 어노테이션이 없을 때에는 식별자가 UNDEFINED가 주입된다. 그렇기에 isUnsaved 로직 자체가 null만을 반환하게 된다. 따라서 isTransient 메서드 또한 FALSE가 아닌 다른 값이 반환되어 객체가 Transient 상태로 인식된다.
| @GeneratedValue 있음 (ID 수동 지정) |
|---|
| Entity ID != null ↓ isNew() → false ↓ merge() 호출 ↓ IdentifierValue → Default (Not matched) ↓ Hibernate: Detached 상태로 인식 ↓ DB에 해당 ID 없음 → 예외 발생 |
| @GeneratedValue 없음 (ID 수동 지정) |
|---|
| Entity ID != null ↓ isNew() → false ↓ merge() 호출 ↓ IdentifierValue → UNDEFINED (Skip) ↓ Hibernate: Transient 상태로 인식 ↓ DB에 insert 수행 → 정상 처리 |
3. 결론
Hibernate의 StaleObjectStateException은 단순히 트랜잭션 충돌로만 발생하는 예외가 아니다. 내부적으로 엔티티의 상태를 Detached로 잘못 판단했을 때도 발생할 수 있다.
이번 케이스처럼 @GeneratedValue가 붙어 있는 엔티티의 ID 값을 개발자가 수동으로 할당하면, Hibernate는 해당 객체를 Transient가 아닌 Detached 상태로 인식하게 된다. 그리고 이 Detached 엔티티를 merge() 하려다 DB에 해당 ID를 가진 레코드가 존재하지 않으면, 마치 "다른 트랜잭션에서 삭제되었다"라고 판단하여 StaleObjectStateException을 던지게 된다.
반면, @GeneratedValue가 붙어있지 않은 경우에는 ID 값이 있어도 Hibernate는 이를 명확하게 Transient 상태로 인식할 수 있다. 이때는 merge 시에도 예외 없이 insert로 처리되기 때문에 문제가 발생하지 않는다.
즉, 핵심은 Hibernate가 엔티티의 생명주기를 어떻게 판단하느냐에 있다.
자동 생성 전략을 사용한다면 ID 값을 수동으로 주입하지 않아야 한다.
[ 참고 자료 ]
'Programming Note' 카테고리의 다른 글
| ASCII Code Table (아스키 코드표) (0) | 2025.09.08 |
|---|---|
| JPA Entity의 생명주기 (0) | 2025.09.04 |
| 도메인 사용 정지 / This domain has been suspended due to non-completion of an ICANN-mandated contact verification (0) | 2025.09.04 |
| MongoDB Change Stream으로 구현하는 서버 간 실시간 데이터 공유 (0) | 2025.08.05 |
| CLOB과 BLOB (0) | 2025.04.08 |














