본문 바로가기

프로젝트/디베이트 타이머

deleteAll vs deleteAllInBatch : 성능차이 + 영속성 컨텍스트 반영여부

SimpleJpaRepository에 구현된 deleteAll을 보면 내부적으로 엔티티를 하나씩 순차적으로 돌면서 delete() 메서드를 호출하고 있습니다. 이말은 즉슨 엔티티의 개수만큼 delete 쿼리가 나가게된다는 것을 의미합니다.

문제 상황 : deleteAllInBatch를 사용한 메서드 테스트 오류

 

디베이트 타이머 1차 스프린트의 주요 내용은 디베이트 시간표와 시간표를 구성하는 타임박스들의 CRUD를 구현하는 것이었습니다. 그중 의회식 토론 테이블의 삭제 로직을 구현하는 과정에서 더 나은 성능을 위해 deleteInBatch 메서드를 사용했습니다.

 

    @Transactional
    public void deleteTable(Long tableId, Member member) {
        ParliamentaryTable table = getOwnerTable(tableId, member.getId());
        ParliamentaryTimeBoxes timeBoxes = timeBoxRepository.findTableTimeBoxes(table);
        timeBoxRepository.deleteAllInBatch(timeBoxes.getTimeBoxes());
        entityManager.clear();
        tableRepository.delete(table);
    }

 

그러나, 테스트를 작성하던 중 hibernate의 InvalidDataAccessApiUsageException이 발생하는 모습을 볼 수 있었습니다.

org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.TransientObjectException: persistent instance references an unsaved transient instance of 'com.debatetimer.domain.parliamentary.ParliamentaryTable' (save the transient instance before flushing)

 

내용인 즉슨 table entity의 삭제를 시도했으나 아직  table을 참조하고 있는 저장되지 않은 인스턴스가 있다는 것이었습니다.

왜 이런 현상이 발생한 것인지 deleteAll과 deleteAllInBatch의 차이점에 대해 알아보도록 하겠습니다.

 


 

내부구현을 살펴보자 : deleteAll vs deleteAllInBatch

 

1) 성능적 관점 : 왜 deleteInBatch가 더 빠를까?

 

deleteAll : 엔티티를 순차적으로 delete 쿼리를 날려 삭제

 

SimpleJpaRepository에 구현된 deleteAll을 보면 내부적으로 엔티티를 하나씩 순차적으로 돌면서 delete() 메서드를 호출하고 있습니다. 이말은 즉슨 엔티티의 개수만큼 delete 쿼리가 나가게된다는 것을 의미합니다.


deleteAllInBatch : 한번의 쿼리로 in절에 넣어 삭제

 

다음으로 deleteAllInBatch는 QueryUtils를 통해 쿼리문을 만드는 것처럼 보이는데요. delete from (엔티티 이름), + 추가적을 통해 쿼리를 구성하고 있습니다. 즉 entity 객체들의 정보를 기반으로 하나의 쿼리문을 만들어 executeUpdate라는 하나의 쿼리를 날리는 것입니다.


테스트를 통해 알아보기

그럼 테스트를 통해 알아보겠습니다.

 

간단한 브로콜리 엔티티를 하나 만들어주고, 더미데이터 100만건을 h2 DB에 넣어주었습니다.

@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Broccoli {

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

    private String color;
}

 

 

 

 

이제 deleteAll과 deleteAllInBatch를 사용하는 서비스 코드를 만들고, 얼마나 걸리는지를 테스트로 측정해보겠습니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BroccoliServiceTest {

    @Autowired
    private BroccoliService broccoliService;

    @Test
    void deleteAll() {
        long start = System.currentTimeMillis();
        broccoliService.deleteAll();
        long end = System.currentTimeMillis();
        long elapseTime = end - start;
        System.out.println("deleteAll-ElapseTime = " + elapseTime);
    }

    @Test
    void deleteAllInBatch() {
        long start = System.currentTimeMillis();
        broccoliService.deleteAllInBatch();
        long end = System.currentTimeMillis();
        long elapseTime = end - start;
        System.out.println("deleteAllInBatch-ElapseTime = " + elapseTime);
    }
}

 

 

deleteAll 실행시간 : 2430ms

 

내부 구현처럼 100만 건의 entity에 대해 개별 delete 쿼리가 나간 모습을 볼 수 있었습니다.

실행 시간도 2.4초 가량 걸린 모습입니다.

 

 

deleteAllInBatch 실행시간 : 219ms

 

그에 반해 배치 쿼리는 단 하나의 쿼리만 나간 것을 볼 수 있었습니다.

실행 시간도  0.2초로 약 11배 가량 차이가 났습니다.

 


 

2) 영속성 컨텍스트 반영 여부 : DB에 쿼리를 바로 쏘는가? or 영속성 컨텍스트를 거치는가?

제가 겪었던 에러의 해답은 deleteAllInBatch를 서술한 spring 공식문서에서 발견할 수 있었습니다.

Deletes the given entities in a batch which means it will create a single query. This kind of operation leaves JPAs first level cache and the database out of sync. Consider flushing the EntityManager before calling this method.It will also NOT honor cascade semantics of JPA, nor will it emit JPA lifecycle events.

 

batch 쿼리는 데이터베이스에 쿼리를 바로 쏘기때문에 영속성 컨텍스트의 1차 캐시와 동기화되지 않는 주의점이 명시되어 있었습니다. 따라서 사용 이전에 flush를 권장하고 있었습니다.

 

이로써 제가 겪었던 문제의 원인을 특정할 수 있었습니다.

1) 토론 타임박스들은 토론 시간표들과 ManyToOne을 다대일 연관관계가 있다.

