영호
[JPA] 엔티티 delete 시 발생하는 N+1 개선 본문
들어가면서,
프로젝트 중 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));
}
- deleteAll(entities) 호출 시 entities 를 순회하면서 delete(entity) 를 호출한다.
- delete(entity)는 내부적으로 em.find() 를 통해 지우려는 entity 를 영속성 컨텍스트에 등록한다.
- 이후 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 을 통해 발생하는 쿼리를 줄여 성능을 챙길 것 같다.
혹시 잘못된 내용 있으면 댓글로 알려주시면 감사하겠습니다~
'JPA' 카테고리의 다른 글
[JPA] 엔티티 구조 변경을 통한 로직 복잡성 낮추기, 쿼리 개선기 (2) | 2023.11.11 |
---|---|
[JPA] 상속관계 사용 시 주의점 (1) | 2023.10.05 |
[JPA] service 테스트 코드 개선하기 (3) | 2023.08.13 |
[JPA] 테스트에서 repository.save()해도 createdAt이 null일 때 (0) | 2023.08.06 |
[JPA] 다양한 기본 키 자동 생성 전략 (6) | 2023.07.02 |