배경
현재 프로젝트에서 Mybatis -> JPA로 마이그레이션하는 작업을 하고 있다.
기존에 데이터를 일괄 삭제하던 쿼리를 JPA의 deleteAllById 메서드 하나로 쉽게 구현할 수 있었다.
하지만 테스트 코드로 테스트를 해보니 내가 의도한 것과 다르게 쿼리가 실행되고 있었다.
내가 생각한 쿼리
delete from task where task_id in (?, ?)
- in 쿼리로 일괄 삭제
실제 실행된 쿼리
-- 각 ID에 대한 개별 조회
select task_id from task where task_id = ?
select task_id from task where task_id = ?
-- 조회된 엔티티 개별 삭제
delete task_id from task where task_id = ?
delete task_id from task where task_id = ?
- 각 ID에 대한 개별적 select 쿼리 실행
- 조회된 엔티티에 대한 개별적 delete 쿼리 실행
- 10개의 ID가 있다면, 총 20개의 쿼리 실행
deleteAllById 메서드는 in 절을 사용한 단일 쿼리로 여러 행을 삭제할 것으로 예상했지만, 실제로는 각 ID에 대해 개별적으로 select와 delete 쿼리를 실행한다.
만약 10,000개의 ID를 삭제할 경우 20,000개의 쿼리가 실행되는 심각한 성능 문제로 이어질 수 있다.
이러한 동작은 루프로 deleteById를 반복 호출하는 것이나 마찬가지다. 그리고 삭제 쿼리를 실행했는데 select를 왜 하는걸까?
이유를 알기 위해 deleteAllById의 내부 동작을 살펴보자
개발 환경
- Spring Boot 2.x
- Java 8
- JPA, Hibernate
- Querydsl
deleteAllById
예상하지 못했던 쿼리가 발생했던 이유를 알기 위해서 deleteAllById 메서드가 어떻게 동작하는지 확인해보자
deleteAllById 내부 동작
@Repository
@Transactional(
readOnly = true
)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
...
@Transactional
public void deleteAllById(Iterable<? extends ID> ids) {
Assert.notNull(ids, "Ids must not be null!");
Iterator var2 = ids.iterator();
while(var2.hasNext()) {
ID id = var2.next();
this.deleteById(id);
}
}
@Transactional
public void deleteById(ID id) {
Assert.notNull(id, "The given id must not be null!");
this.delete(this.findById(id).orElseThrow(() -> {
return new EmptyResultDataAccessException(String.format("No %s entity with id %s exists!", this.entityInformation.getJavaType(), id), 1);
}));
}
@Transactional
public void delete(T entity) {
Assert.notNull(entity, "Entity must not be null!");
if (!this.entityInformation.isNew(entity)) {
Class<?> type = ProxyUtils.getUserClass(entity);
T existing = this.em.find(type, this.entityInformation.getId(entity));
if (existing != null) {
this.em.remove(this.em.contains(entity) ? entity : this.em.merge(entity));
}
}
}
...
}
- deleteAllById: 파라미터로 전달받은 ids를 순회하면서 deleteById 메서드를 호출한다.
- deleteById: 전달받은 id 값으로 데이터가 조회되면 delete 메서드를 호출한다. 만약 데이터를 조회한 후 존재하지 않으면 EmptyResultDataAccessException 예외를 발생시킨다.
- delete: 영속성 컨텍스트에 있는 엔티티라면 삭제하고, 없다면 merge 후 삭제한다.
- entityInformation.isNew(entity): id 값 null 여부를 기준으로 새로 생성된 엔티티인지 체크한다. 새로 생긴 엔티티가 아닌 경우에만 삭제 로직을 수행한다.
deleteAllById 로직을 보면 알 수 있듯이 루프를 통해 단건으로 조회 후 삭제를 진행하고 있다.
그리고 삭제하려는 id를 가진 객체 존재 여부와 상관없이 전달한 id 리스트 중 존재하는 객체들만 일괄 삭제되기를 원했기 때문에 객체가 없을 때 예외를 발생 시키는 로직은 불필요했다.
더 좋은 방법을 찾아보자
deleteAllByIdIn
JPA 예약어를 사용해서 메서드를 만들었다. In 절을 사용했으니 의도한대로 In을 사용해 일괄 삭제처리를 해줄거라고 생각했다.
메서드를 실행해보자
-- in 쿼리로 일괄 조회
select task_id from task where task_id in (?,?)
-- 조회된 엔티티 개별 삭제
delete from task where task_id = ?
delete from task where task_id = ?
- in 쿼리로 일괄 조회 쿼리 실행
- 조회된 엔티티에 대한 개별적 delete 쿼리 실행
- 10개의 ID가 있다면, 총 11개의 쿼리 실행 (1+N)
??? 당황스럽다. select할 때만 in절을 사용하고 delete는 기존처럼 단건으로 처리하고 있다.
select를 in절로 처리하기때문에 DB에 없는 id를 전달하더라도 EmptyResultDataAccessException 예외가 발생하는 문제는 사라졌다. 하지만 역시 1+N 쿼리가 발생하는 만큼 좀 더 나은 방법을 찾아야했다.
deleteAllByIdInBatch
deleteAllByIdInBatch 메서드를 사용하면 하나의 쿼리로 일괄 삭제할 수 있다.
아래 쿼리를 보면 select, delete를 반복하던 쿼리가 단일 쿼리로 깔끔하게 처리되는 것을 확인할 수 있다.
delete from task where task_id in (?, ?)
몇몇 블로그에서는 단일 쿼리로 처리된다는 부분만 언급하고 있지만 deleteAllByIdInBatch 메서드를 사용할 때 주의할 점이 있다.
공식 문서에서 deleteAllByIdInBatch 메서드에 대한 설명을 보면 알 수 있다.
Deletes the entities identified by the given ids using a single query. This kind of operation leaves JPAs first level cache and the database out of sync. Consider flushing the EntityManager before calling this method.
deleteAllByIdInBatch 메서드는 1차 캐시와 데이터베이스를 동기화되지 않는다고 한다. 즉, 영속성 컨텍스트에의해 관리되지 않는다. 글만 봐서는 와닿지 않으니 예제 코드를 통해 확인해보자
예제
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class Test {
@PersistenceContext
private EntityManager em;
@Autowired
private JpaTaskManagerRepository taskManagerRepository;
@Test
void deleteAllByIdInBatch() {
// 두 개의 Task 엔티티를 데이터베이스에 저장
TaskEntity task1 = createDummyTaskEntity();
TaskEntity task2 = createDummyTaskEntity();
List<TaskEntity> taskEntities = taskManagerRepository.saveAll(Arrays.asList(task1, task2));
Long savedTaskId = taskEntities.stream().mapToLong(TaskEntity::getId).boxed().collect(Collectors.toList()).get(0);
// 영속성 컨텍스트에서 엔티티를 조회
boolean present = taskManagerRepository.findById(savedTaskId).isPresent();
System.out.println("Before deleteAllByIdInBatch, Task is still present: " + present);
// deleteAllByIdInBatch로 데이터베이스에서 직접 삭제
taskManagerRepository.deleteAllByIdInBatch(Arrays.asList(savedTaskId));
// 영속성 컨텍스트에는 여전히 엔티티가 남아 있음
present = taskManagerRepository.findById(savedTaskId).isPresent();
System.out.println("After deleteAllByIdInBatch, Task is still present: " + present);
// flush, clear로 영속성 컨텍스트와 데이터베이스 동기화
em.flush();
em.clear();
// Now, trying to find the deleted entities should return null
present = taskManagerRepository.findById(savedTaskId).isPresent();
System.out.println("After flush and clear, Task is still present: " + present);
}
}
- deleteAllByIdInBatch 호출 전: 데이터 조회됨(true)
- deleteAllByIdInBatch 호출 후: 데이터 조회됨(true)
- Entity Manager flush, clear 후: 데이터 조회되지 않음(false)
로그를 살펴보면 deleteAllByIdInBatch 메서드가 호출된 후에도 삭제했던 객체가 조회되는 것을 확인할 수 있다.
이는 JPA의 1차 캐시 동작 방식때문이다. JPA는 객체를 조회할 때 1차 캐시에서 해당 식별자를 가진 객체가 있는지 확인한다. 있다면 반환하고 없다면 DB에서 조회를 한다. 로그를 보면deleteAllByIdInBatch 메서드 호출 후 findById 메서드를 호출했지만 select 쿼리를 실행하지 않고있다. 영속성 컨텍스트에서 데이터를 조회했기 때문이다.
deleteAllByIdInBatch 메서드는 영속성 컨텍스트를 거치지 않고 직접 DB 쿼리를 실행한다. DB에서는 객체가 삭제되었지만 영속성 컨텍스트에는 남아있기 때문에 조회된 것이다.
벌크 삭제했던 엔티티들을 다루어야 하는 경우, EntityManger의 flush와 clear를 통해 영속성 컨텍스트와 DB를 동기화해줘야 한다. 하지만 이러한 벌크 삭제 로직이 많아지면 1차 캐시를 통해 DB 접근 횟수를 줄여주는 영속성 컨텍스트의 이점 중 하나를 잃게 된다.
deleteAllByIdInBatch 내부 동작
deleteAllByIdInBatch 메서드 동작을 살펴보자
@Repository
@Transactional(
readOnly = true
)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
...
@Transactional
public void deleteAllByIdInBatch(Iterable<ID> ids) {
Assert.notNull(ids, "Ids must not be null!");
if (ids.iterator().hasNext()) {
if (this.entityInformation.hasCompositeId()) {
List<T> entities = new ArrayList();
ids.forEach((id) -> {
entities.add(this.getReferenceById(id));
});
this.deleteAllInBatch(entities);
} else {
String queryString = String.format("delete from %s x where %s in :ids", this.entityInformation.getEntityName(), this.entityInformation.getIdAttribute().getName());
Query query = this.em.createQuery(queryString);
if (Collection.class.isInstance(ids)) {
query.setParameter("ids", ids);
} else {
Collection<ID> idsCollection = (Collection)StreamSupport.stream(ids.spliterator(), false).collect(Collectors.toCollection(ArrayList::new));
query.setParameter("ids", idsCollection);
}
query.executeUpdate();
}
}
}
...
}
- this.entityInformation.hasCompositeId(): 복합키인지 체크한다. 이 쪽 로직은 지금은 확인하지 않겠다.
- String.format("delete from %s x where %s in :ids", ...): 복합키가 아니라면 쿼리 문자열을 생성한다. 전달받은 ID 리스트에 해당하는 엔티티들을 삭제하는 쿼리이다.
- em.createQuery(queryString): 쿼리 문자열로 쿼리 객체를 생성한다.
- query.setParameter(): 쿼리 파라미터를 설정한다.
- query.executeUpdate(): delete 쿼리를 실행한다.
Querydsl
Querydsl을 사용하 직접 쿼리를 작성하는 방법이 있다. 하지만 Querydsl의 delete, update 연산은 deleteAllByIdInBatch 메서드와 마찬가지로 영속성 컨텍스트를 사용하지 않고 바로 DB에 쿼리를 실행한다. 이것도 역시 테스트해보자.
예제
전달받은 id 목록을 in절로 일괄 삭제할 수 있도록 작성했다. 테스트를 해보자
@Repository
@RequiredArgsConstructor
public class JpaTaskManagerRepositoryImpl implements JpaTaskManagerRepositoryCustom {
private final JPAQueryFactory query;
@Override
public void removeAllByIdList(List<Long> taskIdList) {
QTaskEntity t = QTaskEntity.taskEntity;
query
.delete(t)
.where(t.id.in(taskIdList))
.execute();
}
}
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class Test {
@PersistenceContext
private EntityManager em;
@Autowired
private JpaTaskManagerRepository taskManagerRepository;
@Test
void deleteAllByIdInBatch() {
// 두 개의 Task 엔티티를 데이터베이스에 저장
TaskEntity task1 = createDummyTaskEntity();
TaskEntity task2 = createDummyTaskEntity();
List<TaskEntity> taskEntities = taskManagerRepository.saveAll(Arrays.asList(task1, task2));
Long savedTaskId = taskEntities.stream().mapToLong(TaskEntity::getId).boxed().collect(Collectors.toList()).get(0);
// 영속성 컨텍스트에서 엔티티를 조회
boolean present = taskManagerRepository.findById(savedTaskId).isPresent();
System.out.println("Before removeAllByIdList, Task is still present: " + present);
// removeAllByIdList 데이터베이스에서 직접 삭제
taskManagerRepository.removeAllByIdList(Arrays.asList(savedTaskId));
// 영속성 컨텍스트에는 여전히 엔티티가 남아 있음
present = taskManagerRepository.findById(savedTaskId).isPresent();
System.out.println("After removeAllByIdList, Task is still present: " + present);
// flush, clear로 영속성 컨텍스트와 데이터베이스 동기화
em.flush();
em.clear();
// Now, trying to find the deleted entities should return null
present = taskManagerRepository.findById(savedTaskId).isPresent();
System.out.println("After flush and clear, Task is still present: " + present);
}
}
Querydsl도 deleteAllByIdInBatch(JPQL Batch)와 동일한 결과를 보이는걸 알 수 있다.
Querydsl로 delete, update 연산을 할 때도 연산 후 flush, clear을 통해 영속성 컨텍스트를 초기화해주어야 한다.
여기서 잠깐!
❓ 내가 테스트할 때는 flush, clear 안해도 삭제 결과가 잘 반영되는데요?
만약 나처럼 테스트를 했는데 flush, clear를 호출하지 않고도 삭제 연산이 잘 반영되어 있을 수 있다.
위 테스트 코드처럼 엔티티를 조회할 때 JPA의 findById 메서드가 아니라 JPQL이나 Querydsl로 select해오는 경우 다른 결과를 보게 될 수 있다.
예제
Querydsl로 select해오는 쿼리를 만들어서 테스트해보자
@Repository
@RequiredArgsConstructor
public class JpaTaskManagerRepositoryImpl implements JpaTaskManagerRepositoryCustom {
private final JPAQueryFactory query;
@Override
public void removeAllByIdList(List<Long> taskIdList) {
QTaskEntity t = QTaskEntity.taskEntity;
query
.delete(t)
.where(t.id.in(taskIdList))
.execute();
}
@Override
public Optional<TaskEntity> searchByTaskId(Long taskId) {
return Optional.ofNullable(query
.selectFrom(QTaskEntity.taskEntity)
.where(QTaskEntity.taskEntity.id.eq(taskId))
.fetchOne());
}
}
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class Test {
@PersistenceContext
private EntityManager em;
@Autowired
private JpaTaskManagerRepository taskManagerRepository;
@Test
void deleteAllByIdInBatch() {
// 두 개의 Task 엔티티를 데이터베이스에 저장
TaskEntity task1 = createDummyTaskEntity();
TaskEntity task2 = createDummyTaskEntity();
List<TaskEntity> taskEntities = taskManagerRepository.saveAll(Arrays.asList(task1, task2));
Long savedTaskId = taskEntities.stream().mapToLong(TaskEntity::getId).boxed().collect(Collectors.toList()).get(0);
// 영속성 컨텍스트에서 엔티티를 조회
boolean present = taskManagerRepository.searchByTaskId(savedTaskId).isPresent(); // Querydsl로 select
System.out.println("Before removeAllByIdList, Task is still present: " + present);
// removeAllByIdList 데이터베이스에서 직접 삭제
taskManagerRepository.removeAllByIdList(Arrays.asList(savedTaskId));
// 조회되지 않음
present = taskManagerRepository.searchByTaskId(savedTaskId).isPresent(); // Querydsl로 select
System.out.println("After removeAllByIdList, Task is still present: " + present);
// flush, clear로 영속성 컨텍스트와 데이터베이스 동기화
em.flush();
em.clear();
// Now, trying to find the deleted entities should return null
present = taskManagerRepository.searchByTaskId(savedTaskId).isPresent(); // Querydsl로 select
System.out.println("After flush and clear, Task is still present: " + present);
}
}
이번 테스트에서는 삭제 연산 후 flush, clear를 하지 않았는데도 삭제 결과가 잘 반영되어 있다.
그럼 영속성 컨텍스트에서 해당 엔티티가 삭제되있는걸까? 아니다. clear 전까지는 여전히 영속성 컨텍스트에 엔티티가 저장되어 있다.
그럼 왜 로그에서는 After removeAllByIdList, Task is still present: false로 나왔을까? JPQL/Querydsl의 조회 방식때문이다.
JPQL/Querydsl로 조회할 때의 흐름은 다음과 같다.
- JPQL/Querydsl은 영속성 컨텍스트를 거치지 않고 DB에 쿼리를 실행한다.
- 쿼리 실행 후 조회 결과로 반환된 엔티티를 영속성 컨텍스트에 저장하게 된다.
- 만약 조회한 엔티티가 영속성 컨텍스트에 있다면, 영속성 컨텍스트에 있는 엔티티를 사용한다.
findById는 영속성 컨텍스트를 먼저 확인하지만, JPQL/Querydsl은 DB에 쿼리를 실행한 후 결과 값을 영속성 컨텍스트와 비교해서 반영한다. 위 테스트에서도 먼저 DB에서 select 쿼리를 실행했고 엔티티가 조회되지 않았기 때문에 그대로 null로 반환된 것이다.
findById로 테스트했을 때와 Querydsl select로 테스트했을 때의 로그를 비교해보면, Querydsl select로 테스트했을 때 select 쿼리가 실행되는 것을 확인할 수 있다. 이는 Querydsl이 DB에 직접 쿼리를 실행했기 때문이다.
아래 테스트를 통해 searchByTaskId 메서드에서는 엔티티가 조회되지 않지만 영속성 컨텍스트에서는 해당 엔티티가 조회되는 걸 확인 할 수 있다.
// deleteAllByIdInBatch로 데이터베이스에서 직접 삭제
taskManagerRepository.removeAllByIdList(Arrays.asList(savedTaskId));
present = taskManagerRepository.searchByTaskId(savedTaskId).isPresent();
System.out.println("After removeAllByIdList, Task is still present: " + present);
boolean presentInEntityManager = Optional.ofNullable(em.find(TaskEntity.class, savedTaskId)).isPresent();
System.out.println("After removeAllByIdList, Task is still present in EntityManager: " + presentInEntityManager);
After removeAllByIdList, Task is still present: false
After removeAllByIdList, Task is still present in EntityManager: true
결론
결론적으로 나는 이미 Querydsl을 사용하고 있었기 때문에 Querydsl을 사용해 일괄 삭제처리를 했다.
delete, update 처리한 엔티티를 연산 후에 다뤄야 할 경우에는 EntityManger flush, clear를 통해 영속성 컨텍스트를 초기화 해주었다.
@Repository
@RequiredArgsConstructor
public class JpaTaskManagerRepositoryImpl implements JpaTaskManagerRepositoryCustom {
private final JPAQueryFactory query;
private final EntityManager em;
@Override
public void removeAllByIdList(List<Long> taskIdList) {
QTaskEntity t = QTaskEntity.taskEntity;
query
.delete(t)
.where(t.id.in(taskIdList))
.execute();
em.flush();
em.clear();
}
}
@Query 애너테이션을 붙여 사용하는 JPQL로 처리를 하는 경우에는 해당 쿼리에 @Modifying 애너테이션에 clearAutomatically=true 속성을 붙이면된다. clearAutomatically=true 속성을 붙이면 @Query로 정의된 JPQL이 실행된 후에 자동으로 clear 메서드를 실행해 영속성 컨텍스트를 초기화 해준다. (Querydsl에서는 @Modifying 애너테이션이 동작하지 않는다.)
@Query("DELETE FROM TaskEntity t WHERE t.id IN :taskIdList")
@Modifying(clearAutomatically = true)
void removeAllByIdList(@Param("taskIdList") List<Long> taskIdList);
요약
JPA에서 제공하는 일괄 삭제 메서드(deleteAll, deleteAllById 등)는 삭제할 데이터가 많을수록 심각한 성능 문제를 발생시킬 수 있다. 각 엔티티에 대해 개별적인 SELECT, DELETE 쿼리를 실행하기 때문이다.(N+1 문제)
일괄 삭제를 효율적으로 처리하기 위해서는 다음과 같은 방법을 사용할 수 있다.
- JPQL Batch 메서드(deleteAllByIdInBatch, deleteAllInBatch 등)
- Querydsl을 사용한 벌크 삭제
- JPQL을 직접 작성하여 벌크 삭제
위 방법들은 단일 쿼리로 벌크 삭제를 하기 때문에 성능상 이점이 있다.
주의할 점은 이러한 벌크 연산은 영속성 컨텍스트를 거치지 않고 직접 DB에 쿼리를 실행하기 때문에 영속성 컨텍스트와 DB간의 데이터 불일치가 발생할 수 있다.
따라서 벌크 연산 후에는 EntityManager의 flush(), clear() 메서드를 호출해서 영속성 컨텍스트를 초기화하는 것이 좋다.
영속성 컨텍스트를 초기화 함으로써 DB와의 상태를 동기화하고 이후 작업에서 일관성을 유지할 수 있다.
제가 설명한 내용이나 코드 해석이 잘못될 수 있습니다.
공개된 공간에 올려두고 다른 분들의 의견을 나누면서 성장하고 싶습니다.
잘못된 내용이나 부족한 내용이 있다면 꼭 지적해주시길 부탁드립니다.
참조
https://jojoldu.tistory.com/235
http://querydsl.com/static/querydsl/3.7.4/reference/ko-KR/html_single/#d0e354
https://blog.akquinet.de/2020/06/21/jpa-pitfalls-9-update-delete-and-persistence-context/
'JPA' 카테고리의 다른 글
JPA - 연관 관계를 위한 불필요한 select 줄이기(getReferenceById()) (0) | 2024.11.18 |
---|---|
Querydsl - Expressions클래스로 select에서 상수 사용하는 법 (1) | 2024.11.14 |
Spring Data JPA - 외래키(Foreign Key)를 복합 기본키(Composite Primary Key)로 사용하기 (1) | 2024.11.08 |
JPA - 하나의 컬럼에 여러 개의 데이터를 저장하기 (0) | 2023.05.08 |
JPA - Querydsl를 사용해 DTO 받는 방법 (0) | 2023.03.02 |