서론

진행 중인 토이 프로젝트에서 약 2만 건 이상의 데이터를 한 번에 RDB에 저장하기 위해 JPA의 saveAll() 메서드를 사용했습니다.
처음에는 saveAll()이 INSERT 쿼리를 모아서 한 번에 전송할 것이라 기대했으나, 실제로 쿼리를 확인해보니 save()와 마찬가지로 각각의 INSERT 쿼리가 하나씩 전송되는 것을 확인했습니다.

그렇다면 save()와 saveAll()의 차이는 무엇일까요? 그리고 INSERT를 한 번에 처리하는 방법은 무엇인지 알아보겠습니다.

save()와 saveAll의 차이

먼저 save와 saveAll의 차이를 보겠습니다.

SAVE

  @Transactional
  public <S extends T> S save(S entity) {
        Assert.notNull(entity, "Entity must not be null");
        if (this.entityInformation.isNew(entity)) {
            this.entityManager.persist(entity);
            return entity;
        } else {
            return (S)this.entityManager.merge(entity);
        }
    }

먼저 자주 사용하는 save()를 살펴보면 하나의 트랜잭션에서 관리가 되고 있으며, 매개변수로 주어진 entnty가 새 entity의 경우 persist()를 통해 DB에 저장하며 기존에 존재하는 entity의 경우 merge()를 통해 변경된 점을 DB에 병합합니다.

SAVE ALL

   @Transactional
   public <S extends T> List<S> saveAll(Iterable<S> entities) {
        Assert.notNull(entities, "Entities must not be null");
        List<S> result = new ArrayList();

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

        return result;
    }

saveAll() 메서드를 살펴보면, 이 메서드 역시 하나의 트랜잭션에서 관리됩니다. 내부적으로는 for 문을 통해 save() 메서드를 반복 호출하게 됩니다. 그렇다면 save()와의 차이점은 무엇일까요?

가장 큰 차이는 트랜잭션 관리입니다. saveAll()에서는 트랜잭션이 메서드 레벨에 위치하여 작업 수행 동안 동일한 트랜잭션 내에서 처리됩니다. 따라서 for 문을 통해 save()를 직접 반복 호출하는 것보다 더 효율적일 수 있습니다. 즉, saveAll()을 사용할 경우 트랜잭션이 한 번만 생성됩니다.

성능

save()와 saveAll()의 각 성능차이를 알아보겠습니다.

@SpringBootTest
public class SaveTest {

    @Autowired
    TestEntityRepository testEntityRepository;

    @Test
    @Transactional
    @Rollback(value = false)
    @DisplayName("save 테스트")
    void saveTest() {
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            TestEntity testEntity = TestEntity.builder().name("테스트" + i)
                .build();
            testEntityRepository.save(testEntity);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("execution time : " + (endTime - startTime) + "ms"); // 4268ms
    }

    @Test
    @Transactional
    @Rollback(value = false)
    @DisplayName("save all 테스트")
    void saveAllTest() {
        long startTime = System.currentTimeMillis();
        List<TestEntity> testlist = new ArrayList<>();

        for (int i = 0; i < 10000; i++) {
            TestEntity testEntity = TestEntity.builder().name("테스트" + i)
                .build();
            testlist.add(testEntity);
        }
        testEntityRepository.saveAll(testlist);
        long endTime = System.currentTimeMillis();
        System.out.println("execution time : " + (endTime - startTime) + "ms"); // 3664ms
    }
}

각각 1만 건의 TestEntity를 생성하는 반복문을 실행하면서, 생성된 엔티티를 저장하는 두 가지 방법을 비교했습니다. 첫 번째 방법은 save() 메서드를 사용하여 개별적으로 저장하는 것이고, 두 번째 방법은 List 타입의 testList에 엔티티를 담아 saveAll() 메서드를 통해 한 번에 저장하는 것입니다.

실행 결과는 다음과 같습니다:

  • save() 메서드: 4268ms
  • saveAll() 메서드: 3664ms

결과적으로, saveAll() 메서드가 더 나은 성능을 보이는 것으로 확인되었습니다.
하지만 이 결과는 트랜잭션 관리의 차이에서 나오는것이지 결국 쿼리가 하나씩 나가는것은 동일했습니다. 그렇다면 JPA에서는 어떻게 대용량 데이터를 한번에 insert하는지 알아 보겠습니다.

JPA에서의 BulkInsert

  • 먼저 BulkInsert란 insert 쿼리를 한번에 처리하는 것을 의미합니다.
    - MySQL에서는 이러한 insert 합치기를 통해 비약적으로 성능을 향상 시킵니다.
    INSERT INTO post (title) VALUES
    ('title1'),
    ('title2'),
    ('title3'),
    ('title4');

    JPA에서의 Bulk Insert

  • Bulke Insert를 사용하려면 application.yml에 정의한 JdbcUrl 옵션에 rewirteBatchedStatements=true를 필수로 설정해야 합니다.

MySQL8버전 미만
jdbc:mysql://localhost:3306/{db}rewriteBatchedStatements=true

MySQL8 이상부터는 rewriteBatchedStatements=true 옵션의 default = treu로 설정되어 별도의 설정이 없어도 Bulk Insert이 적용됩니다.

