영호

[JPA] 엔티티 delete 시 발생하는 N+1 개선 본문

JPA

[JPA] 엔티티 delete 시 발생하는 N+1 개선

0h0 2023. 10. 4. 12:56

들어가면서,

프로젝트 중 spring data jpa 의 deleteAll() 을 사용할 때 매우 많은 쿼리가 발생했는데, 그 이유와 해결방안에 대해서 작성할 예정입니다.

아래 사진과 같이 Coupon, CouponDesign 이 연관관계가 설정되어 있고, cascade.REMOVE 옵션도 설정되어 있는 상태에서 여러개의 Coupon 을 지우는 상황이다.

Coupon

@Entity
public class Coupon extends BaseDate {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    private Boolean deleted = Boolean.FALSE;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "coupon_design_id")
    private CouponDesign couponDesign;
}

CouponDesign

@Entity
public class CouponDesign extends BaseDate {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    private String frontImage;
    private String backImage;
}

예시 상황

회원 탈퇴 발생 시 회원이 발급받았던 Coupon을 데이터베이스에서 삭제 하는 요구사항이 있다. 그리고 이 과정에서 Coupon 과 연관된 CouponDesign 도 같이 삭제해야한다. CouponDesign 은 Coupon 에서만 사용하고 있기 때문에 cascade 옵션을 통해 요구사항을 만족하려고 했다.

 

Coupon 삭제 흐름은 아래와 같다.

1. customer 에 맞는 List<Coupon> 을 조회

2. repository.deleteAll(coupons) 를 통해 회원 탈퇴 시 해당 회원이 보유한 쿠폰을 삭제하고 있다.

repository.deleteAll() 호출 시 발생하는 쿼리

deleteAll(List.of(Coupon)) 을 통해 한 개의 쿠폰을 지운다고 가정해보자. 이를 실행하면 아래와 같이 4개의 쿼리가 나간다.

  • Coupon 조회
  • CouponDesign 조회
  • Coupon 삭제
  • CouponDesign 삭제

만약 지우려는 쿠폰이 2개라면 8개, 3개면 12개의 쿼리가 발생한다.

Hibernate: 
    select
        c1_0.id,
        c1_0.coupon_design_id,
        c1_0.customer_id,
    from
        coupon c1_0 
    where
        c1_0.id=? 
Hibernate: 
    select
        c1_0.id,
        c1_0.back_image_url,
        c1_0.deleted,
        c1_0.front_image_url,
    from
        coupon_design c1_0 
    where
        c1_0.id=? 
Hibernate: 
    UPDATE
        coupon 
    SET
        deleted = true 
    WHERE
        id = ?
Hibernate: 
    UPDATE
        coupon_design 
    SET
        deleted = true 
    WHERE
        id = ?

많은 쿼리가 나가는 이유

분명히 Coupon 만 지우려고 했는데 select, delete 쿼리가 예상보다 많이 나가고 있다. 그 이유는 뭘까? SimpleJpaRepository 코드를 살펴보면 그 이유를 알 수 있다.

@Override
@Transactional
public void deleteAll(Iterable<? extends T> entities) {

	Assert.notNull(entities, "Entities must not be null");

	for (T entity : entities) {
		delete(entity);
	}
}

@Override
@Transactional
@SuppressWarnings("unchecked")
public void delete(T entity) {

	Assert.notNull(entity, "Entity must not be null");

	if (entityInformation.isNew(entity)) {
		return;
	}

	Class<?> type = ProxyUtils.getUserClass(entity);

	T existing = (T) em.find(type, entityInformation.getId(entity));

	// if the entity to be deleted doesn't exist, delete is a NOOP
	if (existing == null) {
		return;
	}

	em.remove(em.contains(entity) ? entity : em.merge(entity));
}
  1. deleteAll(entities) 호출 시 entities 를 순회하면서 delete(entity) 를 호출한다.
  2. delete(entity)는 내부적으로 em.find() 를 통해 지우려는 entity 를 영속성 컨텍스트에 등록한다.
  3. 이후 em.remove() 를 호출한다. 여기서 cascade 가 걸려있는 엔티티도 삭제하기 위해 영속성 컨텍스트에 등록하는 과정에서 select 쿼리가 추가적으로 나가는 걸로 추측된다.

