Spring JDBC를 사용하여 Batch Insert 수행하기

2024. 10. 6. 22:12Backend/Springboot

** 초보 개발자로 글에 수정해야 할 부분이 있을 수 있습니다. 정정해야 할 부분은 댓글로 소통 부탁드립니다!

 

평소에 repository.save()로 단건 저장하던 경우 외에 여러건을 저장해야 하는 케이스가 발생하여 이러한 경우는 어떻게 처리하는지 궁금증이 생겼습니다. 따라서 이번 글에서는 Spring 환경에서 다량의 데이터를 효율적으로 삽입하는 방법인 Batch Insert에 대해 알아보려고 합니다. 

 

목차는 다음과 같습니다.

1. Batch Insert란?

2. Identity 전략으로는 Batch Insert가 불가능한 이유

3. JdbcTemplate를 사용하여 Batch Insert 적용하기

 

 

1. Batch Insert 란?

Batch Insert는 많은 양의 데이터를 한 번에 삽입하는 방법입니다. 아래의 일반적인 Insert SQL과 비교해 보면 이해하기 쉽습니다.

INSERT INTO table (col1, col2) VALUES (val1, val11);
INSERT INTO table (col1, col2) VALUES (val2, val22);
INSERT INTO table (col1, col2) VALUES (val3, val33);

 

위 쿼리는 개별 Insert입니다.

INSERT INTO table (col1, col2) VALUES
(val1, val11),
(val2, val22),
(val3, val33);

 

위 쿼리는 Batch Insert입니다.

보통 쿼리를 실행하고 응답을 받은 후에야 다음 쿼리를 전달하기 때문에 개별 Insert의 경우 지연 시간이 늘어나지만, 하나의 트랜잭션으로 묶이는 Batch Insert는 하나의 쿼리문으로 여러 데이터를 처리하기 때문에 성능이 뛰어납니다.

 

2. Identity 전략으로는 Batch Insert가 불가능한 이유

@Entity
public class Example {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
}

 

JPA와 MySQL을 함께 사용할 때, 위와 같이 IDENTITY 전략을 사용하여 auto_increment를 통해 PK 값을 자동으로 증가시키는 방식을 일반적으로 사용하며. 이때 ID는 @GeneratedValue(strategy = GenerationType.IDENTITY)로 설정하고 아래와 같이 save() 메서드를 사용하여 저장할 수 있습니다.

Product product = new Product(title, price);
productRepository.save(product);

 

이 방식은 Spring Data JPA에서 제공하는 JpaRepository.save(T) 메서드의 내부 동작 방식으로, ID값을 명시하지 않아도 자동으로 저장됩니다.

 

그러나 이 방식을 사용하면 Hibernate는 JDBC 수준에서 Batch Insert를 비활성화합니다(참고). 이유는 새로 할당할 Key 값을 미리 알 수 없는 IDENTITY 전략을 사용할 경우, Hibernate가 채택한 flush 방식인 'Transactional Write Behind'와 충돌이 발생하기 때문입니다. 따라서 IDENTITY 전략을 사용하면 Batch Insert는 동작하지 않습니다.

이를 구체적인 예로 설명하면, OneToMany의 Entity를 insert할 경우 Hibernate는 아래 과정을 진행하며 이 과정의 쿼리를 모아서 실행합니다.

  1. 부모 Entity를 insert하고 생성된 Id를 반환
  2. 자식 Entity에서는 이전에 생성된 부모 Id를 FK 값으로 채워서 insert

하지만 Batch Insert와 같은 대량 등록의 경우, 이 방식을 사용할 수 없는데 부모 Entity를 한 번에 대량으로 등록하게 되면 어느 자식 Entity가 어느 부모 Entity에 매핑되어야 하는지 알 수 없습니다. 따라서 IDENTITY 전략을 사용하면 Batch Insert는 동작하지 않습니다.

물론 Auto Increment가 아닐 경엔 아래와 같은 옵션을 통해 values 사이즈를 조절하여 Batch Insert를 사용할 수 있습니다.