하지만 id 채번이 DB에 의해서 정해지는 auto_increment(IDENTITY)인 경우 JPA를 통한 save는 Bulk Insert가 적용되지 않습니다.

그 이유는 아래와 같습니다.

  • 하이버네이트는 트랜잭션 마지막에 flush 하여 DB에 insert하는 write-behind 방식을 사용
    (트랜잭션을 지원하는 쓰기 지연: 트랜잭션이 커밋 될 떄까지 내부 쿼리 저장소에 모아뒀다가 한 번에 실행)
  • auto_increment(IDENTITY)는 DB에 insert되면서 ID가 채번
  • Bulk Insert를 위해서는 여러 트랜잭션이 들어오는 것을 대비해 ID 값을 먼저 알고 있어야하나 그렇게 하는것이 불가능
    하이버네이트

이러한 상황에서의 대처 방법들은 ID 채번 전략 수정 또는 JPA가 아닌 JdbcTemplate와 같은 네이티브 쿼리를 작성하는 방법, QueryDSL, JOOQ 등이 있습니다.

  • 테이블 전략을 수정하는 방법은 테이블 설계 단계에서부터 정한것이 아니라면 적용이 어려움.(이미 DB에 데이터가 자리잡은 상황에서 id 채번을 변경하는것은 부담)
  • JdbcTemplate(+ Mybatis)와 같이 문자열 기반의 SQL 프레임워크는 IDE 자동 지원이 제한적.
  • 테이블 컬럼에 추가/수정 발생시 연관된 쿼리 문자열을 모두 찾아서 반영 필요.
  • QueryDSL, JOOQ의 경우 아직 다뤄본 적이 없어 제외했습니다.

구현

저는 jdbcTemplate를 이용해 Bulk Insert를 구현하기로 했습니다.

  datasource:
    url: jdbc:mysql://localhost:3306/{db}?profile=true&logger=Slf4JLogger&maxQuerySizeToLog=1000
  • profile=true : SQL쿼리의 프로파일링 활성화로 실행된 쿼리에 대한 성능 통계를 수집해 로그로 남깁니다.
  • Logger=Slf4JLogger : DB 로그를 출력하는 로거를 설정합니다.
  • maxQuerySizeToLog : 로그로 남길 SQL쿼리의 최대 크기를 지정합니다.
@Repository
@RequiredArgsConstructor
public class TestEntityJdbcRepository {

    private final JdbcTemplate jdbcTemplate;

    public void bulkInsert(List<TestEntity> entities) {

        jdbcTemplate.batchUpdate("INSERT INTO test_entity(name) VALUES (?)",
            new BatchPreparedStatementSetter() {
                @Override
                public void setValues(PreparedStatement ps, int i) throws SQLException {
                    ps.setString(1, entities.get(i).getName());
                }

                @Override
                public int getBatchSize() {
                    return entities.size();
                }

            });
    }
}
  • jdbcTemplate의 batchUpdate를 이용해 실행할 네이티브 쿼리를 작성합니다.
  • BatchPreparedStatementSetter의 setValues, getBatchSzie를 구현
    - setValues() : entity 속성 값을 PreparedStatement에 설정하며, i는 리스트의 현재 처리중인 entity의 인덱스.
    • getBatchSize() : 현재 배치의 크기를 반환하며 전달받은 entity의 리스트의 크키를 반환해 얼마나 많은 객체를 삽입할 것인지 알려줌.
  @Test
    @Transactional
    @Rollback(value = false)
    @DisplayName("bulk insert 테스트")
    void bulkInsertTest() {
        long startTime = System.currentTimeMillis();
        List<TestEntity> testlist = new ArrayList<>();

        for (int i = 0; i < 10000; i++) {
            TestEntity testEntity = TestEntity.builder().name("테스트" + i)
                .build();
            testlist.add(testEntity);
        }
        jdbcTestEntityRepository.bulkInsert(testlist);
        long endTime = System.currentTimeMillis();
        System.out.println("execution time : " + (endTime - startTime) + "ms"); //93ms
    }
}

결과

10,000개의 데이터를 넣는 테스트 환경에서의 결과 입니다.

  • save() : 4268ms
  • saveAll() : 3664ms
  • jdbc batchUpdate : 93ms

save()와 batchUpdate()사이에 약 46배의 성능차이가 발생하는것이 확인되었습니다.

정리

  • auto_increment(IDENTITY) 환경에서는 JpaRepository를 사용해 Bulk Insert를 수행할 수 없습니다.
  • JdbcTemplate를 이용해 Bulk Insert를 구현해야 합니다.

참고

https://velog.io/@ogu1208/Spring-JPA-JPA-%EC%97%90%EC%84%9C-ID-%EC%83%9D%EC%84%B1-%EC%A0%84%EB%9E%B5%EC%9D%B4-IDENTITY%EC%9D%BC-%EB%95%8C-Bulk-Insert-%EB%A5%BC-%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95#jdbctemplate---batchupdate-2

https://imksh.com/113

https://jojoldu.tistory.com/558

profile
정신차려 이 각박한 세상속에서!!!

0개의 댓글

관련 채용 정보

Powered by GraphCDN, the GraphQL CDN