안녕하세요. 오늘은 JPA에서 auto_increment 테이블에 bulk insert를 지원하지 않는 이유에 대해서 알아보도록 하겠습니다. 그리고 JPA를 이용해서 다량의 데이터를 넣어보고 각 insert가 완료되기 까지의 시간을 확인해보는 포스팅을 진행하도록 하겠습니다. 또한 왜 이런 상대적인 결과가 나왔는지도 알아보도록 하겠습니다.
JPA와 bulk insert, 그리고 IDENTIFY
일반적으로 RDBMS에서는 bulk insert라고 하여 한번의 쿼리로 여러건의 데이터를 insert 할 수 있는 기능을 제공해주고 있습니다. 이런 bulk insert 쿼리를 이용하면 한번의 쿼리로 여러건의 데이터를 한번에 insert 할 수 있기 때문에 데이터베이스와 어플리케이션 사이의 통신에 들어가는 비용을 줄여주어 성능상 이득을 얻을 수 있습니다. 예를 들어 아래와 같은 쿼리가 bulk insert 입니다.
insert into user (name, age)
values ('ykh', 25),
('karol', 56),
('sabarada', 68);
JPA에서도 이런 bulk insert를 기본적으로 지원하고 있습니다. 하지만 만약 당신이 bulk insert를 원하는 테이블에서 auto_increment
를 사용하고 있다면 아쉽지만 bulk insert는 JPA를 통해서는 해결할 수 없습니다. 이는 hibernate 문서에 아래와 같이 표기되어 있는 부분이기도합니다.
Hibernate disables insert batching at the JDBC level transparently if you use an identity identifier generator.
그 이유는 stackoverflow에 hiberate의 대표적인 committer 중 한명인 Vlad Mihalcea가 설명한 답변을 보시면 이해하실 수 있습니다.
요약하자면 아래와 같습니다.
- 하이버네이트는 트랜잭션 마지막에 flush 하여 DB에 insert하는 write-behind 전략을 취함
- auto_increment는 DB에 insert 되면서 ID가 채번
- bulk insert를 하기 위해서는 여러 트랜잭션이 들어오는 것을 대비해 ID 값을 먼저 알고 있어야하나 그렇게 하는 것은 불가능
이와 같은 이유로 앞으로도 IDENTIFY 타입(auto_increment)으로 ID가 선언되는 테이블에 대해서는 bulk insert 지원은 없을 것으로 보입니다. 대안으로 보자면 JDBC를 쓸 수 있을것입니다. 만약 kotlin이라면 ORM 프레임워크를 exposed로 바꾸는것도 방법일 수 있습니다. 하지만 프레임워크를 추가하거나 바꾸지 못 하고 JPA를 통해 해결해야할 수 있습니다. 이럴 경우 어쩔 수 없이 다수의 insert 쿼리를 통해 할 수 밖에 없는데요. 그래도 어떤 방법이 그나마 빠르게 이를 수행할 수 있을지 한번 경우의 수에 따른 비교를 해보도록 하겠습니다.
repository save 밖에서 for을 통해 insert
가장 단순하게 생각할 수 있는 방법은 for 문을 통해서 insert를 진행하는 것입니다. 아래의 코드처럼 말이죠. 아래의 코드는 for 문을 1000번 톨면서 insert 하는 쿼리입니다. 그리고 이에 수행되는 시간을 확인해보았습니다.
@Test
void bulkService3() {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
bulkService.bulkService();
}
System.out.println("elapsed = " + (System.currentTimeMillis() - startTime) + "ms"); // 7531ms
}
public void bulkService() {
CreateReviewReq createReviewReq = new CreateReviewReq(
"hello + ",
new ReviewScore(1, 2, 3, 4, 5, 6)
);
Review review = reviewMapper.buildReview(1, 1, createReviewReq);
reviewRepositoryV1.save(review);
}
이 경우 7531ms
라는 시간을 보여주었습니다. bulkService에 @Transactional을 붙여주거나 saveAndflush를 하더라도 거의 동일한 시간대의 결과를 보여주었습니다. 이렇게 오래 걸리는 이유는 save의 구현을 보시면 이해하실 수 있습니다. 아래의 코드는 JPA save의 구현 코드입니다. 내부 코드에 @Transactional
이 들어가 있는 것을 확인하실 수 있습니다. 즉, save를 할 때 마다 트랜잭션을 잡는 행위를 하기 때문 이러한 경과시간을 보여주었다는 것을 확인할 수 있었습니다.
@Transactional
@Override
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
@Transactional 안에서 돌리기
위의 경우에서 save 내부에 @Transactional을 사용하고 있고 save 할때마다 트랜잭션을 잡아주니 저렇게 오래걸리는 것으로 확인을 할 수 있었습니다. 그렇다면 여기서 for 문을 도는 부분을 @Tranasactional로 묶어서 트랜잭션 전파(propagation)을 통해 save 한다면 save시 마다 트랜잭션을 새로 열지 않을것이며 성능개선을 할 수 있을 것으로 판단하였습니다. 그것이 아래코드입니다.
@Test
void bulkService7() {
long startTime = System.currentTimeMillis();
bulkService.bulkService3();
System.out.println("elapsed = " + (System.currentTimeMillis() - startTime) + "ms"); // 4255ms
}
@Transactional
public void bulkService3() {
for (int i = 0; i < 1000; i++) {
CreateReviewReq createReviewReq = new CreateReviewReq(
"hello + ",
new ReviewScore(1, 2, 3, 4, 5, 6)
);
Review review = reviewMapper.buildReview(1, 1, createReviewReq);
reviewRepositoryV1.save(review);
}
}
결과적으로 4255ms
정도가 걸렸으며 50% 정도의 성능향상을 확인할 수 있었습니다.
@Transaction 안에서 saveAll
마지막으로 saveAll을 사용했을 때 코드를 보도록 하겠습니다. 아래 코드가 saveAll
을 이용하여 전체 list를 한번의 saveAll
호출로 저장하는 방법입니다.
@Test
void bulkService8() {
long startTime = System.currentTimeMillis();
bulkService.bulkService4();
System.out.println("elapsed = " + (System.currentTimeMillis() - startTime) + "ms"); // 2850ms
}
public void bulkService4() {
List<CreateReviewReq> lists = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
CreateReviewReq createReviewReq = new CreateReviewReq(
"hello + ",
new ReviewScore(1, 2, 3, 4, 5, 6)
);
lists.add(createReviewReq);
}
reviewRepositoryV1.saveAll(
reviewReq.stream()
.map(req -> reviewMapper.buildReview(userId, gameId, req))
.collect(Collectors.toList())
)
}
결과적으로 2850ms
의 시간이 걸렸습니다. 이는 처음 대비 70% 정도의 성능 향상을 확인할 수 있었습니다. 2번째 방법에 비해서도 많은 성능 향상을 확인할 수 있었는데요. 그 이유는 @Transactional은 AOP 코드 라는 점에 있습니다. 2번째 방법은 외부에서 호출하기 때문에 @Transactional의 코드가 내부적으로 실행되어집니다. 하지만 saveAll을 사용하면 @Transactional 코드를 한번만 딱 실행시킬 수 있습니다. 아래는 aveAll코드입니다. 보시면 클래스 내부에서 save를 호출하는 것을 알 수있습니다. 때문에 @Transactional에 대한 코드가 전혀 실행되지 않기 때문에 이정도의 성능을 향상 시킬 수 있었던 것입니다.
@Transactional
@Override
public <S extends T> List<S> saveAll(Iterable<S> entities) {
Assert.notNull(entities, "Entities must not be null!");
List<S> result = new ArrayList<S>();
for (S entity : entities) {
result.add(save(entity));
}
return result;
}
마무리
오늘은 이렇게 JPA에서 auto_increment 테이블에서 bulk insert가 되지 않는 사실과 함께 다건 save에서 시간을 줄이는 방법에 대해서 알아보았습니다.
감사합니다.
참조
vladmihalcea_why-you-should-never-use-the-table-identifier-generator-with-jpa-and-hibernate
'language, framework, library > Spring' 카테고리의 다른 글
[Spring] @Async에서 사용하는 ThreadPoolTaskExecutor 최적화하기 (1) | 2022.02.19 |
---|---|
[Spring Boot] package(패키지)의 역할과 archUnit를 이용하여 구조 정립하기 (0) | 2021.12.26 |
[Spring] Spring의 Event를 어떻게 사용하는지에 대해서 알아봅시다. - @TransactionalEventListener에 대해서 (6) | 2021.08.23 |
[Spring + JPA] jpa에서 Repository를 이용한 비관적락을 구현해봅시다. With MariaDB (0) | 2021.08.16 |
[JPA] jpa에서 Repository를 이용한 낙관적락을 구현해봅시다. (1) | 2021.08.14 |
댓글