JPA를 사용하다 보면 “기존 데이터를 지우고 같은 키로 다시 저장한다”는 흐름을 가끔 구현할 일이 있다.
비즈니스적으로는 기존 상태를 정리하고 새로운 상태를 반영하는 것에 가깝고, 코드 수준에서도 delete 이후 save를 같은 트랜잭션에서 처리하는 것이 특별히 위험해 보이지는 않는다.
하지만 실제로 이 패턴을 적용하던 중, Hibernate가 delete 단계에서 OptimisticLockingFailureException 계열의 예외를 던지는 상황을 겪었다.
로그만 보면 분명 delete 쿼리가 실패한 것처럼 보였지만, delete 조건도 명확했고 동시에 동작하는 트랜잭션도 없었기 때문에 직관적으로는 납득하기 어려운 에러였다.
문제가 발생한 구조는 다음과 같았다.
@GeneratedValue를 사용하지 않고, 애플리케이션에서 직접 키를 생성하는 방식이었다@IdClass를 사용하고 있었다의사코드로 표현하면 대략 이런 형태였다.
@Transactional
public void replaceEntity(String id1, String id2, String id3) {
repository.deleteById1AndId2(id1, id2);
Entity entity = new Entity(id1, id2, id3);
repository.save(entity);
}
문제는 이 코드가 실행될 때, 다음과 같은 예외가 발생했다는 점이다.
org.springframework.orm.ObjectOptimisticLockingFailureException:
Batch update returned unexpected row count from update [0];
expected: 1; actual: 0;
로그를 조금 더 자세히 보면, Hibernate가 실행한 SQL은 다음과 같은 형태였다.
delete from entity
where id1 = ?
and id2 = ?
and id3 = ?
이 시점에서 직관적으로 가장 이상했던 점은 분명했다.
나는 id1, id2만 조건으로 delete를 호출했는데, Hibernate가 생성한 delete SQL은 PK 전체(id1, id2, id3)를 조건으로 사용하고 있었기 때문이다.
처음에는 여러 가능성을 의심했다.
하지만 하나씩 확인해보면서 이상한 점이 더 분명해졌다.
내가 직접 호출한 delete는 벌크 delete였고, 벌크 delete는 0건이 삭제되더라도 예외를 던지지 않는다.
그런데 실제 예외는 “delete가 기대한 row 수만큼 삭제되지 않았다”는 메시지를 포함하고 있었다.
즉, 이 예외는 내가 작성한 delete 쿼리에서 발생한 것이 아니라, Hibernate가 내부적으로 수행한 delete에서 발생한 것 이라는 가설을 세울 수 있었다.
이 지점에서 관점은 “delete 쿼리가 왜 실패했는가”에서 “Hibernate는 언제 delete를 수행하고 있는가”로 이동하게 되었다.
Hibernate는 엔티티의 변경 사항을 즉시 DB에 반영하지 않는다.
트랜잭션 동안 발생한 insert, update, delete 요청은 내부의 ActionQueue에 쌓이고, flush 시점에 한꺼번에 SQL로 변환되어 실행된다.
중요한 점은, flush 시점의 SQL 실행 순서는 코드에서 메서드를 호출한 순서와 무관하다는 것이다.
Hibernate는 내부 규칙에 따라 액션을 그룹화하고, 대략 다음과 같은 순서로 실행한다.
즉, 코드에서는 delete를 먼저 호출하고 save를 나중에 호출했더라도, flush 시점에는 insert나 update가 delete보다 먼저 실행될 수 있다.
이 동작은 Hibernate User Guide에도 명시되어 있고, 실제 구현은 ActionQueue 클래스에서 확인할 수 있다.
다음으로 살펴본 것은 save()의 동작 방식이었다.
Spring Data JPA의 save()는 항상 insert를 의미하지 않는다.
엔티티가 “새로운 엔티티”라고 판단되지 않으면, 내부적으로 merge 경로를 타게 되고, 이는 update 성격의 SQL을 생성한다.
문제는 복합키와 임의 키 조합이었다.
PK 값이 이미 채워진 상태에서 @GeneratedValue를 사용하지 않으면, JPA는 해당 엔티티를 신규로 판단하기 어렵고, save는 자연스럽게 merge로 흘러간다.
실제로 Persistable을 적용하기 전의 흐름은 다음과 같았다.
Entity entity = new Entity(id1, id2, id3);
// isNew 판단 불가
repository.save(entity); // merge 경로
이를 명확히 제어하기 위해 Persistable을 구현하고 isNew()를 재정의했다.
public class Entity implements Persistable<EntityId> {
@Override
public boolean isNew() {
return true;
}
}
이후 save는 더 이상 merge가 아니라 insert로 동작하게 되었고, 이 부분은 의도대로 작동했다.
Persistable을 적용한 이후에도, 에러는 여전히 delete 단계에서 발생했다.
그리고 Hibernate가 생성한 delete SQL은 항상 PK 전체 조건을 사용하고 있었다.
그래서 열심히 확인한 결과 Hibernate 공식 문서에서 Identifiers가 엔티티를 고유하게 식별하는 기본이 되는 값이며, 식별자(identifier)는 PK 전체를 의미한다고 정의한다는 것을 알 수 있었다.
이 식별자는 엔티티의 삭제, 조회, 갱신 등 모든 단일 엔티티 연산에서 기준으로 사용되며, Hibernate는 엔티티 삭제 시 이 식별자 값을 기반으로 DELETE SQL의 WHERE 절을 구성한다.
따라서 Hibernate가 생성하는 엔티티 delete SQL은 항상 식별자 PK 전체 조건을 사용하게 되며, 이 부분은 JPA 명세와 Hibernate 구현에서 일관되게 적용된다.
반면 내가 원하는 delete는 부분 조건을 사용하는 벌크 delete였다.
@Modifying
@Query("delete from Entity e where e.id1 = :id1 and e.id2 = :id2")
void deleteById1AndId2(String id1, String id2);
void deleteById1AndId2(String id1, String id2);
이 두 delete는 완전히 다른 레벨의 연산이다.
결국 실제로 발생한 상황은 다음과 같았다.
에러는 delete에서 발생했지만, 원인은 delete 자체가 아니라 Hibernate가 기대한 엔티티 상태와 실제 DB 상태의 불일치였다.
이 문제를 해결하기 위해 선택한 방법은 delete 이후의 상태를 Hibernate에게 명확히 알려주는 것이었다.
구체적으로는 벌크 delete에 다음 옵션을 적용했다.
@Modifying(
flushAutomatically = true,
clearAutomatically = true
)
@Query("delete from Entity e where e.id1 = :id1 and e.id2 = :id2")
void deleteById1AndId2(String id1, String id2);
이 설정의 의미는 단순하다.
이후 save(insert)는 새로운 컨텍스트에서 수행되었고, Hibernate 내부의 flush 순서와도 충돌하지 않게 되었다.
한 트랜잭션에서 delete와 insert를 함께 사용하는 것이 항상 잘못된 것은 아니다.
하지만 그 패턴은 Hibernate의 flush 순서와 엔티티 생명주기를 정확히 이해하고 있을 때만 안전하다.
관측된 에러는 실제 장애라기보다, Hibernate가 자신의 전제와 맞지 않는 상태를 만났을 때 던진 신호에 가까웠다.
이런 상황에서는 에러 메시지 자체보다, Hibernate가 “어떤 액션을 언제 실행하려 했는지”를 따라가는 것이 훨씬 중요하다.
다음에 비슷한 상황을 마주친다면, delete와 insert를 같이 써도 되는지를 묻기 전에,
그 둘이 Hibernate의 flush 모델 안에서 어떤 의미를 가지는지를 먼저 떠올리는 편이 더 빠른 판단으로 이어질 것이다.