이제 발생했던 4개의 쿼리가 SimpleJpaRepository 의 어느 부분에서 발생했는지 분석해보자.

  • Coupon 조회2번의 em.find() 에서 호출된다.
  • CouponDesign 조회3번의 em.remove() 에서 cascade 옵션으로 인해 select 쿼리가 발생한다.
  • Coupon 삭제 역시 3번의 em.remove() 에서 발생한다.
  • CouponDesign 삭제3번의 em.remove() 를 실행할 때 cascade 옵션으로 인해 발생한다.

실제로 Coupon의 Customer 는 cascade 옵션이 없기 때문에 Coupon 삭제 시 Customer 조회, 삭제 쿼리가 발생하지 않고 있다.

이러한 이유로 미루어봤을 때 cascade.REMOVE 옵션이 설정되어 있는 엔티티를 지울 때 JpaRepository 가 제공하는 delete 를 활용하게 되면 예상치 못한 select 쿼리가 발생한다는 것을 알 수 있다.

 

지우려는 엔티티(1) + cascade.REMOVE 가 걸려있는 엔티티 조회(N) + 지우려는 엔티티 별 delete 쿼리 (N)번 만큼의 쿼리가 발생하게 된다.

이 과정에서 한 가지 궁금한 점이 생겼다. 지우려는 엔티티(Coupon) 를 조회할 때 fetch join을 통해 영속성 컨텍스트에 프록시가 아닌 실제 객체를 등록하면 select 쿼리는 발생하지 않을 수도 있겠다는 생각을 했다. 그래서 실제로 List<Coupon> 을 조회할 때 @Query 로 연관 객체를 fetch join 으로 가져오고 deleteAll 을 하니 fetch join 을 한 객체에 한해서 delete 시 select 쿼리가 나가지 않는 것을 확인했다.

해결 방법

결론부터 말하자면 별도의 삭제 jpql을 작성했다.

SimpleJpaRepository 에서 지원하는 delete 는 크게 분류하자면 delete(), deleteAll(), deleteAllInBatch() 정도가 있다. 이 중 delete(), deleteAll() 은 위에서 살펴봤듯이 지우려는 엔티티를 조회(em.find()) 하고 삭제**(em.remove()) 하는 과정에서 예상치 못한 쿼리**가 발생할 수 있다.

 

그에 반면, deleteAllInBatch() 는 쿼리 한 번으로 삭제가 가능하지만 where 절에 삭제하려는 엔티티 개수만큼 or 연산자가 생성된다. 예를 들어 지우려는 엔티티가 3개면 where id = ? or id =? or id = ? 이런식으로 쿼리가 발생한다. 그러나 or 을 사용하면 index 를 타지 않을 확률이 매우 높기 때문에 선택하지 않았다.

 

이 상황에서 delete로 인해 발생하는 쿼리를 줄이기 위해 직접 delete 쿼리를 작성하는 방법밖에 떠오르지 않았다.

jpql 을 사용해 where 절에 in 을 활용해 한 번의 쿼리로 List<Coupon> 데이터를 지우고, 불필요한 select 쿼리 발생을 막을 수 있었다.

@Modifying
@Query("update Coupon c set c.deleted = true where c in :coupons")
void deleteAllCoupons(List<Coupon> coupons);

실제 발생 쿼리

Hibernate: 
/* update
    Coupon c 
set
    c.deleted = true 
where
    c in :coupons */ update coupon 
set
    deleted=true 
where
    id in (?,?,?) 
    and (
        coupon.deleted = false
    )

기존과 다르게 한 번의 쿼리로 원하는 List<Coupon> 데이터 삭제 작업을 마칠 수 있었다.

주의점

jpql 을 활용한 방법은 cascade 옵션을 활용하지 못한다. 그래서 지우려는 엔티티를 참조하는 객체가 많고, 엔티티 삭제 시 참조한고 있는 객체도 지워야 하는 상황이라면 개발자가 직접 참조하고 있는 다른 엔티티를 삭제하는 쿼리를 직접 작성해야 하는 단점이 있다.

 

이러한 점을 고려해 delete 쿼리가 N+1 만큼 발생하더라도 개발자가 모든 엔티티를 지우는 쿼리를 짜는게 부담스러운 상황이라면 JpaRepository 가 제공하는 delete 기능과 cascade 를 활용할 것 같고, 그렇지 않다면 직접 jpql 을 통해 발생하는 쿼리를 줄여 성능을 챙길 것 같다.

 

혹시 잘못된 내용 있으면 댓글로 알려주시면 감사하겠습니다~

Comments