Spring Data JPA unexpected row count 삭제 오류 해결 방법

Spring Data JPA에서 엔티티를 삭제할 때 Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1 오류가 발생하는 경우가 있다. DELETE SQL 자체는 단순하지만, Hibernate가 예상한 삭제 건수와 데이터베이스가 반환한 삭제 건수가 다르면 예외로 처리될 수 있다.

특히 동일한 데이터를 여러 요청이 삭제하거나, 조회한 엔티티가 실제 DELETE 실행 전에 다른 트랜잭션에서 먼저 삭제되면 문제가 나타날 수 있다. 이 글에서는 오류의 의미와 deleteById() 예외 처리의 한계를 살펴보고, 삭제 건수를 직접 반환받는 JPQL DELETE 방식과 flushAutomatically 적용 기준을 정리한다.

핵심 요약

  • 삭제 대상이 이미 사라진 상태라면 Hibernate가 기대한 1건과 실제 결과 0건이 달라 예외가 발생할 수 있다.
  • 중복 삭제를 정상 처리하려면 existsById() 확인보다 조건부 DELETE 결과 건수를 사용하는 방법이 적합하다.
  • PK만으로 삭제하는 쿼리에서는 flushAutomatically = true가 일반적으로 필수는 아니다.

오류 메시지가 의미하는 내용

발생한 오류는 다음과 같은 형태다.

Batch update returned unexpected row count from update [0];
actual row count: 0;
expected: 1;
statement executed: delete from account_links where id=?

이 메시지에서 중요한 값은 다음 두 가지다.

  • actual row count: 0: 실제로 삭제된 행이 없다는 의미다.
  • expected: 1: Hibernate는 삭제 대상 엔티티에 해당하는 행 하나가 삭제될 것으로 예상했다는 의미다.

Hibernate는 엔티티 상태를 기준으로 INSERT, UPDATE, DELETE 작업을 관리한다. 관리 중인 엔티티를 삭제하도록 예약했지만 SQL 실행 시점에 데이터베이스 행이 존재하지 않으면, 영속성 컨텍스트의 상태와 데이터베이스 상태가 일치하지 않는 상황이 된다.

이 오류가 반드시 @Version 필드를 사용하는 낙관적 락에서만 발생하는 것은 아니다. 다른 트랜잭션이 행을 먼저 삭제했거나 애플리케이션이 이미 삭제된 엔티티를 다시 삭제하려는 경우에도 예상 행 개수 불일치가 발생할 수 있다.

Hibernate의 벌크 수정 쿼리는 executeUpdate() 결과로 영향을 받은 엔티티 수를 정수로 반환한다. 다만 상속 매핑처럼 하나의 JPQL 작업이 여러 SQL 문으로 변환되는 환경에서는 엔티티 수와 실제 테이블 행 수가 항상 같지는 않을 수 있다. (⁠Hibernate Documentation)

동일한 데이터가 두 번 삭제되는 상황

이번 문제에서 검토한 코드는 다음과 같은 구조다.

try {
  repository.deleteById(data.getId());
} catch (ObjectOptimisticLockingFailureException e) {
  logger.warn("Conflict");
}

이 코드는 이미 삭제된 데이터에 대한 예외를 무시하려는 목적이다. 그러나 중복 삭제는 단순히 동일한 메서드가 연속 호출되는 경우에만 발생하지 않는다.

예를 들어 두 개의 요청이 같은 행을 거의 동시에 조회했다고 가정할 수 있다.

  1. 트랜잭션 A가 ID가 10인 엔티티를 조회한다.
  2. 트랜잭션 B도 ID가 10인 엔티티를 조회한다.
  3. 트랜잭션 A가 먼저 행을 삭제한다.
  4. 트랜잭션 B가 같은 ID로 DELETE를 실행한다.
  5. 두 번째 DELETE 결과가 0건으로 반환된다.

두 트랜잭션은 조회 시점에는 모두 데이터가 존재한다고 판단했다. 그러나 실제 SQL 실행 시점에는 한쪽에서 이미 데이터가 삭제된 상태다.

따라서 이 문제는 단순한 존재 여부 확인만으로 완전히 방지하기 어렵다. 존재 확인과 삭제가 서로 다른 SQL로 실행되면 두 작업 사이에 다른 트랜잭션이 개입할 수 있기 때문이다.

