JPA Batch Insert 성능 개선기 (GenerationType.IDENTITY의 한계점)

JPA Batch Insert API 성능 개선기 (GenerationType.IDENTITY의 한계점)

 

개요

안녕하세요? 오늘은 성능적으로 문제가 있던 API의 처리속도를 간단한 해결방법을 통하여 96%가량 개선한 사례를 적어보려 합니다.
 
먼저, 문제의 API는 여러 건의 데이터를 받아 데이터베이스에 저장하는 기능을 수행하고 있었는데, 보통의 경우 100건 정도를 일괄적으로 처리하는 API였습니다.
로컬에서 1회 호출해 본 결과 100건 기준으로 약 21989ms정도 걸리는 것을 확인했고, 테스트를 시도한 데이터의 건 수가 많은 것은 아니라고 생각하기에, 이는 성능적으로 문제가 있다고 생각하여 처리 속도를 개선하기로 마음을 먹었습니다.
 
코드를 살펴보니, 다음과 같이 Spring Data JPA의 saveAll() 메서드를 통하여 엔티티를 저장하고 있는 것을 확인하였습니다.

@Component
@Transactional
@RequiredArgsConstructor
public class ExampleWriterImpl implements ExampleWriter {

    private final ExampleRepository exampleRepository;

    /**
     * 일괄 저장
     */
    @Override
    public List<ExampleEntity> createAll(List<ExampleEntity> exampleEntities) {
        return exampleRepository.saveAll(exampleEntities);
    }

}

 
이 코드를 보고, 처음에는 엔티티 리스트를 한 번에 saveAll() 메서드 호출을 통하여 DB에 저장하였기 때문에 1번의 쿼리를 통하여 데이터베이스에 저장되는 줄 알았으나,, 직접 콘솔에 찍힌 쿼리 로그를 확인해봤더니 List로 들어온 데이터의 갯수 만큼 insert 쿼리가 발생하고 있었습니다.
 
쿼리 로그 예시

Hibernate: 
    insert 
    into
        example_table
        (id,field1,field2) 
    values
        (default,?,?)
        
 Hibernate: 
    insert 
    into
        example_table
        (id,field1,field2) 
    values
        (default,?,?)
        
 Hibernate: 
    insert 
    into
        example_table
        (id,field1,field2) 
    values
        (default,?,?)
        
 // ...

 
 
대체 왜 루프를 돌며 save()를 호출한 것도 아니고, saveAll()을 통하여 한 번의 메소드 호출만을 했을 뿐인데 데이터 건 수 별로 단건으로 호출이 되고 있는 것일까요?
 
 

Generated.Identity의 한계점

이유는 바로 Hibernate에서 PK 생성 전략을 Identity로 하였을 경우 Batch Insert 기능을 지원하지 않기 때문입니다.
제가 일하고 있는 회사에서는 DBMS로 MySQL을 사용하고 있었는데요, MySQL에서 IDENTITY 전략은 Auto-Increment로 PK 값을 자동 증분하여 생성합니다.
따라서 Insert를 실제로 실행하기 전 까지는 ID에 할당된 값을 알 수 없으므로, Transaction Write Behind를 할 수 없어 결과적으로 Batch Insert를 실행할 수 없습니다.
 
이로 인하여 ID 생성 전략을 Identity 대신 Sequence나 Table 전략을 사용하는 방법도 있으나, MySQL에서 Sequence 전략은 지원하지 않으며, Table 전략은 별도의 테이블을 생성해야 하기에 권장하지 않는 방법이라고 합니다.
또한 Batch Insert만을 위하여 다른 테이블 들은 전부 Auto-Increment를 통하여 PK를 생성하는데, 해당 테이블만의 예외를 두기도 찜찜하였습니다.
 
분명히 저와 같이(?) 이러한 상황에 놓인 개발자 선배님들이 있을 것이라고 생각하고, 열심히 서치 해본 결과 JDBC Template를 이용하여 해결할 수 있는 방법이 있음을 알게 되었습니다.
 

Batch Insert를 하기 위한 설정

먼저 JDBC Template를 이용하여 Batch Insert를 하기 위해서는 MySQL JDBC에  rewriteBatchedStatements 옵션을 true로 설정해주어야 한다고 합니다. 저는 이 설정 외에도 로컬 환경의 경우에만 몇 가지 추가적인 설정을 yml을 통하여해 주었습니다.
 

spring:
  datasource:
    jdbc-url: jdbc:mysql://{mysql_db_url}:3306/{my_schema}?rewriteBatchedStatements=true&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999

rewriteBatchedStatements=true : Batch Insert를 하기 위한 설정
profileSQL=true : Driver에서 전송하는 쿼리를 출력
logger=Slf4JLogger : Driver에서 쿼리 출력 시 사용할 Logger 설정
maxQuerySizeToLog=999999 : 출력할 쿼리 길이 설정
 
※ JDBC 쿼리 로그를 확인할 것이 아니라면 rewriteBatchedStatements=true 설정만 해주면 되기에, 로컬 환경을 제외하고는 옵션을 부여하지 않았습니다.
 
이후, JDBC Template를 사용할 것이기에 기존에 Bean으로 등록되어 있던 데이터소스를 생성자에 넣어 JDBCTemplate 빈을 등록하는 Config 클래스를 작성하였습니다. (JDBCTemplate 클래스는 기본적으로 Spring Data JPA 의존성이 있다면 Import 할 수 있습니다.)

@Configuration
public class JDBCTemplateConfig {

    @Bean
    public JdbcTemplate jdbcTemplate(DataSource myDataSource) {
        return new JdbcTemplate(myDataSource); // 데이터소스를 생성자에 주입.
    }
    
}

 
 