2) 자식객체인 타임박스들을 배치쿼리로 삭제하면 DB상에서는 삭제된다.

3) 그러나, 영속성 컨텍스트 내에는 토론 시간표들을 참조하는 토론 타임박스 객체들이 저장되어 있다.

4) 토론 테이블을 삭제하려 시도한다.

5) 영속성 컨텍스트는 아직 테이블 id를 참조하는 자식객체인 타임박스들이 존재한다고 판단한다.

6) 에러가 발생한다.


테스트로 확인하기

이를 간단한 예시로 만들어 보면 다음과 같습니다. 서로 다대일 연관관계가 있는 Company와 Member 엔티티를 만들어주겠습니다.

@Entity
@Getter
@NoArgsConstructor
public class Company {

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


@Entity
@NoArgsConstructor
public class Member {

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

    @JoinColumn(name = "company_id")
    @ManyToOne
    private Company company;

    public Member(Company company) {
        this.company = company;
    }
}

 

 

DB에 Company와 그 Company에 속한 Member 1명씩 저장되어 있다고 가정해보겠습니다.

이제 Company 를 삭제하는 상황을 보겠습니다. Company를 삭제하기 위해선 회사에 속하는 Member 객체들을 먼저 삭제해주어야 합니다. 그렇지 않을 경우 참조키 오류가 발생하기 떄문입니다. 그럼 Member 객체들을 deleteAllInBatch로 삭제해보겠습니다.

    @Transactional
    public void deleteCompany(long companyId) {
        List<Member> companyMember = memberRepository.findAllByCompanyId(companyId);
        memberRepository.deleteAllInBatch(companyMember);
        companyRepository.deleteById(companyId);
    }

 

삭제를 위해선 삭제의 대상이 되는 company에 속한 회원들을 findAllByCompanyId로 모두 조회해와야 합니다.

 

그리고 이 과정에서 영속성 컨텍스트에는 member가 1차 캐시에 저장되게 됩니다.

 

이후 member들을 deleteAllInBatch로 삭제하게되면 삭제쿼리가 DB로 바로 날아가게 되어 영속성 컨텍스트와 다른 형태를 띄게 됩니다.

 

이 상황에서 Company 삭제를 시도하게 되면 영속성 컨텍스트는 아직 1차 캐시에 삭제를 시도하는 Company 엔티티를 참조하는 Member가 있다고 판단하여 에러가 발생됩니다.


해결방안은? : 호출전 flush || 호출 후 clear

 

그럼 해당 문제를 어떻게 해결할 수 있을까요? 방법은 간단합니다.

deleteAllInBatch를 호출하기 전 flush를 호출하거나, 호출한 후 clear를 통해 영속성 콘텍스트를 비워주면됩니다.

 

테스트를 통해 판단하기 위해 clear를 호출하지 않은 deleteCompanyWithoutClear와 deleteCompanyWithClear 메서드를 구현해주었습니다.

@Service
@RequiredArgsConstructor
public class CompanyService {

    private final CompanyRepository companyRepository;
    private final MemberRepository memberRepository;
    private final EntityManager entityManager;

    @Transactional
    public void deleteCompanyWithoutClear(long companyId) {
        List<Member> companyMember = memberRepository.findAllByCompanyId(companyId);
        memberRepository.deleteAllInBatch(companyMember);
        companyRepository.deleteById(companyId);
    }

    @Transactional
    public void deleteCompanyWithClear(long companyId) {
        List<Member> companyMember = memberRepository.findAllByCompanyId(companyId);
        memberRepository.deleteAllInBatch(companyMember);
        entityManager.clear();
        companyRepository.deleteById(companyId);
    }
}

 

그리고 해당 메서드의 실행 테스트를 작성해보았습니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CompanyServiceTest {

    @Autowired
    private CompanyService companyService;

    @Autowired
    private CompanyRepository companyRepository;

    @Autowired
    private MemberRepository memberRepository;

    @DisplayName("batch 쿼리를 동기화하지 않으면 에러가 발생한다.")
    @Test
    void deleteCompanyWithoutClear() {
        Company company = companyRepository.save(new Company());
        Member member = memberRepository.save(new Member(company));

        assertThatThrownBy(() -> companyService.deleteCompanyWithoutClear(company.getId()))
                .isInstanceOf(InvalidDataAccessApiUsageException.class);
    }

    @DisplayName("batch 쿼리 후, clear로 영속성 컨텍스트를 비워주면 에러가 발생하지 않는다.")
    @Test
    void deleteCompanyWithClear() {
        Company company = companyRepository.save(new Company());
        Member member = memberRepository.save(new Member(company));

        assertThatCode(() -> companyService.deleteCompanyWithClear(company.getId()))
                .doesNotThrowAnyException();
    }
}

 

예상과 같은 테스트 결과가 나온 것을 확인할 수 있었습니다.

 


요약

1. deleteAll은 순차적으로 엔티티 삭제쿼리를 날려 N+1 문제가 발생한다.

2. deleteAllInBatch는 한번의 쿼리로 DB에 직접 쿼리를 날린다.

3. batch 쿼리는 영속성 콘텍스트의 동기화를 보장하지 않기에 호출전 flush 혹은 호출 후 clear로 동기화를 신경써야 한다.

 

 

작성한 테스트 코드는 해당 레포지토리에서 확인하고, 돌려보실 수 있습니다.

https://github.com/coli-geonwoo/blog/tree/feature/delete-in-batch

 

GitHub - coli-geonwoo/blog: 블로그 헤이,브로의 실습 코드를 모아놓은 저장소입니다.

블로그 헤이,브로의 실습 코드를 모아놓은 저장소입니다. Contribute to coli-geonwoo/blog development by creating an account on GitHub.

github.com