deleteById 예외 처리만으로 충분하지 않은 이유

DELETE가 메서드 호출 즉시 실행되지 않을 수 있다

JPA는 영속성 컨텍스트에 엔티티의 변경 상태를 모아 두었다가 flush 시점에 SQL을 실행할 수 있다. 따라서 deleteById() 호출과 실제 DELETE SQL 실행 시점이 항상 같다고 볼 수 없다.

다음 코드를 살펴보자.

@Transactional
public void delete(Long id) {
  try {
    repository.deleteById(id);
  } catch (ObjectOptimisticLockingFailureException e) {
    logger.info("already deleted. id={}", id);
  }
}

deleteById() 호출 시점에 DELETE SQL이 실행되면 catch에서 예외를 처리할 수 있다. 반면 트랜잭션 종료 직전 flush 또는 commit 과정에서 SQL이 실행되면, 예외가 try-catch 범위 밖에서 발생할 가능성이 있다.

JpaRepository.flush()는 영속성 컨텍스트에 쌓여 있는 변경사항을 데이터베이스에 즉시 반영하도록 요청하는 메서드다. Spring Data JPA API에서도 flush()를 대기 중인 변경사항을 데이터베이스로 반영하는 작업으로 정의한다.

따라서 기존 예외 처리 방식을 유지하려면 다음과 같이 명시적으로 flush하는 방법을 검토할 수 있다.

// 예시 코드
@Transactional
public void delete(Long id) {
  try {
        repository.deleteById(id);
        repository.flush();
  } catch (ObjectOptimisticLockingFailureException e) {
    logger.info("already deleted. id={}", id);
  }
}

다만 이 방식은 SQL 실행 시점을 try-catch 내부로 당길 뿐, 중복 삭제를 정상적인 반환값으로 처리하는 구조는 아니다.

예외 발생 이후 트랜잭션 상태를 고려해야 한다

JPA나 Hibernate 작업 중 예외가 발생하면 현재 트랜잭션이 rollback 대상으로 표시될 수 있다. 애플리케이션 코드에서 예외를 잡았더라도 같은 트랜잭션에서 후속 데이터베이스 작업을 계속 수행하면 최종 commit 시점에 문제가 드러날 수 있다.

따라서 “이미 삭제된 상태도 정상”이라는 업무 규칙이 있다면, 정상적인 중복 요청을 예외 흐름으로 처리하는 것보다 처음부터 삭제 건수를 결과로 받는 구조가 명확하다.

예외를 잡는 방식은 예상하지 못한 데이터 정합성 문제와 정상적인 중복 삭제를 구분하기 어렵게 만들 수도 있다. 예외 타입을 지나치게 넓게 잡으면 실제 데이터베이스 장애나 매핑 오류까지 단순한 중복 삭제로 오판할 가능성이 있다.

삭제 건수를 반환하는 JPQL DELETE 적용 방법

중복 삭제를 정상으로 취급해야 한다면 직접 DELETE 쿼리를 선언하고 삭제 건수를 반환받는 방법을 사용할 수 있다.

다음 코드는 일반적인 구현을 설명하기 위한 예시 코드다. 제공된 자료에서는 실제 운영 환경에 적용하고 검증한 결과가 확인되지 않았으므로, 적용 전 테스트가 필요하다.

public interface Repository
    extends JpaRepository<Data, Long> {

  @Modifying
  @Query("delete from datas al where al.id = :id")
  int deleteByIdIfExists(@Param("id") Long id);
}

서비스에서는 반환된 삭제 건수를 확인한다.

// 예시 코드
@Transactional
public void delete(Long id) {
  int deletedCount =
          repository.deleteByIdIfExists(id);

  if (deletedCount == 0) {
    logger.info(
        "deletion skipped because it does not exist. id={}",
        id
    );
  }
}

이 방식에서는 DELETE 결과를 다음과 같이 해석할 수 있다.

  • 반환값 1: 현재 요청에서 행 하나를 삭제했다.
  • 반환값 0: 삭제 대상이 존재하지 않았거나 이미 삭제된 상태다.
  • 예외 발생: SQL 문법, 연결 장애, 제약조건 등 별도의 오류 가능성을 확인해야 한다.

