[트러블슈팅] 쿼리 튜닝과 StaleObjectStateException

김지현·2024년 2월 2일
0

Spring Boot 프로젝트

목록 보기
14/20

문제: Delete 쿼리 n개 발생

오늘의 목적은 연관 관계가 설정된 엔티티의 삭제시 n개의 delete 쿼리가 나가는 것을 발견하고 이를 1개의 쿼리로 처리하기 위한 쿼리 튜닝이었다.
해당 로직은 구매 취소 로직으로 구매를 취소하면 purchase 엔티티가 삭제되어야 하고 그 이전에 구매상품 내역인 purchaseProductList가 삭제되어야 했다. 이를 위해 코드를 다음과 같이 작성했다.

	@Transactional
    public void cancelPurchase(Long purchaseId, Member member) {

        Purchase purchase = purchaseRepository.findByIdWithProducts(purchaseId).orElseThrow(() ->
            new GlobalException(PurchaseErrorCode.NOT_FOUND_PURCHASE)
        );
        ...

        purchaseProductRepository.deleteByPurchaseId(purchaseId);
        purchaseRepository.deleteById(purchaseId);
    }

쿼리 튜닝: JPQL

purchaseId를 통해 해당 id를 가진 purchaseProduct들을 모두 삭제해주고 purchase를 마지막에 삭제한다. 그런데 이때 해당 메서드를 모두 JPA 메서드로 작성하였더니 purchaseProduct 삭제 시에 해당 인스턴스의 개수만큼 delete 쿼리가 날아가게 된다. 그래서 JPQL로 변경하였다.

	@Modifying
    @Query("DELETE FROM PurchaseProduct pp WHERE pp.purchase.id = :purchaseId")
    void deleteByPurchaseId(@Param("purchaseId") Long purchaseId);

오류: StaleObjectStateException

여기서 오류가 발생하였다.

StaleObjectStateException: Row was updated or deleted by another transaction

이미 다른 트랜잭션에 의해 삭제되거나 수정된 행을 다시 삭제하려고 시도해서 발생했다고 한다. 코드를 뜯어보다가 cascade와 orphanRemoval 설정이 되어 있음을 발견했다.

public class Purchase{
	@OneToMany(mappedBy = "purchase", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    private final List<PurchaseProduct> purchaseProductList = new ArrayList<>();
    }
    
public class PurchaseProduct {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "purchase_id")
    private Purchase purchase;
    }
    

원인

서비스 로직에서 자식 엔티티인 purchaseProduct를 먼저 삭제하고 부모 엔티티인 purchase를 삭제하도록 하였는데 이 경우 purchase 삭제 시, orphanRemoval = true 설정에 의해 purchaseProduct를 삭제하려다가 이미 삭제되어있음을 발견하고 오류가 발생하는 듯하다.

해결 방법

해당 오류의 해결을 위해 생각해본 방법은 다음과 같다

  1. purchaseProduct 서비스 로직에서 delete하도록 할 것
  2. purchase의 cascade와 orphanRemoval 설정을 변경할 것
  3. purchase 먼저 삭제 할 것

먼저 1번 방법의 경우... purchaseProduct가 중간테이블로 존재하기 때문에 따로 서비스 클래스가 존재하지 않아서 2번과 3번 방법을 우선 시도하였다.

2번 방법으로 해결하기 위해 purchase 엔티티에서 purchaseProduct와의 연관관계 정의시 cascade와 orphanRemoval 설정을 따로 해주지 않고 기본값으로 적용되도록 하였다.

public class Purchase{
	@OneToMany(mappedBy = "purchase", fetch = FetchType.LAZYe)
    private final List<PurchaseProduct> purchaseProductList = new ArrayList<>();
    }

이렇게 하였더니 원래의 의도대로 purchaseProduct 삭제시 delete 쿼리가 한 개만 날아가면서 삭제도 잘 되었다.

3번 방법으로 해결하기 위해 purchase를 먼저 삭제하고 orphanRemoval = true 설정에 의해 purchaseProduct가 잘 삭제되는지 확인해보았다. purchaseProduct를 명시적으로 삭제하는 로직은 제외하였다.

@Transactional
    public void cancelPurchase(Long purchaseId, Member member) {

        ...
        purchaseRepository.deleteById(purchaseId);
    }

이 경우 purchase만 삭제해도 연관된 purchaseProduct가 모두 삭제된다. 그러나 처음에 발생했던 문제 (delete 쿼리가 purchaseProduct 수만큼 발생)가 그대로 존재하게 된다.

결론

현재 우리 프로젝트는 purchase 삭제 로직을 해당 메서드만 가지고 있고 이후 프로젝트의 확장 계획이 없으므로 purchase와 purchaseProduct의 cascade와 orphanRemoval 설정을 기본값으로 둔 뒤 purchaseProductList를 먼저 하나의 delete 쿼리로 삭제하고, 이후 purchase를 마지막으로 삭제하는 로직을 선택하였다. 쿼리의 개수가 적을수록 성능 향상에 영향을 미치는 것은 명백하므로 성능 개선에 중점을 두고 선택했다.

다만 이후 프로젝트의 확장성을 고려한다면 purchaseProduct 서비스 로직을 따로 만드는 1번방법을 고려해야 할 듯하다.

0개의 댓글