코진남
Delete문 성능 개선을 위해 본문
2-1. 관계가 있는 Entity 삭제
자 이번엔 다른 엔티티 클래스와 관계가 맺어진 엔티티를 삭제하는 테스트를 해보겠습니다.
테스트 코드는 아래와 같습니다.
@SpringBootTest
class ShopRepositoryTest extends Specification {
@Autowired
private ShopRepository shopRepository
@Autowired
private ItemRepository itemRepository
private final List<Long> SHOP_ID_LIST = new ArrayList<>()
def setup() {
for (long i = 1; i <= 2; i++) {
SHOP_ID_LIST.add(i)
}
}
def cleanup() {
println "======== Clean All ========="
itemRepository.deleteAll()
shopRepository.deleteAll()
}
def "SpringDataJPA에서 제공하는 예약어를 통해 삭제한다 - 부모&자식" () {
given:
createShopAndItem()
when:
shopRepository.deleteAllByIdIn(SHOP_ID_LIST)
then:
shopRepository.findAll().size() == 8
}
private void createShop() {
for (int i = 0; i < 10; i++) {
shopRepository.save(new Shop("우아한서점" + i, "우아한 동네" + i))
}
println "=======End Create Shop======="
}
private void createShopAndItem() {
for (int i = 0; i < 10; i++) {
Shop shop = new Shop("우아한서점" + i, "우아한 동네" + i)
for (int j = 0; j < 3; j++) {
shop.addItem(new Item("IT책" + j, j * 10000))
}
shopRepository.save(shop)
}
println "=======End Create Shop & Item======="
}
}
테스트 기능은 간단합니다.
- shop 테이블 데이터를 10개 생성합니다.
- 1,2 id list를 파라미터로 하여 deleteAllByIdIn 메소드로 삭제합니다.
- 8개가 남았는지 검증합니다.
처음 테스트와 마찬가지로 여기서 사용할 메소드는 deleteAllByIdIn입니다.
@Transactional
@Modifying
long deleteAllByIdIn(List<Long> ids);
그럼 이 테스트 코드를 실행해보겠습니다.
테스트 코드는 성공적으로 실행되지만, 콘솔에 찍힌 쿼리 로그가 너무 많지 않으신가요?
좀 더 로그를 자세히 살펴보겠습니다.
Customer 때와는 또 다른 예상치 못한 select 쿼리가 다수 발생하였습니다.
- in쿼리로 조회하는 쿼리가 처음 실행됩니다.
- Shop Id별로 Item을 조회한다.
- 조회된 Item을 1건씩 삭제한다.
- 조회된 Shop을 1건씩 삭제한다.
왜 Customer를 지울때는 발생하지 않았던 Shop조회가 여기서는 발생했는지 em.remove메소드를 좀 더 파고들어 확인해보겠습니다.
em.remove를 파고들어보면 DefaultDeleteEventListener의 deleteEntity 메소드를 만나게 됩니다.
Break Point를 걸어 하나씩 쫓아가다보면 cascadeBeforeDelete 메소드를 다시 파고들어야함을 알수 있습니다.
그리고 그 안에선 다시 Cascade.cascade가 수행됩니다.
슬슬 냄새가 나기 시작합니다.
Cascade.cascade코드를 확인하고 의심스러운 부분에 Break Point를 걸어 다시 테스트 메소드를 실행해보겠습니다.
Break Point를 지나 persister.getPropertyValue( parent, i ) 메소드가 수행되면!
이렇게 ~~~ from item items0_ where items0_.shop_id=?쿼리가 한줄 수행되었음을 확인할 수 있습니다.
이 코드와 결과로 쉽게 추측할 수 있는 것은 cascade = CascadeType.ALL로 인해 (ALL은 Delete까지 포함된 상태입니다.) 부모인 Shop을 지울때, 자식인 Shop도 같이 지워야하니 Shop 키를 기준으로 Item을 모두 가져와서 1건씩 삭제하는 것이였습니다.
그럼 cascade = CascadeType.ALL이나 cascade = CascadeType.DELETE가 아니면 부모 조회 쿼리가 발생하지 않는지 확인해볼까요?
이렇게 cascade = CascadeType.PERSIST로 변경후에 다시 테스트 코드를 실행해보겠습니다.
삭제 대상인 Shop을 전체 조회하는 1번의 쿼리 이후 Shop Id별로 Item을 조회하는것 없이 바로 Shop을 삭제하는 쿼리로 넘어갑니다.
(에러는 FK를 맺고 있는 Item들로 인해 Shop 삭제가 안되는 것을 보여줍니다.)
자! 그렇다면 결론은 간단합니다.
SpringDataJpa에서 deleteByXXX 등의 메소드 사용시
- 삭제 대상들을 전부 조회하는 쿼리가 1번 발생한다.
- 삭제 대상들은 1건씩 삭제 된다.
- cascade = CascadeType.DELETE으로 하위 엔티티와 관계가 맺어진 경우 하위 엔티티들도 1건씩 삭제가 진행된다.
단순히 코드 작성하기가 편해서 만든 삭제 메소드이지만 성능 저하 요소가 굉장히 많은 것을 알 수 있습니다.
이 문제점을 해결하려면 어떻게 해야할까요?
해결책
이 문제의 해결책은 간단합니다.
직접 범위 조건의 삭제 쿼리를 생성하면 됩니다.
예를 들어 Customer의 경우입니다.
@Transactional
@Modifying
@Query("delete from Customer c where c.id in :ids")
void deleteAllByIdInQuery(@Param("ids") List<Long> ids);
이렇게 직접 범위조건의 삭제 쿼리를 생성하여 사용하는 것입니다.
테스트 코드를 작성합니다.
def "Customer in 삭제-@Query" () {
given:
for(int i=0;i<100;i++){
customerRepository.save(new Customer(i+"님"))
}
when:
customerRepository.deleteAllByIdInQuery(Arrays.asList(1L,2L,3L))
then:
println "======= Then ======="
customerRepository.findAll().size() == 97
}
그리고 이를 실행해보면!
삭제쿼리만 발생하는 것을 확인할 수 있습니다.
만약 Shop과 Item 같이 서로 연관관계가 있는 경우에는 Shop만 삭제시 에러가 발생할 수 있습니다.
그럴땐 Item을 먼저 삭제후, Shop을 삭제하시면 됩니다.
테스트 코드를 실행해보시면!
깔끔하게 delete from item ~~, delete from shop ~~ 2건의 쿼리 수행만 이루어진 것을 확인할 수 있습니다.
(마지막 select는 then 비교를 위해 날린 검증 쿼리입니다.)