카페 주문 플랫폼 장바구니 비우기 기능을 테스트하던 중, 로그에서 이상한 점을 발견했다.
단 10개의 상품을 삭제하는데 DELETE 쿼리가 11번이나 실행되고 있었다.
처음에는 단순히 비효율적인 구현 때문이라고 생각했다.
하지만 원인을 하나씩 살펴보면서,
JPA에서의 삭제 방식과 트랜잭션 처리에 대해 다시 고민하게 되었다.
이 글에서는 원인 분석과 여러 삭제 방식(Bulk Delete, Cascade 등)을 비교하며
직접 측정해본 과정을 정리해보려고 한다.

문제 상황 분석

장바구니 관련 테이블은 다음과 같이 구성되어 있다:
문제 코드

사용자가 장바구니 "전체 비우기" 버튼을 클릭했을 때 실행되는 메서드이다.
@Transactional
public void clearCart(Long memberId, Long cartId) {
List<CartItem> items = cartItemRepository.findByCartId(cartId);
for (CartItem ci : items) {
cartOptionRepository.deleteByCartItemId(ci.getId());
// 반복문 안에서 매번 개별 DELETE 실행
}
cartItemRepository.deleteAllInBatch(items);
}
문제점 :
장바구니 상품의 옵션을 삭제할 때,
반복문 안에서 각 CartItem에 대해 매번 개별 DELETE 쿼리가 실행되고 있다.
실행된 쿼리 로그 분석
-- 1. 장바구니 조회
SELECT c1_0.cart_id, c1_0.member_id
FROM cart c1_0
WHERE c1_0.cart_id=?
-- 2. 장바구니 상품 조회
SELECT ci1_0.cart_item_id, ci1_0.cart_id, ci1_0.price, ci1_0.product_id, ci1_0.quantity
FROM cart_item ci1_0
JOIN cart c1_0 ON c1_0.cart_id=ci1_0.cart_id
WHERE c1_0.cart_id=?
-- 3. 장바구니 옵션 개별 삭제 (10번 반복)
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
-- 4. 장바구니 상품 일괄 삭제
DELETE FROM cart_item
WHERE cart_item_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
쿼리 실행 결과 :
장바구니 상품이 10개일 때,
DELETE 쿼리가 11번 실행되는 비효율이 발생했다.
트랜잭션 관점의 문제
불필요한 네트워크 왕복으로 인한 트랜잭션 보유 시간 과다
반복문으로 개별 DELETE를 수행할 때 발생하는 N+1 삭제 문제는 벌크 삭제를 통해 해결할 수 있다.
벌크 삭제는 여러 개의 데이터를 한 번의 쿼리로 한꺼번에 삭제하는 방식을 말한다.
영속성 컨텍스트를 거치는 단계를 모두 건너뛰고 데이터베이스에 직접 delete sql을 실행하기 때문에 빠르다.
벌크 삭제 방식에는 두 가지 방법이 있다.
1. deleteAllInBatch() 메서드로 삭제 수행
deleteAllInBatch는 Spring Data JPA가 기본 제공하는 메서드이다.
먼저 엔티티를 조회하고 엔티티 컬렉션을 받아서 IN절로 삭제를 수행한다. (SELECT + DELETE, 총 두 번의 쿼리)
SELECT * FROM cart_item WHERE cart_id = ?; -- 먼저 조회
DELETE FROM cart_item WHERE cart_item_id IN (?, ?, ...); -- 그 다음 삭제
2. Repository에 벌크 삭제 쿼리 추가 (@Query + @Modifying)
@Query + @Modifying 방식은 조회 없이 바로 DELETE 쿼리를 실행하는 특징이 있다.
영속성 컨텍스트를 거치지 않고 SQL을 직접 실행하여, 엔티티 로딩 없고 메모리 사용 최소화할 수 있는 방식이다.
@Modifying
@Query("DELETE FROM CartOption co WHERE co.cartItem.cart.id = :cartId")
void deleteByCartId(@Param("cartId") Long cartId);
deleteAllInBatch()와 비교했을 때 조회 없이 바로 삭제가 가능하다는 장점이 있지만, Repository에 쿼리를 직접 작성한 메서드를 추가해야 한다.
DELETE FROM cart_item WHERE cart_id = ?; -- 바로 삭제 (조회 없음)
Cascade는 부모 엔티티의 작업이 자식의 엔티티에게 전파되는 기능을 말한다.
부모를 삭제하면 자식도 자동으로 삭제돼서, 외래키 제약 조건 문제를 자동으로 해결해준다.
@OneToMany(mappedBy = "cartItem", cascade = CascadeType.ALL) // 모든 작업 전파
private List<CartOption> options = new ArrayList<>();
한 명의 사용자가 3개의 옵션이 적용된 10개의 상품이 들어있는 장바구니를 비우는 상황을 가정했다.
개선 전 방식과 3가지 개선 방식(벌크 삭제 2가지, Cascade)을 비교하여 각각 10회씩 반복 측정한 후 평균 실행 시간을 비교했다.