중요한 점은 반환값 0을 애플리케이션의 업무 규칙에 따라 정상으로 볼 것인지 결정하는 것이다. 삭제 API가 멱등성을 가져야 한다면 이미 삭제된 상태를 성공으로 처리할 수 있다.

멱등성이란 같은 요청을 여러 번 수행해도 최종 상태가 달라지지 않는 특성이다. 특정 ID의 데이터를 삭제하는 작업에서는 첫 번째 요청이 데이터를 삭제하고 두 번째 요청이 아무 행도 삭제하지 않더라도 최종 상태는 동일하다.

flushAutomatically는 필요한가

@Modifying에는 flushAutomatically 속성을 지정할 수 있다.

@Modifying(flushAutomatically = true)

Spring Data JPA 공식 API에 따르면 flushAutomatically는 수정 쿼리를 실행하기 전에 영속성 컨텍스트를 자동으로 flush할지를 결정한다. 기본값은 false다.

따라서 다음 쿼리에 flushAutomatically = true를 반드시 추가해야 하는 것은 아니다.

@Modifying
@Query("delete from AccountLink al where al.id = :id")
int deleteByIdIfExists(@Param("id") Long id);

현재 DELETE 조건은 기본 키인 id만 사용한다. 같은 트랜잭션에서 아직 데이터베이스에 반영되지 않은 엔티티 변경사항이 있더라도, 일반적으로 그 변경사항은 삭제 조건인 id에 영향을 주지 않는다.

오히려 flushAutomatically = true를 지정하면 DELETE 쿼리를 실행하기 전에 영속성 컨텍스트의 다른 변경사항까지 데이터베이스로 전달될 수 있다. 의도하지 않은 시점에 INSERT나 UPDATE가 실행되면 오류 발생 지점이나 트랜잭션 처리 흐름을 이해하기 어려워질 수 있다.

flushAutomatically가 의미 있는 경우

같은 트랜잭션에서 변경한 값이 벌크 쿼리의 조건에 포함될 때는 자동 flush가 의미를 가질 수 있다.

// 예시 코드
Data data = repository.findById(id).orElseThrow();

data.setStatus(1);
repository.deleteByStatus(1);

위 코드에서 status 변경이 아직 데이터베이스에 반영되지 않았다면, deleteByStatus(1)이 해당 행을 찾지 못할 수 있다.

이 경우에는 다음과 같은 설정을 검토할 수 있다.

// 예시 코드
@Modifying(flushAutomatically = true)
@Query("delete from data al where a.status = :status")
int deleteByStatus(@Param("status") Integer status);

하지만 PK만 사용하는 단건 삭제에서는 이러한 필요성이 낮다. 따라서 기본적으로는 @Modifying만 사용하고, 실제 트랜잭션 흐름에서 선행 변경사항을 반드시 반영해야 할 때만 flushAutomatically = true를 추가하는 편이 명확하다.

clearAutomatically도 무조건 사용하면 안 되는 이유

JPQL DELETE와 같은 벌크 쿼리는 데이터베이스에 직접 반영되며, 이미 영속성 컨텍스트에 올라온 엔티티 상태와 데이터베이스 상태가 달라질 수 있다.

예를 들어 같은 트랜잭션에서 엔티티를 먼저 조회한 뒤 벌크 DELETE를 실행할 수 있다.

// 예시 코드
Data data = repository.findById(id).orElseThrow();
repository.deleteByIdIfExists(id);

데이터베이스에서는 행이 삭제되었지만, data 객체는 영속성 컨텍스트에 남아 있을 수 있다. 이후 해당 객체를 다시 참조하거나 변경하면 예상하지 못한 동작이 발생할 가능성이 있다.

@Modifying(clearAutomatically = true)는 수정 쿼리 실행 후 영속성 컨텍스트를 자동으로 비우도록 설정한다. 공식 API에서도 clearAutomatically를 수정 쿼리 실행 후 기반 영속성 컨텍스트를 clear할지 결정하는 속성으로 설명한다. (⁠Home)

@Modifying(clearAutomatically = true)
@Query("delete from Data al where a.id = :id")
int deleteByIdIfExists(@Param("id") Long id);

