DataRow Count & Query Count 의존 끊기

YoonJuHo·2025년 4월 18일

포트폴리오

목록 보기
7/9
post-thumbnail

본 프로젝트는 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 QueryDelete QueryData 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);
    });
}
  • moment 별 comment, moment_image에 대해 Select Qeury 발생
  • 조회된 모든 것들마다 개별적으로 Delete Query 발생

[문제 상황 예시]

moment가 10개, moment 당 comment가 10개, moment_image가 5개라고 한다면

  • 특정 memory id를 가지는 moment 를 조회하는 쿼리 1
  • 특정 moment에서 모든 comment 조회하는 쿼리 10
    • comment 삭제 쿼리 10(moment 수) X 10(comment 수) = 100
  • 특정 moment에서 모든 moment_image 조회하는 쿼리 10
    • moment_image 삭제 쿼리 10(moment 수) X 5(moment_image 수) = 50
  • moment 삭제 쿼리 10
  • memory_member 삭제 쿼리 1
  • memory 삭제 쿼리 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));
        }
    }
}
  1. deleteAllById(Iterable<? extends ID> ids) 호출 시 ids 를 순회하면서 deleteById(ID id) 를 호출
  2. deleteById(ID id)는 내부적으로 em.find() 를 통해 지우려는 entity 를 영속성 컨텍스트에 등록
  3. 이후 em.remove() 를 호출.
    1. 여기서 Cascade 가 걸려있는 엔티티도 삭제하기 위해 영속성 컨텍스트에 등록하는 과정에서 select 쿼리가 추가적으로 발생.

JpaRepository 가 제공하는 delete 를 활용하게 되면 예상치 못한 select 쿼리가 발생한다는 것을 알 수 있었습니다.


[해결 방안]

✅ [해결 방안 1 - 직접적인 DELETE 쿼리 사용으로 일괄 삭제(Bulk Delete)]

  • 성능 최적화를 위해 직접적인 DELETE 쿼리를 작성
  • JPQL을 사용하여 조회 없이 한 번의 쿼리로 삭제
  • in절을 활용해 여러 조건에 해당하는 데이터를 한 번에 삭제할 수 있도록 구성
@Modifying
@Query("DELETE FROM Comment c WHERE c.moment.id IN :momentIds")
void deleteAllByMomentIdInBulk(@Param("momentIds") List<Long> momentIds);

❌ [해결 방안 2 - Spring Data JPA가 제공하는 deleteAllInBatch 메서드 사용]

  • deleteAllInBatch() 메서드는 엔티티 리스트 전체를 한 번의 SQL 문으로 삭제
momentRepository.deleteAllInBatch();

[해결방안 1(Bulk Delete) 선택 이유]

[조건 기반 삭제의 필요성]

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);
}

[개선된 문제 상황]

[문제 상황 예시에서 발생했던 200개의 쿼리가 5개로 줄일 수 있었습니다.]

Category가 10개, Staccato 당 comment가 10개, staccato_image가 5개라고 한다면

  • 특정 category_id를 가지는 staccato_ids 를 조회하는 쿼리 1
  • staccato_ids에 포함되는 staccato_id를 가지는 모든 comment 삭제하는 쿼리 1
  • staccato_ids에 포함되는 staccato_id를 가지는 모든 staccato_image 삭제하는 쿼리 1
  • category_member 삭제 쿼리 1
  • category 삭제 쿼리 1

[결론]

연관 엔티티 삭제 시 JpaRepository와 Cascade 설정으로 인해 불필요하게 대량의 SELECT 및 DELETE 쿼리가 발생하는 문제를 확인하였습니다. 이를 해결하기 위해, JPQL 기반의 Bulk Delete 기법을 도입하여 조건에 맞는 데이터를 일괄 삭제함으로써 쿼리 발생 횟수를 약 200개에서 5개로 줄일 수 있었습니다.

이와 같은 접근 방식은 단순히 쿼리 수를 줄여 데이터베이스 부하를 경감시키는 것을 넘어, 시스템의 성능과 응답 속도를 향상시키는 효과를 가져올 수 있다고 생각합니다.

0개의 댓글