spring.jpa.properties.hibernate.jdbc.batch_size=개수

 

 

3. JdbcTemplate를 사용하여 Batch Insert 적용하기

테이블 전략을 변경하는 방법이 있지만, 이는 테이블 변경이 필요하고 이미 진행 중인 프로젝트에 적용하기 어렵습니다. 그래서 대안으로 JdbcTemplate를 사용하여 Batch Insert를 적용하는 방안을 설명하겠습니다.

JdbcTemplate에는 Batch를 지원하는 batchUpdate() 메서드가 있습니다. 먼저 MySQL에서 Bulk Insert를 사용하려면, DB-URL에 'rewriteBatchedStatements=true' 파라미터를 추가해야 합니다.

 

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/batch_test?&rewriteBatchedStatements=true
    username: root
    password: 1234
    driver-class-name: com.mysql.cj.jdbc.Driver

 

'rewriteBatchedStatements'를 true로 설정하지 않으면 Insert 쿼리가 여전히 단건으로 수행됩니다.

Batch Insert가 제대로 진행되는지 확인하려면 다음과 같은 추가 옵션을 설정할 수 있습니다.

 

spring:
    datasource:
        url: jdbc:mysql://localhost:3306/db명?rewriteBatchedStatements=true&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999

 

각 파라미터의 설명은 다음과 같습니다.

  • postfileSQL = true : Driver에 전송하는 쿼리를 출력합니다.
  • logger=Slf4JLogger : Driver에서 쿼리 출력 시 사용할 로거를 설정합니다.
    • MySQL 드라이버 : 기본값은 System.err로 출력하도록 설정되어 있기 때문에 필수로 지정해 줘야 합니다.
    • MariaDB 드라이버 : Slf4j 를 이용하여 로그를 출력하기 때문에 설정할 필요가 없습니다.
  • maxQuerySizeToLog=999999 : 출력할 쿼리 길이
    • MySQL 드라이버 : 기본값이 0으로 지정되어 있어 값을 설정하지 않을 경우 쿼리가 출력되지 않습니다.
    • MariaDB 드라이버 : 기본값이 1024로 지정되어 있습니다. MySQL 드라이버와는 달리 0으로 지정 시 쿼리의 글자 제한이 무제한으로 설정됩니다.

다음은 Entity와 Batch Insert를 정의한 Repository 코드입니다. Batch Insert 관련 코드는 공식문서를 참고하여 작성하였습니다.

@Entity(name = "product")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private Long price;
 
    public Product(String title, Long price) {
        this.title = title;
        this.price = price;
    }
 
}

 

@Repository
@RequiredArgsConstructor
public class ProductBulkRepository {
 
    private final JdbcTemplate jdbcTemplate;
 
    @Transactional
    public void saveAll(List<Product> products) {
        String sql = "INSERT INTO product (title, price) " +
                "VALUES (?, ?)";
 
        jdbcTemplate.batchUpdate(sql,
                products,
                products.size(),
                (PreparedStatement ps, Product product) -> {
                    ps.setString(1, product.getTitle());
                    ps.setLong(2, product.getPrice());
                });
    }
}

 

batchUpdate 메서드의 파라미터는 순서대로 "sql, batchArgs, batchSize, sql ?에 들어갈 값"입니다.

또는 다음과 같이 작성할 수도 있습니다.

 

@Repository
@RequiredArgsConstructor
public class ProductBulkRepository {
 
    private final JdbcTemplate jdbcTemplate;
 
    @Transactional
    public void saveAll(List<Product> products) {
        String sql = "INSERT INTO product (title, price) " +
                "VALUES (?, ?)";
 
        jdbcTemplate.batchUpdate(sql,
                new BatchPreparedStatementSetter() {
                    @Override
                    public void setValues(PreparedStatement ps, int i) throws SQLException {
                        Product product = products.get(i);
                        ps.setString(1, product.getTitle());
                        ps.setLong(2, product.getPrice());
                    }
 
                    @Override
                    public int getBatchSize() {
                        return products.size();
                    }
                });
    }
 
}

 

[References]

https://dkswnkk.tistory.com/682