각 측정마다 entityManager.clear()로 캐시를 초기화하여 정확한 성능을 측정했다.

반복문으로 CartOption을 개별 삭제한 후, CartItem을 일괄 삭제하는 기존 방식에 대한 메서드이다.
총 11번의 DELETE 쿼리 수행으로 매우 비효율적으로 장바구니를 비우고 있다.




@Query+@Modifying 방식은 JPQL로 직접 작성한 벌크 삭제 쿼리를 사용한다.
조회 없이 바로 벌크 삭제하고 쿼리 개수가 가장 적게 발생한 방식이다.
개선 전 대비 367ms 빠른 방식이라는 것을 알 수 있었다. (13.5% 향상)


deleteAllInBatch는 엔티티를 먼저 조회하고 조회한 엔티티들을 메모리에 로딩하여 일괄 삭제하는 방식이다.
CartOption과 CartItem 엔티티를 각각 한번씩 조회하여 두 번의 SELECT문과 두 번의 DELETE문이 발생하였다.
네가지 방식 중 가장 짧은 평균 실행시간을 보였고, 개선 전 대비 446ms 빠른 방식이었다. (16.8%)
@Query + @Modifying 방식보다는 31ms 빠르게 측정되었다.



CartItem의 options를 cascadeType.ALL을 적용하여 JPA가 연관관계에 따라 자동 삭제하도록 하였다.
N+1 문제로 쿼리 많이 발생하지만, 그럼에도 개선 전과 비교했을 때 75ms 더 빨랐다는 걸 알 수 있었다. (2.8% 향상)
| 순위 | 방식 | 평균 시간 | 쿼리 개수 | 개선율 |
|---|---|---|---|---|
| 1 | deleteAllInBatch | 227ms | 4번 | 16.8% |
| 2 | @Query + @Modifying | 258ms | 2번 | 13.5% |
| 3 | Cascade | 298ms | 40번+ | 2.8% |
| 4 | 개선 전 (반복문) | 303ms | 11번 | 기준 |
이론적으로 @Query + @Modifying 방식이 조회없이 바로 삭제하기 때문에 가장 빠를 것으로 예상했다.
하지만 예상과 달리 실제 테스트 결과는 deleteAllInBatch 방식이 가장 빠른 성능을 보였다.
왜 예상과 다를까 ?
내가 실행한 테스트에서는 소량 데이터(상품 10개, 옵션 30개)가 이러한 결과를 보인 가장 큰 요인으로 예상된다.
deleteAllInBatch 는 먼저 엔티티를 조회한 후 삭제하는 2단계 과정을 거친다.
List options = cartOptionRepository.findByCartId(cartId);
// 30개 엔티티 조회
DELETE FROM cart_option
WHERE cart_option_id IN (1, 2, 3, 4, 5, ..., 30); // IN절로 삭제
MySQL은 이 IN 절을 처리할 때 매우 효율적으로 동작한다.
이러한 과정을 거쳐 삭제 처리가 완료된다.
반면 @Query + @Modifying 방식은 조회 없이 바로 삭제하지만, 서브쿼리를 실행해야 한다.
DELETE FROM cart_option
WHERE cart_item_id IN (
SELECT cart_item_id FROM cart_item WHERE cart_id = 1
);
MySQL이 위 쿼리를 처리하는 방법은 다음과 같다.
SELECT cart_item_id FROM cart_item WHERE cart_id = 1
→ 결과: [101, 102, 103, 104, 105, 106, 107, 108, 109, 110]
이 과정에서 아래와 같은 과정이 이루어진다.
- cart_item 테이블 스캔
- WHERE 조건 평가
- 결과 10개 추출
- 임시 메모리 영역에 저장
조인 조건 : cart_option.cart_item_id = 임시테이블.cart_item_id
이처럼 @Query + @Modifying 방식은 서브쿼리 실행, 임시 테이블 생성, 조인 처리 등의 구조적 오버헤드가 발생한다.
이러한 오버헤드는 데이터 개수와 무관하게 항상 발생하는 준비 작업이다.
소량 데이터에서는 실제 데이터를 삭제하는 시간보다 이런 준비 작업 시간이 더 오래 걸려서 비효율적이다.
deleteAllInBatch의 IN 절 방식은 임시 테이블 생성 X, 서브쿼리 실행 X, 단순 인덱스 스캔으로 빠른 처리하기 때문에
30개 정도의 소량 데이터에서는 조회 비용을 감안하더라도,
MySQL의 IN 절 최적화가 매우 효과적으로 작동하여 서브쿼리 방식보다 빠른 성능을 보인다.
테스트 결과의 한계
이번 테스트는 소량 데이터(상품 10개 + 옵션 30개)로만 진행했다.
deleteAllInBatch가 가장 빨랐지만, 대량 데이터에서는 결과가 달라질 수 있다.
deleteAllInBatch는 데이터가 적을 땐 부담이 없지만 1000개가 되면
1000개 객체를 메모리에 로딩하고, 긴 IN 절 (1000개) 파싱해야 하기 때문에 부담이 크다.
반면 @Query + @Modifying 는 조회 없이 서브쿼리로 바로 삭제하므로
데이터가 많아져도 안정적일 것으로 예상된다.
이론대로만 생각하면 놓치는 것들이 있다
처음에는 @Query + @Modifying 방식이
쿼리를 한 번만 실행하니 당연히 가장 빠를 것이라고 생각했다.
하지만 실제로 테스트해보니
deleteAllInBatch가 더 빠른 결과가 나와 예상과 달라서 꽤 당황했다.
이 경험을 통해
이론에서 배운 내용만으로 성능을 판단하는 데에는 한계가 있다는 것을 느꼈다.
특히 데이터 개수나 실행 상황에 따라 결과가 달라질 수 있다는 점을 직접 확인할 수 있었다.
데이터가 많지 않은 경우에는 deleteAllInBatch 방식이 더 효율적으로 동작했고
데이터가 많아질 경우에는 다른 방식이 더 적합할 수도 있다는 가능성을 알게 되었다
앞으로는 “이 방식이 더 좋다”라고 단정하기보다,
지금 상황에서는 왜 이 방식이 맞는지 고민해봐야겠다고 느꼈다.
성능은 생각이 아니라 직접 재봐야 알 수 있다
기존 코드에서 DELETE 쿼리가 여러 번 실행되는 것을 보고
“쿼리가 많으니까 무조건 느릴 것 같다”라고 생각했다.
그래서 쿼리 수를 줄이는 데 집중했는데,
막상 측정해보니 쿼리 수가 더 많았던 Cascade 방식이
오히려 전체 실행 시간은 조금 더 빠른 결과를 보였다.
이 결과를 통해
쿼리 개수가 적다고 해서 항상 빠른 것은 아니라는 점을 배웠다.
각 쿼리가 어떤 방식으로 실행되는지,
그 과정에서 어떤 비용이 드는지도 함께 봐야 한다는 것을 알게 되었다.
앞으로는 추측으로 결론을 내리기보다,
반드시 로그와 측정 결과를 먼저 확인하는 습관을 들여야겠다고 느꼈다.