본 프로젝트는 Spring Boot와 MySQL을 활용한 기록 프로젝트 입니다.
해당 글은 기록 프로젝트 진행 중, 특정 레코드 삭제 시 JpaRepository와 Cascade로 인해 불필요한 SELECT 및 개별 DELETE 쿼리가 다량 발생하는 문제를 해결하고자 선택한 기술적 접근 방식에 대해 설명합니다.
Memory 삭제 API 호출 시, 관련 Moment와 그에 속한 Comment, MomentImage 등 연관 엔티티 삭제 과정에서 약 200개의 쿼리가 발생하는 문제를, JPQL 기반의 Bulk Delete를 활용해 조건에 맞는 데이터만 일괄 삭제함으로써 총 쿼리 수를 5개로 줄여 성능 최적화를 달성하였습니다.
// Moment.java
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "memory_id", nullable = false)
private Memory memory;
@Embedded
private MomentImages momentImages = new MomentImages();
@OneToMany(mappedBy = "moment", orphanRemoval = true, cascade = CascadeType.ALL)
private List<Comment> comments = new ArrayList<>();
// MomentImages.java
@OneToMany(mappedBy = "moment", orphanRemoval = true, cascade = CascadeType.ALL)
private List<MomentImage> images = new ArrayList<>();
Memory(부모 엔티티)가 N개의 Moment(자식 엔티티)를 가지고, Moment(부모 엔티티)가 N개의 Comment, MomentImage (자식 엔티티)를 가집니다. (양방향 연관관계 설정)

특정 Memory(추억에 해당하는 도메인) 삭제 API 호출 시, 불필요한 Select Query와 Delete Query가 Data row 수만큼 나가는 문제상황이 발생했습니다.
// Memory.java
@Transactional
public void deleteMemory(long memoryId, Member member) {
memoryRepository.findById(memoryId).ifPresent(memory -> {
validateOwner(memory, member);
momentRepository.deleteAllByMemoryId(memoryId);
memoryRepository.deleteById(memoryId);
});
}
Select Qeury 발생Delete Query 발생moment가 10개, moment 당 comment가 10개, moment_image가 5개라고 한다면
1개10개100개10개50개10개1개1개= 약 200개의 쿼리가 나가게 됩니다.
// Memory.java
@Transactional
public void deleteMemory(long memoryId, Member member) {
memoryRepository.findById(memoryId).ifPresent(memory -> {
validateOwner(memory, member);
**momentRepository.deleteAllByMemoryId(memoryId);**
**memoryRepository.deleteById(memoryId);**
});
}
// SimpleJpaRepository.java
@Transactional
**public void deleteById(ID id) {**
Assert.notNull(id, "The given id must not be null");
this.findById(id).ifPresent(this::delete);
}
...
@Transactional
**public void deleteAllById(Iterable<? extends ID> ids) {**
Assert.notNull(ids, "Ids must not be null");
Iterator var3 = ids.iterator();
while(var3.hasNext()) {
ID id = (Object)var3.next();
this.deleteById(id);
}
}
...
**public Optional<T> findById(ID id) {**
Assert.notNull(id, "The given id must not be null");
Class<T> domainType = this.getDomainClass();
if (this.metadata == null) {
return Optional.ofNullable(this.entityManager.find(domainType, id));
} else {
LockModeType type = this.metadata.getLockModeType();
Map<String, Object> hints = this.getHints();
return Optional.ofNullable(type == null ? this.entityManager.find(domainType, id, hints) : this.entityManager.find(domainType, id, type, hints));
}
}
...
@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.entityManager.find(type, this.entityInformation.getId(entity));
if (existing != null) {
this.entityManager.remove(this.entityManager.contains(entity) ? entity : this.entityManager.merge(entity));
}
}
}
entity 를 영속성 컨텍스트에 등록Cascade 가 걸려있는 엔티티도 삭제하기 위해 영속성 컨텍스트에 등록하는 과정에서 select 쿼리가 추가적으로 발생.JpaRepository 가 제공하는 delete 를 활용하게 되면 예상치 못한 select 쿼리가 발생한다는 것을 알 수 있었습니다.
@Modifying
@Query("DELETE FROM Comment c WHERE c.moment.id IN :momentIds")
void deleteAllByMomentIdInBulk(@Param("momentIds") List<Long> momentIds);
deleteAllInBatch() 메서드는 엔티티 리스트 전체를 한 번의 SQL 문으로 삭제momentRepository.deleteAllInBatch();
Bulk delete는 특정 조건에 맞는 데이터를 선택적으로 삭제할 수 있는 장점이 있습니다. 만약 특정 조건에 맞는 일부 데이터만 삭제하고자 할 경우, deleteAllInBatch()는 사용할 수 없고 Bulk Delete를 사용해야 합니다. 물론 deleteAllByIdInBatch()와 같이 특정 식별자 목록을 기반으로 일괄 삭제하는 메서드를 활용할 수도 있지만, 이는 자신 테이블의 ID에 해당하는 데이터만 삭제하기에 사용할 수 없었습니다.
따라서 아래와 같이 BulkDelete를 활용하는 코드로 개선되었습니다.
Memory → Category, Moment → Staccato 로 도메인 명 변경이 이루어졌습니다.
@Transactional
public void deleteCategory(long categoryId, Member member) {
categoryRepository.findById(categoryId).ifPresent(category -> {
validateOwner(category, member);
deleteAllRelatedCategory(categoryId);
categoryRepository.deleteById(categoryId);
});
}
private void deleteAllRelatedCategory(long categoryId) {
List<Long> staccatoIds = staccatoRepository.findAllByCategoryId(categoryId)
.stream()
.map(Staccato::getId)
.toList();
staccatoImageRepository.deleteAllByStaccatoIdInBulk(staccatoIds);
commentRepository.deleteAllByStaccatoIdInBulk(staccatoIds);
staccatoRepository.deleteAllByCategoryIdInBulk(categoryId);
categoryMemberRepository.deleteAllByCategoryIdInBulk(categoryId);
}
Category가 10개, Staccato 당 comment가 10개, staccato_image가 5개라고 한다면
1개1개1개1개1개연관 엔티티 삭제 시 JpaRepository와 Cascade 설정으로 인해 불필요하게 대량의 SELECT 및 DELETE 쿼리가 발생하는 문제를 확인하였습니다. 이를 해결하기 위해, JPQL 기반의 Bulk Delete 기법을 도입하여 조건에 맞는 데이터를 일괄 삭제함으로써 쿼리 발생 횟수를 약 200개에서 5개로 줄일 수 있었습니다.
이와 같은 접근 방식은 단순히 쿼리 수를 줄여 데이터베이스 부하를 경감시키는 것을 넘어, 시스템의 성능과 응답 속도를 향상시키는 효과를 가져올 수 있다고 생각합니다.