JDBC Template를 이용하여 Batch Insert 하기

위에서 필요한 설정들은 전부 해주었으니, 이제 적용 코드를 살펴보겠습니다.
 
ExampleJdbcRepository

@Repository
@RequiredArgsConstructor
public class ExampleJdbcRepository {

    private final JdbcTemplate jdbcTemplate;

    @Transactional
    public void saveAll(List<ExampleEntity> exampleEntities) {
        String sql = "INSERT INTO example_table(field1, field2) " +
                     "VALUES (?, ?)"; 			// (1)
                     
        int size = exampleEntities.size(); 		// (2)
        jdbcTemplate.batchUpdate(sql, exampleEntities, size, 	// (3)
                (PreparedStatement ps, ExampleEntity exampleEntity) -> {
                    ps.setString(1, exampleEntity.getField1());
                    ps.setString(2, exampleEntity.getField2());
                }
        );
    }

}
  • (1) - 하나의 데이터를 저장할 때 사용될 쿼리를 정의합니다.
  • (2) - Batch Insert 될 쿼리 개수를 선언합니다. 위 코드의 경우 파라미터로 저장될 엔티티 리스트를 받으므로 리스트의 사이즈로 설정했습니다.
  • (3) - JDBC Template의 batchUpdate() 메서드를 호출하여 설정한 값들을 파라미터로 넘겨주고, PreparedStatement 객체를 통하여 각각의 값들을 바인딩시켜줍니다.

 
ExampleWriterImpl 수정

@Component
@Transactional
@RequiredArgsConstructor
public class ExampleWriterImpl implements ExampleWriter {

    private final ExampleRepository exampleRepository;
    private final ExampleJdbcRepository exampleJdbcRepository;	// 추가

    /**
     * 일괄 저장
     */
    @Override
    public void createAll(List<ExampleEntity> exampleEntities) {
//     	return exampleJdbcRepository.saveAll(exampleEntities);
        exampleRepository.saveAll(exampleEntities); // JDBC Template를 이용하여 일괄 저장하도록 수정
    }

}

Spring Data JPA를 사용하는 ExampleRepository 대신 ExampleJdbcRepository를 사용하도록 수정해 준 모습입니다.
 
 
저와 같이 콘솔에 쿼리 로그가 나오도록 설정해 준 뒤, 테스트코드를 작성해 보면 아래와 같이 기존과 달리 한 번의 쿼리로 데이터베이스에 저장되는 것을 확인할 수 있습니다. (회사 코드이기에, 보안에 문제가 되는 부분은 가렸습니다.)

성능 차이

이제 대망의 성능 차이를 체감해 볼 시간입니다. 처리 속도 측정 방법은 주석 처리해 두었던 코드를 번갈아가며 테스트 코드를 통해 호출하여, 데이터 건 수를 늘려가며 수행 시간 측정을 하였습니다.
 
측정에 사용된 테스트 코드 예시입니다.

@SpringBootTest
@Transactional
@ActiveProfiles("local")
class ExampleWriterImplTest {

    @Autowired
    private ExampleWriterImpl exampleWriterImpl;

    @Test
    @DisplayName("일괄 저장 수행 시간 측정 테스트")
    void batch_insert_test(){
        // given
        List<ExampleEntity> exampleEntities = new ArrayList<>();
        
        int dataSize = 10000;

        for(int i = 0; i < dataSize; i++) {
            exampleEntities.add(new ExampleEntity("Field1", "Field2"));
        }

        // when
        long startTime = System.currentTimeMillis(); // 시작시간
        exampleWriterImpl.createAll(exampleEntities);
        long endTime = System.currentTimeMillis(); // 끝 시간
        long resultTime = endTime - startTime; // 수행 시간 = (끝 시간 - 시작시간)

        // then
        System.out.println("resultTime = " + resultTime); // 출력하여 확인
    }

}

 
 

 1건10건100건1000건10000건
JPA629ms2212ms21989ms154311ms5696002ms
JDBC651ms672ms699ms1209ms2665ms
개선률차이 없음69.62%96.82%99.22%99.95%
JPA / JDBC 비율차이 없음3.2 배 차이약 31배 차이 약 127배 차이약 2,137배 차이

 
기능의 특성상 가장 많이 사용될 데이터 100건 기준으로는 처리 속도가 96.82% 개선되었으며, 기존의 JPA의 saveAll()보다 31배나 퍼포먼스가 증가했음을 확인할 수 있었고, 데이터가 많아질수록 기하급수적인 퍼포먼스 차이를 확인할 수 있었습니다!
 
 

마치며..

단순히 JPA 대신 JDBC Template를 사용하는 선택을 했을 뿐인데, 기존과 비교하여 드라마틱한 성능 개선을 할 수 있었습니다. 역시 모든 기술은 Trade-Off인 것 같습니다.
 
여러 가지 장점과 트렌드로 인하여 자바&스프링 개발자라면 JPA를 습관처럼 사용하고, 좋아하시는 개발자 분들이 많은데, 때로는 이러한 사례처럼 이 글을 읽는 여러분들도 기술의 콘셉트와 한계점을 명확하게 인지하고 상황에 따라 다른 기술의 선택지도 늘 염두하며 개발하시면 좋을 것 같습니다.
 

Reference

[JPA] Spring JPA 환경에서 bulk insert를 효율적으로 해보자 - JPA의 한계와 JDBC 활용
JPA ID전략이 IDENTITY인 상태에서 Bulk Insert 수행하기
Batch Insert/Update with Hibernate/JPA
MySQL 환경의 스프링부트에 하이버네이트 배치 설정 해보기
[우아콘2020] 수십억건에서 QUERYDSL 사용하기