그러나 clearAutomatically = true도 무조건 안전한 설정은 아니다. 특정 엔티티 하나만 분리하는 것이 아니라 현재 영속성 컨텍스트 전체를 비울 수 있기 때문이다.

같은 트랜잭션에서 다른 엔티티를 수정한 뒤 아직 flush하지 않았다면, clear 과정에서 관리 상태가 해제될 수 있다. Spring Data JPA의 벌크 삭제 관련 API도 벌크 작업이 JPA 1차 캐시와 데이터베이스 상태를 불일치시킬 수 있으며, 필요하면 작업 전 flush를 고려해야 한다고 설명한다.

따라서 다음 기준으로 판단하는 편이 좋다.

상황권장 설정
삭제가 트랜잭션의 마지막 작업인 경우@Modifying
삭제 후 동일 엔티티를 다시 조회하는 경우clearAutomatically = true 검토
수정한 값이 DELETE 조건에 포함되는 경우flushAutomatically = true 검토
다른 엔티티의 미반영 변경사항이 많은 경우자동 clear보다 트랜잭션 구조 분리 검토
삭제 후 복잡한 로직이 계속되는 경우삭제 전용 메서드나 트랜잭션 분리 검토

existsById 확인 후 삭제하는 방법의 한계

다음 방식은 직관적으로 보이지만 동시성 문제를 완전히 해결하지 못한다.

// 권장하지 않는 예시
if ( repository.existsById(id)) {
     repository.deleteById(id);
}

existsById()deleteById()는 별도 작업이다. 존재 여부를 확인한 직후 다른 트랜잭션이 데이터를 삭제하면, 현재 트랜잭션의 DELETE 결과는 다시 0건이 될 수 있다.

동작 순서는 다음과 같다.

  1. 트랜잭션 A가 existsById()를 실행하고 true를 받는다.
  2. 트랜잭션 B가 같은 행을 삭제한다.
  3. 트랜잭션 A가 deleteById()를 실행한다.
  4. 실제 삭제 건수가 0건이 된다.

이 방식은 SELECT 쿼리를 추가하지만 경쟁 조건을 제거하지 못한다. 삭제 대상의 존재 여부가 업무적으로 필요하지 않다면 DELETE를 바로 실행하고 반환 건수를 판단하는 편이 효율적이고 단순하다.

Spring Data Commons 문서에서도 deleteById()existsById()는 각각 독립적인 저장소 메서드로 정의한다. 두 메서드를 연속 호출한다고 해서 하나의 원자적 작업으로 합쳐지는 것은 아니다.

적용 전 확인해야 할 사항

직접 JPQL DELETE 쿼리를 도입하기 전에는 엔티티 연관관계와 삭제 부수 효과를 확인해야 한다.

제공된 엔티티에서는 다음과 같은 다대일 연관관계가 존재한다.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "data_link_provider_id")
private DataLinkProvider dataLinkProvider;

표시된 코드에는 CascadeType.REMOVE가 선언되어 있지 않다. 따라서 제공된 범위만 보면 Data 삭제 시 연관된 DataLinkProvider를 함께 삭제하는 구조는 아니다.

그러나 실제 프로젝트에는 다음 요소가 추가로 존재할 수 있으므로 확인이 필요하다.

  • 데이터베이스 외래 키와 삭제 제한 조건
  • 엔티티 리스너의 @PreRemove, @PostRemove
  • 감사 로그 또는 이력 저장 로직
  • 삭제 시 함께 정리해야 하는 하위 엔티티
  • Hibernate 이벤트 리스너
  • 논리 삭제 정책
  • 애플리케이션 캐시 삭제 로직

JPQL 벌크 DELETE는 엔티티를 한 건씩 조회한 뒤 EntityManager.remove()를 호출하는 방식과 동작이 다르다. 엔티티 라이프사이클 콜백이나 영속성 컨텍스트 기반 처리가 필요하다면 직접 DELETE 쿼리가 적합하지 않을 수 있다.

정상 동작 검증 방법

제공된 자료에서는 변경 코드를 실제 환경에 적용하고 검증한 결과가 포함되지 않았다. 따라서 다음 검증 방법은 적용 후 수행할 수 있는 제안 사항이다.

