1. 개요
이 예제에서는 Spring JDBC Batch 지원을 사용하여 대상 RDBMS에 방대한 양의 데이터를 효과적으로 삽입하는 방법을 배우고 일괄 삽입과 여러 개의 단일 삽입을 사용할 때의 성능을 비교할 것입니다.
2. 일괄 처리 이해
애플리케이션이 데이터베이스에 연결되면 각 명령문을 하나씩 보내는 대신 한 번에 여러 SQL 문을 실행할 수 있습니다. 따라서 통신 오버헤드를 크게 줄입니다.
이를 달성하기 위한 한 가지 옵션은 다음 섹션의 초점인 Spring JDBC API를 사용하는 것입니다.
2.1. 데이터베이스 지원
JDBC API 가 배치 기능을 제공 하더라도 우리가 사용하고 있는 기본 JDBC 드라이버가 실제로 이러한 API를 구현하고 이 기능을 지원한다는 보장은 없습니다.
Spring은 JDBC 연결 을 매개변수로 사용하고 단순히 true 또는 false 를 반환하는 JdbcUtils.supportsBatchUpdates() 라는 유틸리티 메서드를 제공합니다 . 그러나 JdbcTemplate API 를 사용하는 대부분의 경우 Spring은 이미 이를 확인하고 그렇지 않으면 일반 동작으로 돌아갑니다.
2.2. 전체 성능에 영향을 미칠 수 있는 요인
상당한 양의 데이터를 삽입할 때 고려해야 할 몇 가지 측면이 있습니다 .
- 데이터베이스 서버와 통신하기 위해 생성하는 연결 수
- 우리가 삽입하는 테이블
- 단일 논리적 작업을 실행하기 위해 수행하는 데이터베이스 요청 수
일반적으로 첫 번째 점을 극복하기 위해 연결 풀링을 사용합니다 . 이렇게 하면 새 연결을 만드는 대신 기존 연결을 재사용할 수 있습니다.
또 다른 중요한 점은 대상 테이블입니다. 정확히 말하면 인덱스 열이 많을수록 데이터베이스 서버가 새 행마다 인덱스를 조정해야 하기 때문에 성능이 저하됩니다.
마지막으로 배치 지원을 사용하여 왕복 횟수를 줄여 많은 항목을 삽입할 수 있습니다 .
그러나 모든 JDBC 드라이버/데이터베이스 서버가 배치 작업을 지원하더라도 동일한 효율성 수준을 제공하는 것은 아닙니다. 예를 들어 Oracle, Postgres, SQL Server 및 DB2와 같은 데이터베이스 서버는 상당한 이점을 제공하지만 MySQL은 추가 구성 없이는 낮은 이점을 제공합니다.
3. Spring JDBC 일괄 삽입
이 예에서는 Postgres 14를 데이터베이스 서버로 사용합니다 . 따라서 해당 postgresql JDBC 드라이버를 종속 항목에 추가해야 합니다.
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
그런 다음 Spring의 JDBC 추상화를 사용하기 위해 spring-boot-starter-jdbc 의존성도 추가해 보겠습니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
데모 목적으로 두 가지 다른 방법을 살펴보겠습니다. 먼저 각 레코드에 대해 정기적으로 삽입을 수행한 다음 일괄 지원을 활용하려고 합니다. 두 경우 모두 단일 트랜잭션을 사용합니다.
먼저 간단한 제품 테이블 부터 시작하겠습니다 .
CREATE TABLE product (
id SERIAL PRIMARY KEY,
title VARCHAR(40),
created_ts timestamp without time zone,
price numeric
);
해당 모델 제품 클래스는 다음과 같습니다.
public class Product {
private long id;
private String title;
private LocalDateTime createdTs;
private BigDecimal price;
// standard setters and getters
}
3.1. 데이터 소스 구성
아래 구성을 application.properties 에 추가하면 Spring Boot는 DataSource 와 JdbcTemplate 빈을 생성합니다.
spring.datasource.url=jdbc:postgresql://localhost:5432/sample-baeldung-db
spring.datasource.username=postgres
spring.datasource.password=root
spring.datasource.driver-class-name=org.postgresql.Driver
3.2. 일반 삽입 준비
제품 List을 저장하기 위해 간단한 리포지토리 인터페이스를 만드는 것으로 시작합니다.
public interface ProductRepository {
void saveAll(List<Product> products);
}
그런 다음 첫 번째 구현은 단순히 제품을 반복하고 동일한 트랜잭션에 하나씩 삽입합니다.
@Repository
public class SimpleProductRepository implements ProductRepository {
private JdbcTemplate jdbcTemplate;
public SimpleProductRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
@Transactional
public void saveAll(List<Product> products) {
for (Product product : products) {
jdbcTemplate.update("INSERT INTO PRODUCT (TITLE, CREATED_TS, PRICE) " +
"VALUES (?, ?, ?)",
product.getTitle(),
Timestamp.valueOf(product.getCreatedTs()),
product.getPrice());
}
}
}
이제 주어진 수의 Product 개체를 생성하고 삽입 프로세스를 시작 하는 서비스 클래스 ProductService 가 필요합니다. 먼저 미리 정의된 값을 사용하여 무작위 방식으로 주어진 수의 Product 인스턴스를 생성하는 방법이 있습니다.
public class ProductService {
private ProductRepository productRepository;
private Random random;
private Clock clock;
// constructor for the dependencies
private List<Product> generate(int count) {
final String[] titles = { "car", "plane", "house", "yacht" };
final BigDecimal[] prices = {
new BigDecimal("12483.12"),
new BigDecimal("8539.99"),
new BigDecimal("88894"),
new BigDecimal("458694")
};
final List<Product> products = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
Product product = new Product();
product.setCreatedTs(LocalDateTime.now(clock));
product.setPrice(prices[random.nextInt(4)]);
product.setTitle(titles[random.nextInt(4)]);
products.add(product);
}
return products;
}
}
둘째, 생성된 Product 인스턴스를 가져와서 삽입하는 다른 메서드를 ProductService 클래스에 추가합니다.
@Transactional
public long createProducts(int count) {
List<Product> products = generate(count);
long startTime = clock.millis();
productRepository.saveAll(products);
return clock.millis() - startTime;
}
ProductService 를 Spring bean 으로 만들기 위해 아래 구성도 추가해 보겠습니다.
@Configuration
public class AppConfig {
@Bean
public ProductService simpleProductService(SimpleProductRepository simpleProductRepository) {
return new ProductService(simpleProductRepository, new Random(), Clock.systemUTC());
}
}
보시다시피 이 ProductService 빈은 SimpleProductRepository 를 사용하여 일반 삽입을 수행합니다.
3.3. 일괄 삽입 준비
이제 Spring JDBC 배치 지원이 작동하는 것을 볼 시간입니다. 먼저 ProductRepository 클래스의 또 다른 배치 구현을 생성해 보겠습니다.
@Repository
public class BatchProductRepository implements ProductRepository {
private JdbcTemplate jdbcTemplate;
public BatchProductRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
@Transactional
public void saveAll(List<Product> products) {
jdbcTemplate.batchUpdate("INSERT INTO PRODUCT (TITLE, CREATED_TS, PRICE) " +
"VALUES (?, ?, ?)",
products,
100,
(PreparedStatement ps, Product product) -> {
ps.setString(1, product.getTitle());
ps.setTimestamp(2, Timestamp.valueOf(product.getCreatedTs()));
ps.setBigDecimal(3, product.getPrice());
});
}
}
이 예제에서는 배치 크기 100을 사용한다는 점에 주목하는 것이 중요합니다. 이는 Spring이 100개의 삽입마다 배치하고 별도로 전송한다는 것을 의미합니다. 즉, 왕복 횟수를 100배 줄이는 데 도움이 됩니다.
일반적으로 권장 배치 크기는 50-100이지만 데이터베이스 서버 구성과 각 배치 패키지의 크기에 따라 크게 달라집니다.
예를 들어 MySQL Server에는 각 네트워크 패키지에 대해 64MB 제한이 있는 max_allowed_packet 이라는 구성 속성이 있습니다 . 배치 크기를 설정하는 동안 데이터베이스 서버 제한을 초과하지 않도록 주의해야 합니다.
이제 AppConfig 클래스 에 추가 ProductService 빈 구성을 추가합니다.
@Bean
public ProductService batchProductService(BatchProductRepository batchProductRepository) {
return new ProductService(batchProductRepository, new Random(), Clock.systemUTC());
}
4. 성능 비교
예제를 실행하고 벤치마킹을 살펴볼 시간입니다. 단순성을 위해 Spring에서 제공하는 CommandLineRunner 인터페이스 를 구현하여 Command-Line Spring Boot 애플리케이션을 준비합니다. 두 접근 방식에 대해 예제를 여러 번 실행합니다.
@SpringBootApplication
public class SpringJdbcBatchPerformanceApplication implements CommandLineRunner {
@Autowired
@Qualifier("batchProductService")
private ProductService batchProductService;
@Autowired
@Qualifier("simpleProductService")
private ProductService simpleProductService;
public static void main(String[] args) {
SpringApplication.run(SpringJdbcBatchPerformanceApplication.class, args);
}
@Override
public void run(String... args) throws Exception {
int[] recordCounts = {1, 10, 100, 1000, 10_000, 100_000, 1000_000};
for (int recordCount : recordCounts) {
long regularElapsedTime = simpleProductService.createProducts(recordCount);
long batchElapsedTime = batchProductService.createProducts(recordCount);
System.out.println(String.join("", Collections.nCopies(50, "-")));
System.out.format("%-20s%-5s%-10s%-5s%8sms\n", "Regular inserts", "|", recordCount, "|", regularElapsedTime);
System.out.format("%-20s%-5s%-10s%-5s%8sms\n", "Batch inserts", "|", recordCount, "|", batchElapsedTime);
System.out.printf("Total gain: %d %s\n", calculateGainInPercent(regularElapsedTime, batchElapsedTime), "%");
}
}
int calculateGainInPercent(long before, long after) {
return (int) Math.floor(100D * (before - after) / before);
}
}
벤치마킹 결과는 다음과 같습니다.
--------------------------------------------------
Regular inserts | 1 | 14ms
Batch inserts | 1 | 8ms
Total gain: 42 %
--------------------------------------------------
Regular inserts | 10 | 4ms
Batch inserts | 10 | 1ms
Total gain: 75 %
--------------------------------------------------
Regular inserts | 100 | 29ms
Batch inserts | 100 | 6ms
Total gain: 79 %
--------------------------------------------------
Regular inserts | 1000 | 175ms
Batch inserts | 1000 | 24ms
Total gain: 86 %
--------------------------------------------------
Regular inserts | 10000 | 861ms
Batch inserts | 10000 | 128ms
Total gain: 85 %
--------------------------------------------------
Regular inserts | 100000 | 5098ms
Batch inserts | 100000 | 1126ms
Total gain: 77 %
--------------------------------------------------
Regular inserts | 1000000 | 47738ms
Batch inserts | 1000000 | 13066ms
Total gain: 72 %
--------------------------------------------------
결과는 상당히 유망해 보입니다.
그러나 그게 다가 아닙니다. Postgres, MySQL 및 SQL Server와 같은 일부 데이터베이스는 다중 값 삽입을 지원합니다. 삽입 문의 전체 크기를 줄이는 데 도움이 됩니다. 이것이 일반적으로 어떻게 작동하는지 봅시다:
-- REGULAR INSERTS TO INSERT 4 RECORDS
INSERT INTO PRODUCT
(TITLE, CREATED_TS, PRICE)
VALUES
('test1', LOCALTIMESTAMP, 100.10);
INSERT INTO PRODUCT
(TITLE, CREATED_TS, PRICE)
VALUES
('test2', LOCALTIMESTAMP, 101.10);
INSERT INTO PRODUCT
(TITLE, CREATED_TS, PRICE)
VALUES
('test3', LOCALTIMESTAMP, 102.10);
INSERT INTO PRODUCT
(TITLE, CREATED_TS, PRICE)
VALUES
('test4', LOCALTIMESTAMP, 103.10);
-- EQUIVALENT MULTI-VALUE INSERT
INSERT INTO PRODUCT
(TITLE, CREATED_TS, PRICE)
VALUES
('test1', LOCALTIMESTAMP, 100.10),
('test2', LOCALTIMESTAMP, 101.10),
('test3', LOCALTIMESTAMP, 102.10),
('test4', LOCALTIMESTAMP, 104.10);
Postgres 데이터베이스에서 이 기능을 활용하려면 application.properties 파일 에서 spring.datasource.hikari.data-source-properties.reWriteBatchedInserts=true 를 설정하는 것으로 충분합니다. 기본 JDBC 드라이버는 일반 삽입 문을 일괄 삽입을 위한 다중 값 문으로 재작성하기 시작합니다.
이 구성은 Postgres에만 적용됩니다. 다른 지원 데이터베이스는 구성 요구 사항이 다를 수 있습니다 .
이 기능을 활성화한 상태에서 애플리케이션을 다시 실행하고 차이점을 살펴보겠습니다.
--------------------------------------------------
Regular inserts | 1 | 15ms
Batch inserts | 1 | 10ms
Total gain: 33 %
--------------------------------------------------
Regular inserts | 10 | 3ms
Batch inserts | 10 | 2ms
Total gain: 33 %
--------------------------------------------------
Regular inserts | 100 | 42ms
Batch inserts | 100 | 10ms
Total gain: 76 %
--------------------------------------------------
Regular inserts | 1000 | 141ms
Batch inserts | 1000 | 19ms
Total gain: 86 %
--------------------------------------------------
Regular inserts | 10000 | 827ms
Batch inserts | 10000 | 104ms
Total gain: 87 %
--------------------------------------------------
Regular inserts | 100000 | 5093ms
Batch inserts | 100000 | 981ms
Total gain: 80 %
--------------------------------------------------
Regular inserts | 1000000 | 50482ms
Batch inserts | 1000000 | 9821ms
Total gain: 80 %
--------------------------------------------------
비교적 큰 데이터 세트가 있을 때 이 기능을 활성화하면 전반적인 성능이 향상되는 것을 볼 수 있습니다.
5. 결론
이 기사에서는 삽입에 대한 Spring JDBC 배치 지원의 이점을 보여주는 간단한 예제를 만들었습니다. 일반 인서트와 일괄 처리된 인서트를 비교하여 약 80-90%의 성능 향상을 얻었습니다. 물론 배치 기능을 사용하는 동안 JDBC 드라이버의 지원과 효율성도 고려해야 합니다.
또한 일부 데이터베이스/드라이버는 다중 값 삽입 기능을 제공하여 성능을 더욱 향상시키고 Postgres의 경우 이를 활용하는 방법을 살펴보았습니다.
항상 그렇듯이 예제의 소스 코드는 GitHub에서 사용할 수 있습니다 .