본문 바로가기
프로그래밍/Spring

[JPA] JPA의 AUTO_INCREMENT 테이블에서 다건 insert 시간 비교 - save vs saveAll

by 사바라다 2021. 9. 24.

안녕하세요. 오늘은 JPA에서 auto_increment 테이블에 bulk insert를 지원하지 않는 이유에 대해서 알아보도록 하겠습니다. 그리고 JPA를 이용해서 다량의 데이터를 넣어보고 각 insert가 완료되기 까지의 시간을 확인해보는 포스팅을 진행하도록 하겠습니다. 또한 왜 이런 상대적인 결과가 나왔는지도 알아보도록 하겠습니다.

JPA와 bulk insert, 그리고 IDENTIFY

일반적으로 RDBMS에서는 bulk insert라고 하여 한번의 쿼리로 여러건의 데이터를 insert 할 수 있는 기능을 제공해주고 있습니다. 이런 bulk insert 쿼리를 이용하면 한번의 쿼리로 여러건의 데이터를 한번에 insert 할 수 있기 때문에 데이터베이스와 어플리케이션 사이의 통신에 들어가는 비용을 줄여주어 성능상 이득을 얻을 수 있습니다. 예를 들어 아래와 같은 쿼리가 bulk insert 입니다.

insert into user (name, age)
values ('ykh', 25),
       ('karol', 56),
       ('sabarada', 68);

JPA에서도 이런 bulk insert를 기본적으로 지원하고 있습니다. 하지만 만약 당신이 bulk insert를 원하는 테이블에서 auto_increment를 사용하고 있다면 아쉽지만 bulk insert는 JPA를 통해서는 해결할 수 없습니다. 이는 hibernate 문서에 아래와 같이 표기되어 있는 부분이기도합니다.

Hibernate disables insert batching at the JDBC level transparently if you use an identity identifier generator.

그 이유는 stackoverflow에 hiberate의 대표적인 committer 중 한명인 Vlad Mihalcea가 설명한 답변을 보시면 이해하실 수 있습니다.

요약하자면 아래와 같습니다.

  • 하이버네이트는 트랜잭션 마지막에 flush 하여 DB에 insert하는 write-behind 전략을 취함
  • auto_increment는 DB에 insert 되면서 ID가 채번
  • bulk insert를 하기 위해서는 여러 트랜잭션이 들어오는 것을 대비해 ID 값을 먼저 알고 있어야하나 그렇게 하는 것은 불가능

이와 같은 이유로 앞으로도 IDENTIFY 타입(auto_increment)으로 ID가 선언되는 테이블에 대해서는 bulk insert 지원은 없을 것으로 보입니다. 대안으로 보자면 JDBC를 쓸 수 있을것입니다. 만약 kotlin이라면 ORM 프레임워크를 exposed로 바꾸는것도 방법일 수 있습니다. 하지만 프레임워크를 추가하거나 바꾸지 못 하고 JPA를 통해 해결해야할 수 있습니다. 이럴 경우 어쩔 수 없이 다수의 insert 쿼리를 통해 할 수 밖에 없는데요. 그래도 어떤 방법이 그나마 빠르게 이를 수행할 수 있을지 한번 경우의 수에 따른 비교를 해보도록 하겠습니다.

repository save 밖에서 for을 통해 insert

가장 단순하게 생각할 수 있는 방법은 for 문을 통해서 insert를 진행하는 것입니다. 아래의 코드처럼 말이죠. 아래의 코드는 for 문을 1000번 톨면서 insert 하는 쿼리입니다. 그리고 이에 수행되는 시간을 확인해보았습니다.

@Test
void bulkService3() {
    long startTime = System.currentTimeMillis();

    for (int i = 0; i < 1000; i++) {
        bulkService.bulkService();
    }

    System.out.println("elapsed = " + (System.currentTimeMillis() - startTime) + "ms"); // 7531ms
}

 

public void bulkService() {
    CreateReviewReq createReviewReq = new CreateReviewReq(
            "hello + ",
            new ReviewScore(1, 2, 3, 4, 5, 6)
    );

    Review review = reviewMapper.buildReview(1, 1, createReviewReq);
    reviewRepositoryV1.save(review);
}