1. 데이터가 존재하는 경우

삭제 전 테스트 데이터를 준비한다.

SELECT id
FROM account_links
WHERE id = :TEST_ID;

삭제 메서드를 호출한 뒤 반환값이 1인지 확인한다.

int deletedCount =
        repository.deleteByIdIfExists(testId);

이후 동일한 ID의 행이 존재하지 않는지 확인한다.

SELECT id
FROM data_links
WHERE id = :TEST_ID;

2. 데이터가 존재하지 않는 경우

이미 삭제된 ID 또는 존재하지 않는 테스트 ID로 삭제 메서드를 호출한다.

예상 결과는 다음과 같다.

  • 예외가 발생하지 않는다.
  • 반환값은 0이다.
  • 애플리케이션 정책에 따라 INFO 또는 DEBUG 로그를 남긴다.

3. 동시 삭제 테스트

동일한 ID를 대상으로 두 요청을 동시에 실행한다.

예상 가능한 결과는 다음과 같다.

  • 한 요청의 삭제 건수는 1이다.
  • 다른 요청의 삭제 건수는 0이다.
  • 두 요청 모두 중복 삭제 정책에 따라 정상 종료할 수 있다.

동시성 테스트에서는 단순히 메서드를 두 번 순차 호출하는 것보다 별도의 스레드나 통합 테스트 환경에서 실제 트랜잭션을 분리하는 편이 적합하다.

4. 영속성 컨텍스트 확인

동일 트랜잭션에서 엔티티를 조회한 뒤 JPQL DELETE를 실행하고, 이후 다시 같은 ID를 조회한다.

clearAutomatically를 사용하지 않는다면 1차 캐시의 영향을 받을 수 있으므로 EntityManager.clear() 전후 결과를 비교할 필요가 있다.

// 예시 코드
Data beforeDelete =
        repository.findById(id).orElseThrow();

int deletedCount =
        repository.deleteByIdIfExists(id);

entityManager.clear();

Optional<AccountLink> afterDelete =
        repository.findById(id);

운영 반영 전에는 롤백 가능한 테스트 데이터 또는 별도 테스트 환경을 사용해야 한다.

로그 레벨과 메시지 선택

이미 삭제된 데이터를 다시 삭제하는 상황을 정상적인 멱등 처리로 정의했다면 WARN 로그는 과도할 수 있다.

logger.warn("Conflict");

WARN은 운영자가 확인해야 할 비정상 상황이라는 의미로 해석될 수 있다. 중복 요청이 예상 가능한 흐름이라면 INFO 또는 DEBUG가 더 적합할 수 있다.

logger.info(
    "deletion skipped because it does not exist. id={}",
    id
);

다만 실제 ID가 개인정보나 계정 식별자로 사용될 수 있다면 로그에 원본 값을 기록하지 않아야 한다. 공개 게시글의 예시에서는 일반적인 숫자 ID로 표현했지만, 실제 서비스에서는 내부 로그 정책에 따라 마스킹하거나 추적용 식별값으로 치환해야 한다.

또한 Conflict라는 표현은 HTTP 409 응답이나 업무 충돌을 의미하는 것으로 오해될 수 있다. 이미 삭제된 상태를 정상으로 간주한다면 already deleted, delete skipped, not found처럼 상태를 직접 설명하는 문구가 명확하다.

최종 권장 구현

PK 기반 삭제에서 이미 삭제된 상태를 정상으로 처리하려는 경우 다음과 같은 구성을 우선 검토할 수 있다.

public interface DatakRepository
    extends JpaRepository<Data, Long> {

  @Modifying
  @Query("delete from Data al where a.id = :id")
  int deleteByIdIfExists(@Param("id") Long id);
}
// 예시 코드
@Transactional
public void delete(Long id) {
  int deletedCount =
          repository.deleteByIdIfExists(id);

  if (deletedCount == 0) {
    logger.info("Data link deletion skipped.");
  }
}

현재 조건이 기본 키 하나뿐이라면 flushAutomatically = true는 우선 제외할 수 있다. 삭제 전에 반영해야 하는 다른 엔티티 변경사항이 있고 그 변경값이 DELETE 조건에 영향을 줄 때만 추가하는 편이 적절하다.

