쿼리 성능 테스트를 위해 대용량 데이터를 삽입해야 했다.
평소에는 데이터 삽입 시 saveAll()을 사용했는데, batch insert를 사용하면 빠르게 삽입할 수 있다.
이번 글에서는 batch insert로 대용량 데이터를 삽입해 보고, saveAll()과의 성능 차이를 비교해 보려고 한다.
그전에 batch insert에 대해 간략히 알아보자.
Batch insert
batch는 대량의 작업을 한 번에 처리한다는 뜻이다.
batch insert는 여러 SQL 명령을 그룹화하여 단일 네트워크 호출로 DB 서버에 전송한다.
batch insert는 JPA, JDBC 두 방식으로 구현할 수 있다.
JPA로 batch insert 구현
JPA 사용 시 아래처럼 batch size 설정을 추가해 배치를 구현할 수 있다.
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 50
JPA는 트랜잭션이 commit 되면 쓰기 지연저장소 내 쿼리가 한 번에 flush 된다.
이때 batch_size 옵션이 없으면 쿼리를 단건으로 network에 보내고,
옵션이 있으면 해당 크기만큼의 데이터를 한 번에 network로 보낸다.
JDBC로 batch insert 구현
나는 JPA가 아닌 JDBC로 batch insert를 구현했는데 두 가지 이유에서였다.
1. JDBC는 영속성 컨텍스트에서 관리되지 않고, DB와 직접 상호작용하므로 JPA보다 빠르다.
2. IDENTITY 전략에서는 batch insert가 불가능하다.
batch insert가 IDENTITY 전략을 쓰지 못하는 이유
엔티티가 영속성 컨텍스트에서 영속화되려면 식별자가 필요하다.
하지만 IDENTITY 전략은 기본 키 생성을 DB에 위임하므로, 실제 DB에 insert 되어야 식별자를 얻을 수 있다.
이로 인해 해당 전략에서는 쓰기 지연이 동작하지 않아 batch 처리가 불가능하다.
이러한 이유로 hibernate는 키 생성 전략이 IDENTITY일 경우 batch insert를 비활성화한다.
Batch insert JDBC 코드
아래는 JDBC를 사용해 질문글 리스트에 대한 batch insert를 수행하는 코드이다.
JdbcTemplate은 배치 작업을 위한 BatchPreparedStatementSetter 인터페이스를 제공한다.
@Transactional
public void saveQuestionPosts(List<QuestionPost> questionPosts) {
jdbcTemplate.batchUpdate("insert into question_post "
+ "(question_post_id, content, is_chosen, job_group, reward, title, member_id, status,created_at) "
+ "values (?,?,?,?,?,?,?,?,?)",
new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
QuestionPost questionPost = questionPosts.get(i);
ps.setLong(1, questionPost.getId());
..
}
@Override
public int getBatchSize() {
return questionPosts.size();
}
});
}
RewriteBatchedStatements 옵션
jdbc batch insert를 사용한다고 하더라도 무조건 하나의 쿼리로 insert 되지 않는다.
단지 여러 쿼리가 한 번에 단일 네트워크로 갈 뿐이다.
하나의 insert문으로 여러 데이터를 삽입하려면, DB URL에 rewriteBatchedStatements = true를 추가해야 한다.
이 옵션을 활성화하면 JDBC connector는 여러 insert문을 하나의 insert문으로 재작성해준다.
아래는 JDBC batch insert로 데이터 100,000건을 저장하는 코드이다.
List<QuestionPost> questionPosts = new ArrayList<>();
int threadCount = 100_000;
for (int i = 0; i < threadCount; i++) {
QuestionPost questionPost = QuestionPostFixture.questionPost(i + 1L, questioner);
questionPosts.add(questionPost);
}
questionPostJdbcRepository.saveQuestionPosts(questionPosts);
옵션에 따라 DB로 전송되는 쿼리가 어떻게 달라지는지 로그로 직접 확인해 보자.
참고로 batch insert 로그를 확인하기 위해서는 profileSQL=true 옵션을 DB URL에 추가해야 한다.
rewriteBatchedStatements = false일 때는 여러 개 insert문이 개별적으로 DB에 전송된다.
INFO MySQL - [FETCH] [Created on: Sat Feb 22 15:13:06 KST 2025, duration: 0, connection-id: 9, statement-id: 0, resultset-id: 0, at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:127)]
INFO MySQL - [QUERY] insert into question_post (question_post_id, content, is_chosen, job_group, reward, title, member_id, status,created_at) values (8494,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:12:59.388375') [Created on: Sat Feb 22 15:13:06 KST 2025, duration: 1, connection-id: 9, statement-id: 0, resultset-id: 0, at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:127)]
INFO MySQL - [FETCH] [Created on: Sat Feb 22 15:13:06 KST 2025, duration: 0, connection-id: 9, statement-id: 0, resultset-id: 0, at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:127)]
INFO MySQL - [QUERY] insert into question_post (question_post_id, content, is_chosen, job_group, reward, title, member_id, status,created_at) values (8495,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:12:59.388377') [Created on: Sat Feb 22 15:13:06 KST 2025, duration: 1, connection-id: 9, statement-id: 0, resultset-id: 0, at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:127)]
...
반면 true일 때는 하나의 insert문으로 합쳐져서 나간다.
INFO MySQL - [QUERY] insert into question_post (question_post_id, content, is_chosen, job_group, reward, title, member_id, status,created_at) values (1,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001348'),(2,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001388'),(3,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.0014'),(4,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.00141'),(5,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001419'),(6,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001426'),(7,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001433'),(8,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001445'),(9,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001452'),(10,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001459'),(11,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001467'),(12,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001474'),(13,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001487'),(14,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001495'),(15,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001502'),(16,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001509'),(17,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001517'),(18,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001524'),(19,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001532'),(20,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001539'),(21,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001546'),(22,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001554'),(23,'내용',0,'ENG',1000,'제목',1,'ANSWER_WAITING','2025-02-22 15:09:07.001564'), ... (truncated) [Created on: Sat Feb 22 15:09:07 KST 2025, duration: 165, connection-id: 9, statement-id: 0, resultset-id: 0, at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:127)]
INFO MySQL - [FETCH] [Created on: Sat Feb 22 15:09:07 KST 2025, duration: 0, connection-id: 9, statement-id: 0, resultset-id: 0, at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:127)]
DB가 처리해야 할 쿼리 수가 감소하므로, 아래 그림처럼 rewriteBatchedStatements 옵션을 추가했을 때 성능이 확연히 개선된다.
SaveAll()
saveAll()은 내부적으로 em.persist()를 여러 번 호출하여 save()와 동일한 작업을 수행한다.
하지만 saveAll()은 save()와 달리, 트랜잭션이 한 번만 실행되므로 save()를 반복 호출하는 것보다 성능이 좋다.
saveAll()에서 save()를 호출하는데 왜 트랜잭션이 한 번만 실행된다고 하는 것인지 궁금했다.
구글링 해보니 아래와 같은 사실을 알 수 있었다.
saveAll()과 save()의 전파속성은 기본값인 REQUIRED이다.
REQURIED 속성은 상위 트랜잭션이 있을 경우 상위 트랜잭션으로 들어가고, 없을 경우 새로운 트랜잭션을 생성한다.
saveAll()에서 이미 트랜잭션이 시작되어 있으므로 save()는 기존 트랜잭션에 합류하는 것이다.
@Transactional
public <S extends T> List<S> saveAll(Iterable<S> entities) {
Assert.notNull(entities, "Entities must not be null");
List<S> result = new ArrayList();
Iterator var4 = entities.iterator();
while(var4.hasNext()) {
S entity = (Object)var4.next();
result.add(this.save(entity));
}
return result;
}
@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 this.entityManager.merge(entity);
}
}
이러한 이유로 나는 batch insert를 알기 전 항상 saveAll()을 사용했었다.
그렇다면 saveAll()로 100,00개 데이터를 넣었을 때 얼마나 걸리는지 확인해 보자.
batch insert와 비교해 보았을 때 실행 시간이 140배 정도 차이가 난다.
saveAll()과 batch insert의 성능이 이렇게 까지 차이 나는 이유는 무엇일까?
identity 전략에서는 쓰기 지연을 쓸 수 없기 때문에 saveAll() 호출 시, insert 쿼리마다 네트워크 호출이 발생한다.
각 쿼리가 독립적으로 DB에 전송되기 때문에 대량의 데이터를 처리할 때는 비효율적이다.
반면 JDBC batch insert는 여러 개의 insert 쿼리를 단일 네트워크 호출로 DB에 전송할 수 있다.
더 나아가 RewriteBatchedStatements 옵션을 사용하면, 여러 개의 쿼리를 단일 SQL로 합쳐 DB 처리 시간을 감소시킬 수 있다.
MySQL 환경의 스프링부트에 하이버네이트 배치 설정해 보기 | 우아한형제들 기술블로그
안녕하세요. 배민상품시스템팀 권순규 입니다. 저희팀에서 하이버네이트 배치 설정을 통해 대량 insert/update 시의 속도개선을 경험하여 공유드리고자 합니다. 전체 예제 파일은 github 에서 확인
techblog.woowahan.com
[JPA/MySQL] saveAll() 쓰면 쿼리 하나로 나가는 거 아니었어? / JPA에서 Bulk Insert 처리해보기
Bulk Insert란? INSERT 쿼리를 한번에 처리하는 것 MySQL에서는 아래처럼 Insert 합치기 옵션을 통해 성능을 비약적으로 향상할 수 있다. INSERT INTO person (name) VALUES ('name1'), ('name2'), ('name3'); Hibernate의 Bulk In
dct-wonjung.tistory.com
JPA bulk insert(save) performance
JPA는 Spring에서 사용하는 ORM 기술로 최근에 개발되고 있는 많은 애플리케이션이 이 기술을 이용하고 있다. JPA는 SQL을 직접 작성하지 않으며 객체지향적으로 코드를 유지할 수 있게 도와주기 때
medium.com
Spring JPA Batch Insert 과연 생각대로 동작할까? | Carrey`s 기술블로그
들어가며 Spring JPA를 사용하며 대량으로 insert 시, 1건씩 insert 되기에 성능이 너무 안나온다고 생각을 하고 있었습니다. 그래서 초반에는 bulk insert와 같은 키워드로 검색을 해보니 Hibernate Batch Inser
jaehun2841.github.io
JPA 배치 인서트 vs JDBC배치 인서트
들어가며 저번 게시글 대용량 데이터 등록에서는 JPA(Java Persistence API) 배치 인서트(Batch Insert)를 사용해서 대용량의 데이터를 등록했습니다. 하지만 프로젝트가 진행되면서 추가할 데이터의 수가
www.nextree.io