이 경우 7531ms라는 시간을 보여주었습니다. bulkService에 @Transactional을 붙여주거나 saveAndflush를 하더라도 거의 동일한 시간대의 결과를 보여주었습니다. 이렇게 오래 걸리는 이유는 save의 구현을 보시면 이해하실 수 있습니다. 아래의 코드는 JPA save의 구현 코드입니다. 내부 코드에 @Transactional이 들어가 있는 것을 확인하실 수 있습니다. 즉, save를 할 때 마다 트랜잭션을 잡는 행위를 하기 때문 이러한 경과시간을 보여주었다는 것을 확인할 수 있었습니다.

@Transactional
@Override
public <S extends T> S save(S entity) {

    if (entityInformation.isNew(entity)) {
        em.persist(entity);
        return entity;
    } else {
        return em.merge(entity);
    }
}

@Transactional 안에서 돌리기

위의 경우에서 save 내부에 @Transactional을 사용하고 있고 save 할때마다 트랜잭션을 잡아주니 저렇게 오래걸리는 것으로 확인을 할 수 있었습니다. 그렇다면 여기서 for 문을 도는 부분을 @Tranasactional로 묶어서 트랜잭션 전파(propagation)을 통해 save 한다면 save시 마다 트랜잭션을 새로 열지 않을것이며 성능개선을 할 수 있을 것으로 판단하였습니다. 그것이 아래코드입니다.

@Test
void bulkService7() {
    long startTime = System.currentTimeMillis();

    bulkService.bulkService3();

    System.out.println("elapsed = " + (System.currentTimeMillis() - startTime) + "ms"); // 4255ms
}

 

@Transactional
public void bulkService3() {
    for (int i = 0; i < 1000; i++) {
        CreateReviewReq createReviewReq = new CreateReviewReq(
                "hello + ",
                new ReviewScore(1, 2, 3, 4, 5, 6)
        );

        Review review = reviewMapper.buildReview(1, 1, createReviewReq);
        reviewRepositoryV1.save(review);
    }
}

결과적으로 4255ms 정도가 걸렸으며 50% 정도의 성능향상을 확인할 수 있었습니다.

@Transaction 안에서 saveAll

마지막으로 saveAll을 사용했을 때 코드를 보도록 하겠습니다. 아래 코드가 saveAll을 이용하여 전체 list를 한번의 saveAll 호출로 저장하는 방법입니다.

@Test
void bulkService8() {
    long startTime = System.currentTimeMillis();

    bulkService.bulkService4();

    System.out.println("elapsed = " + (System.currentTimeMillis() - startTime) + "ms"); // 2850ms
}

 

public void bulkService4() {

    List<CreateReviewReq> lists = new ArrayList<>();
    for (int i = 0; i < 1000; i++) {
        CreateReviewReq createReviewReq = new CreateReviewReq(
                "hello + ",
                new ReviewScore(1, 2, 3, 4, 5, 6)
        );
        lists.add(createReviewReq);
    }


    reviewRepositoryV1.saveAll(
            reviewReq.stream()
                    .map(req -> reviewMapper.buildReview(userId, gameId, req))
                    .collect(Collectors.toList())
    )
}

결과적으로 2850ms의 시간이 걸렸습니다. 이는 처음 대비 70% 정도의 성능 향상을 확인할 수 있었습니다. 2번째 방법에 비해서도 많은 성능 향상을 확인할 수 있었는데요. 그 이유는 @Transactional은 AOP 코드 라는 점에 있습니다. 2번째 방법은 외부에서 호출하기 때문에 @Transactional의 코드가 내부적으로 실행되어집니다. 하지만 saveAll을 사용하면 @Transactional 코드를 한번만 딱 실행시킬 수 있습니다. 아래는 aveAll코드입니다. 보시면 클래스 내부에서 save를 호출하는 것을 알 수있습니다. 때문에 @Transactional에 대한 코드가 전혀 실행되지 않기 때문에 이정도의 성능을 향상 시킬 수 있었던 것입니다.

@Transactional
@Override
public <S extends T> List<S> saveAll(Iterable<S> entities) {

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

    List<S> result = new ArrayList<S>();

    for (S entity : entities) {
        result.add(save(entity));
    }

    return result;
}

마무리

오늘은 이렇게 JPA에서 auto_increment 테이블에서 bulk insert가 되지 않는 사실과 함께 다건 save에서 시간을 줄이는 방법에 대해서 알아보았습니다.

감사합니다.

참조

stackoverflow_why-does-hibernate-disable-insert-batching-when-using-an-identity-identifier-gen/27732138#27732138

vladmihalcea_why-you-should-never-use-the-table-identifier-generator-with-jpa-and-hibernate

baeldung_spring-data-save-saveall

댓글