clearAutomatically = true도 기본 설정으로 넣기보다, 같은 트랜잭션에서 이미 조회한 엔티티를 삭제 후 계속 사용할 가능성이 있는지 확인한 뒤 선택해야 한다.

자주 묻는 질문

deleteById 호출 전에 existsById를 실행하면 오류를 막을 수 있는가?

완전히 막을 수 없다. 존재 확인과 삭제 사이에 다른 트랜잭션이 데이터를 삭제할 수 있다. 중복 삭제를 허용해야 한다면 DELETE 반환 건수를 확인하는 방법이 적합하다.

@Version 필드가 없는데도 낙관적 락 관련 예외가 발생할 수 있는가?

가능하다. Spring은 Hibernate의 stale-state 계열 예외를 낙관적 락 관련 예외로 변환할 수 있다. 이번 오류의 핵심은 버전 필드 자체보다 Hibernate가 예상한 변경 행 수와 실제 행 수가 다르다는 점이다.

flushAutomatically를 사용하면 중복 삭제 문제가 해결되는가?

아니다. flushAutomatically는 수정 쿼리 실행 전에 대기 중인 변경사항을 데이터베이스로 반영하는 설정이다. 다른 트랜잭션이 이미 행을 삭제한 상황을 방지하지는 않는다.

clearAutomatically는 항상 추가하는 것이 안전한가?

항상 안전하지는 않다. 수정 쿼리 실행 후 영속성 컨텍스트 전체가 비워질 수 있으므로, 같은 트랜잭션에서 관리 중인 다른 엔티티의 상태에 영향을 줄 수 있다. 삭제 후 트랜잭션 흐름을 검토한 뒤 적용해야 한다.

결론

Spring Data JPA 삭제 오류 중 actual row count: 0; expected: 1 메시지는 Hibernate가 삭제될 것으로 예상한 행을 데이터베이스에서 찾지 못했음을 의미한다. 동일한 데이터를 여러 요청이 삭제하거나, 조회와 실제 DELETE 사이에 다른 트랜잭션이 먼저 행을 제거하면 발생할 수 있다.

이미 삭제된 상태를 정상으로 취급해야 한다면 deleteById()에서 발생하는 예외를 잡는 것보다 JPQL DELETE 결과 건수를 반환받는 구조가 명확하다. 반환값이 1이면 현재 요청이 삭제한 것이고, 0이면 이미 삭제되었거나 대상이 없었던 것으로 처리할 수 있다.

PK만 조건으로 사용하는 삭제 쿼리에서는 flushAutomatically = true를 기본적으로 추가할 필요가 없다. clearAutomatically 역시 영속성 컨텍스트 불일치를 방지할 수 있지만 전체 컨텍스트를 비울 수 있으므로 트랜잭션 구조를 확인해야 한다. 실제 적용 전에는 존재하는 데이터, 존재하지 않는 데이터, 동시 삭제, 1차 캐시 상태를 각각 테스트하는 것이 다음 단계다.

참고 자료

  • Spring Data JPA 공식 API, @Modifying: flushAutomatically는 수정 쿼리 실행 전 flush 여부를, clearAutomatically는 실행 후 영속성 컨텍스트 clear 여부를 정의한다. 두 속성의 기본값은 false다. (⁠Home)
  • Spring Data JPA 공식 API 색인, JpaRepository.flush(): 대기 중인 변경사항을 데이터베이스에 반영한다. (⁠Home)
  • Spring Data JPA 공식 API, 벌크 삭제 관련 주의사항: 벌크 작업은 JPA 1차 캐시와 데이터베이스 상태를 불일치시킬 수 있으므로 작업 전 flush를 고려해야 한다. (⁠Home)
  • Hibernate ORM 공식 사용자 가이드: 벌크 수정 쿼리의 executeUpdate()는 영향을 받은 엔티티 수를 반환하며, 매핑 구조에 따라 실제 SQL 행 수와 차이가 날 수 있다. (⁠Hibernate Documentation)
  • Spring Data Commons 공식 문서, 저장소 예약 메서드: deleteById()existsById()는 각각 독립된 저장소 메서드다. (⁠Home)

댓글 남기기