서론
이번 포스팅에서는 개인 프로젝트 진행 중에 다수의 쿼리를 한 번에 발생시킬 필요성이 있어, 기존에 사용하던 saveAll() 메서드와 save() 메서드, JPA batch, JdbcTemplate의 batchUpdate 메서드 간의 차이점에 대해 알아본 과정과 결과를 공유하고자 합니다.
테스트에 사용된 기기는 맥북프로 m1 pro 기반이므로, 실제 서버의 성능과 별개로 상대적인 최적화 개선방법으로 봐주시면 감사하겠습니다.
포스팅에서 작성된 코드는 아래 깃헙 레포지토리에서 클론하여 직접 테스트 해볼수 있습니다.
https://github.com/jaewonLeeKOR/save-optimization-test
Hibernate의 save()
보통 jpa에서 단건 데이터를 저장하거나 Transactional을 사용하지 않을 때 데이터 최신화를 위해 사용되는 메서드입니다.
우선 save 메서드가 어떤식으로 구현되어 실행되는지 알아보겠습니다.
CrudRespository 인터페이스의 구현체인 SimpleJpaRepository의 save 메서드를 확인해 보면 다음과 같습니다.
@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);
}
}
이 코드를 해석해보면 다음과 같습니다.
- save 메서드는 Transactional 어노테이션을 활용합니다
- 제공받은 엔티티가 null이면 예외를 발생시킵니다.
- 엔티티가 새로운 객체인지 판단합니다.
- 새로운 객체라면 영속성 컨텍스트에 등록합니다.
- 영속화된 엔티티를 반환합니다. → save 메서드의 호출자가 동일한 영속성 컨텍스트에 존재하지 않는다면 쿼리가 발생하고 난 후의 엔티티가 반환됩니다.
- 엔티티가 이미 데이터베이스에 존재한다면 영속성 컨텍스트에 엔티티를 병합합니다.
이를 통해 save 메서드는 메서드 호출시 마다 영속성 컨텍스트를 생성하고 메모리에 엔티티를 저장한 후 데이터베이스에 쿼리를 보내는 과정을 거치는 것을 알 수 있습니다.
다음으로 다량의 엔티티를 저장할 경우 소모되는 시간에 대해서 알아보겠습니다.
테스트에 사용하기 위해 다음과 같은 간단한 엔티티와 레포지토리 인터페이스를 작성하여 사용했습니다.
// SimpleEntity.java
@Entity(name = "simple_entity")
@NoArgsConstructor
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class SimpleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "simple_data", nullable = false)
Integer simpleData;
@CreatedDate
@Column(name = "created_at", updatable = false, nullable = false)
LocalDateTime createdAt;
}
// SimpleEntityRepository.java
public interface SimpleEntityRepository extends JpaRepository<SimpleEntity, Long> {
}
다음으로 쿼리에 소요되는 시간을 측정하기 위해 다음과 같은 테스트 코드를 작성해봤습니다.
@SpringBootTest
@TestMethodOrder(value = MethodOrderer.OrderAnnotation.class)
class SimpleEntityServiceImplTest {
@Autowired
private SimpleEntityRepository simpleEntityRepository;
@AfterEach
void tearDown() {
simpleEntityRepository.deleteAllInBatch();
}
@Order(1)
@Test
public void springLoader() {
System.out.println("Hello world!");
}
public void saveTest(int test_case) {
// given
List<SimpleEntity> entityList = new ArrayList<>(test_case);
for (int i = 0; i < test_case; i++) {
entityList.add(SimpleEntity.builder()
.simpleData(i)
.build()
);
}
// when
long startTime = System.currentTimeMillis();
List<SimpleEntity> list = entityList.stream().map(entity ->
simpleEntityRepository.save(entity)
).toList();
// then
System.out.println(String.format("Time ---- %d", test_case));
System.out.println((System.currentTimeMillis() - startTime) + "ms");
}
@Order(2)
@TestFactory
public Collection<DynamicTest> saveTestes() {
return List.of(
DynamicTest.dynamicTest("엔티티 1개", () -> {
saveTest(1);
}),
DynamicTest.dynamicTest("엔티티 100개", () -> {
saveTest(100);
}),
DynamicTest.dynamicTest("엔티티 10,000개", () -> {
saveTest(10_000);
}),
DynamicTest.dynamicTest("엔티티 1,000,000개", () -> {
saveTest(1_000_000);
})
);
}
테스트시에 SpringContext 로드와 테스트가 관계가 없도록 junit의 Order 어노테이션을 활용하여 SpringContext가 로드된 이후 다른 간단한 테스트 메서드를 앞단에 배치해 줬습니다.
테스트는 엔티티 저장을 1개, 100개, 10,000, 1,000,000개를 했을 때의 결과를 확인해 봤습니다.
엔티티 개수 | 1개 | 100개 | 10,000개 | 1,000,000 |
---|---|---|---|---|
소요 시간 (ms) | 42ms | 496ms | 23,360ms | 2,385,574ms |
테스트에 로그 출력에 따른 병목현상이 생기지 않도록 쿼리 생성에 따른 로그는 모두 제거하고 수행했습니다.
적은 양의 데이터를 저장할 경우에는 큰 소요시간이 생기지는 않았지만, 현업에서는 많은 양의 데이터를 처리해야 할 필요가 존재하기 때문에 개선이 필요해 보입니다.
다음 유형의 저장 소요시간을 알아보기 앞서 구현체 코드를 확인해 봄으로 알게 된 save 메서드가 정말 영속성 컨텍스트를 매번 새롭게 사용하는지 알아보겠습니다.
우선 hibernate가 발생시키는 쿼리와 영속성 컨텍스트의 상태 변화를 확인하기 위해서 applcation.yml에 다음과 같은 설정값을 설정해줘야 합니다.
spring:
jpa:
show-sql: true # Hibernate가 생성하는 쿼리를 확인하기 위함
logging:
level:
org:
hibernate:
internal:
SessionImpl: TRACE # 영속성 컨텍스트의 상태변화를 확인하기 위함
SessionImpl 이 생소할 수 있지만, SessionImpl는 Hibernate에서 구현한 JPA의 EntityManager 인터페이스의 구현체로 해당 클래스를 TRACE 레벨로 로그를 확인해 보면 영속성 컨텍스트의 상태변화를 확인할 수 있습니다.
이번 테스트에서는 save 메서드 사용 시에 영속성 컨텍스트를 매번 새로운 영속성 컨텍스트를 할당하는지만 알아보면 되기 때문에 5개의 엔티티만 저장해 보겠습니다.
테스트 결과 위의 테스트의 로그와 같이 insert 쿼리 하나당 하나의 영속성 컨텍스트가 만들어지고 제거됨을 확인할 수 있습니다.
따라서 다음으로는 엔티티 저장이 모두 다른 영속성 컨텍스트가 아닌 동일한 영속성 컨텍스트에서 처리될 경우엔 결과가 어떤지 알아보겠습니다.
Hibernate의 save() + @Transactional
모든 save 메서드가 동일한 영속성 컨텍스트로 묶이기 위해서는 save 메서드의 호출자 메서드에 Transactional 어노테이션을 선언해줘야 합니다. 테스트를 위해 다음과 같이 테스트 코드의 메서드를 작성해 봤습니다.
@Test
@Order(3)
@Transactional
@DisplayName("엔티티 1개")
public void saveTransactionalTest1() {
saveTest(1);
}
@Test
@Order(4)
@Transactional
@DisplayName("엔티티 100개")
public void saveTransactionalTest100() {
saveTest(100);
}
@Test
@Order(5)
@Transactional
@DisplayName("엔티티 10,000개")
public void saveTransactionalTest10000() {
saveTest(10_000);
}
@Test
@Order(6)
@Transactional
@DisplayName("엔티티 1,000,000개")
public void saveTransactionalTest1000000() {
saveTest(1_000_000);
}
TestFactory를 사용하는 경우 각각의 테스트케이스들이 하나의 영속성 컨텍스트에 묶이거나 피호출 메서드와 동일한 클래스에서의 호출로 CGLib로 만들어진 메서드를 호출할 수 없어 Transactional 어노테이션을 활용할 수 없습니다. 따라서 개별적인 테스트로 수행해줘야 합니다. 위의 테스트 코드의 결과는 아래와 같습니다.
엔티티 개수 | 1개 | 100개 | 10,000개 | 1,000,000 |
---|---|---|---|---|
소요 시간 (ms) | 38ms | 159ms | 4,333ms | 241,256ms |
이번에도 테스트에 로그 출력에 따른 병목현상이 생기지 않도록 쿼리 생성에 따른 로그는 모두 제거하고 수행했습니다.
소모 시간을 확인해 보면 상당히 큰 폭으로 개선이 됐음을 확인할 수 있습니다. 하지만 1,000,000개의 저장에서는 4분이 넘어가므로 개선의 여지는 많이 남아있습니다.
다음으로 해당방식으로 영속성 컨텍스트가 함께 잘 묶이는 지를 확인하기 위해서 5개의 save 메서드에 대해서 다시 실행해 봤습니다.
위와 같이 영속성 컨텍스트의 생성과 제거가 한 번만 일어나는 로그를 확인할 수 있습니다.
— 주의 —
Transactional 어노테이션이 붙은 메서드를 동일한 메서드에서 호출할 경우에는 CGLib로 만들어진 프록시 클래스를 통해 메서드를 호출하는 것이 아니기 때문에 영속성 컨텍스트를 가지지 못합니다. 따라서 테스트를 실행하는 메서드 상단에 Transactional 어노테이션을 달아주어야 정상적으로 테스트가 가능합니다.
이번 테스트를 통해 복잡한 데이터베이스 작업을 도와주는 영속성 컨텍스트이지만, 생성과 소멸에 꽤나 큰 오버헤드를 가지고 있음을 확인할 수 있었습니다.
Hibernate의 saveAll()
save와 함께 jpa를 사용할 때 여러 개의 엔티티를 저장하기 위해 자주 사용하는 saveAll 메서드입니다.
save와 마찬가지로 ListRepository 인터페이스의 구현체인 SimpleJpaRepository의 saveAll 메서드를 확인해 보면 다음과 같습니다.
@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;
}
이 코드를 해석해 보면 다음과 같습니다.
- saveAll 메서드는 Transactional 어노테이션을 활용합니다.
- 제공받은 Iterable 객체가 null이면 예외를 발생시킵니다.
- Interable 객체의 Iterator를 활용해 순회를 하며 save 메서드를 호출합니다.
- save 메서드에서 반환한 엔티티들의 결괏값을 리스트로 모아 반환합니다.
이를 통해 saveAll 메서드 내부에서는 save 메서드를 호출하는 방식으로 구현이 되어있음을 확인할 수 있습니다.
따라서 이번 테스트의 결과를 유추해 본다면 save 메서드의 호출자 메서드에 Transactional 어노테이션을 붙여줬을 때의 비슷한 결과를 얻을 수 있을 것이라 생각됩니다.
saveAll 메서드를 테스트하기 위해 save의 테스트 코드를 조금 변형해 다음과 같이 작성해 봤습니다.
public void saveAllTest(int test_case) {
// given
List<SimpleEntity> entityList = new ArrayList<>(test_case);
for (int i = 0; i < test_case; i++) {
entityList.add(SimpleEntity.builder()
.simpleData(i)
.build()
);
}
// when
long startTime = System.currentTimeMillis();
List<SimpleEntity> list = simpleEntityRepository.saveAll(entityList);
// then
System.out.println(String.format("Time ---- %d", test_case));
System.out.println((System.currentTimeMillis() - startTime) + "ms");
}
@Order(7)
@TestFactory
public Collection<DynamicTest> saveAllTestes() {
return List.of(
DynamicTest.dynamicTest("엔티티 1개", () -> {
saveAllTest(1);
}),
DynamicTest.dynamicTest("엔티티 100개", () -> {
saveAllTest(100);
}),
DynamicTest.dynamicTest("엔티티 10,000개", () -> {
saveAllTest(10_000);
}),
DynamicTest.dynamicTest("엔티티 1,000,000개", () -> {
saveAllTest(1_000_000);
})
);
}
위 테스트의 결과는 아래와 같습니다.
엔티티 개수 | 1개 | 100개 | 10,000개 | 1,000,000 |
---|---|---|---|---|
소요 시간 (ms) | 83ms | 128ms | 5,121ms | 285,483ms |
앞선 테스트들과 마찬가지로 테스트에 로그 출력에 따른 병목현상이 생기지 않도록 쿼리 생성에 따른 로그는 모두 제거하고 수행했습니다.
소모 시간을 확인해 보면 save 메서드에 Transactional을 붙인 경우와 비슷하면서 조금은 느린 것을 알 수 있습니다. 따라서 동일하게 개선의 여지가 더 남아있습니다.
다음으로 saveAll 메서드도 하나의 영속성 컨텍스트에서 동작하는지 확인하기 위해서 5개 엔티티에 대해서 다시 실행해 봤습니다.
위와 같이 insert를 위한 영속성 컨텍스트의 생성과 제거와 tearDown을 위한 영속성 컨텍스트로 두개의 영속성 컨텍스트의 로그를 확인할 수 있습니다.
이번 테스트를 통해 복잡한 데이터베이스 작업을 도와주는 영속성 컨텍스트의 생성과 소멸이 꽤나 큰 오버헤드를 가지고 있음을 확인할 수 있었습니다.
Hibernate의 batch
다음으로 Hibernate를 활용했을 때 딥하지 않은 선에서 가장 최적화 정도가 좋은 Hibernate의 Batch 방식입니다.
Hibernate의 Batch insert는 Hibernate가 SQL문을 그룹으로 묶어 보내는 방식입니다.
이를 수행하기 위해서는 다음의 두 가지 Hibernate의 설정값을 수정해줘야 합니다.
- hibernate.jdbc.batch_size
- INSERT/UPDATE/DELETE 쿼리의 배치 크기를 지정합니다.
- 여러 쿼리를 모아서 한 번에 DB로 보내 성능을 높입니다.
- 주의
- 데이터베이스에 부하를 줄 수 있기 때문에
- hibernate.jdbc.fetch_size
- SELECT 쿼리 시 fetch size(한 번에 가져오는 레코드 개수)를 지정합니다.
- 가져오는 레코드 개수를 수정이 필요 없다면 설정하지 않아도 됩니다.
spring:
datasource:
url: jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true
jpa:
properties:
hibernate:
jdbc:
batch_size: 50
Hibernate의 Batch로 DML을 사용하기 위해서는 기존의 엔티티에서 사용하던 primary key 생성 전략인 IDENTITY 방식을 사용할 수 없습니다. 따라서 정수 형태로 primary key를 사용할 수 있는 GenerateType은 TABLE, SEQUENCE가 있습니다. SEQUENCE 은 테스트로 사용하고 있는 환경인 mysql 데이터베이스에서 사용할 수 없는 방식이므로 TABLE 방식을 사용해야 합니다.
TABLE 방식의 primary key 생성을 적용하기 위해 기존의 엔티티를 다음과 같이 변경할 수 있습니다.
@Getter
@TableGenerator(
name = "simple_entity_seq_generator",
table = "simple_entity_seq_table",
pkColumnName = "simple_entity_seq",
allocationSize = 20
)
@Entity(name = "simple_entity_with_table")
@NoArgsConstructor
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class SimpleEntityWithTable {
@Id
@GeneratedValue(
strategy = GenerationType.TABLE,
generator = "simple_entity_seq_generator"
)
private Long id;
@Column(name = "simple_data", nullable = false)
Integer simpleData;
@CreatedDate
@Column(name = "created_at", updatable = false, nullable = false)
LocalDateTime createdAt;
}
위의 엔티티 선언에서 사용된 TABLE 방식을 사용하기 위해 사용되는 TableGenerator 어노테이션의 각 설정값들에 대해서 간단히 설명해 보면,
- name
- Table 방식의 id 생성기의 이름을 지정해 줍니다.
- table
- 앞서 생성된 id의 마지막 값이 저장되는 테이블을 지정합니다.
- pkColumnName
- id에 해당하는 테이블명을 저장을 위해 사용하는 컬럼 명입니다.
- allocationSize
- id 생성 시 한 번에 예약하여 받아올 primary key의 크기를 지정합니다.
- 생성할 레코드의 개수가 해당 값 보다 작다면 레코드의 primary key에 사용하고 남은 primary key 후보들은 무시하고 다음부터 새롭게 할당됩니다.
- 생성할 레코드의 개수가 해당 값 보다 크다면 primary key 후보를 확보하기 위해 여러 번에 걸쳐 현재 생성된 마지막 id를 조회하고 새로운 값을 저장합니다.
과 같습니다.
엔티티를 사용하기 위한 코드는 기존과 동일합니다.
이제 앞선 세 가지 방식 save(), save() + @Transactional, saveAll()에 대해서 테스트 코드를 통해 수행 시간을 측정해 봤습니다.
위의 테스트 결과를 바탕으로 아래와 같이 앞서 사용한 batch를 사용하지 않은 방식과 속도를 비교해 봤습니다.
simple JPA | 1개 | 100개 | 10,000개 | 1,000,000개 |
---|---|---|---|---|
save() | 42ms | 496ms | 23,360ms | 2,385,574ms |
save() + @Transactional | 38ms | 159ms | 4,333ms | 241,256ms |
saveAll() | 83ms | 128ms | 5,121ms | 285,483ms |
JPA + batch size | 1개 | 100개 | 10,000개 | 1,000,000개 |
---|---|---|---|---|
save() | 57ms | 384ms | 24,495ms | 2,352,363ms |
save() + @Transactional | 6ms | 41ms | 1,953ms | 118,109ms |
saveAll() | 15ms | 25ms | 1,313ms | 137,020ms |
두 결과를 통해 얼마나 개선되었는지 비율로 나타내보면 다음 표와 같습니다.
(simple JPA) / (JPA + batch size) | 1개 | 100개 | 10,000개 | 1,000,000개 |
---|---|---|---|---|
save() | 135.71% | 77.42% | 104.86% | 98.61% |
save() + @Transactional | 15.79% | 25.79% | 45.07% | 48.96% |
saveAll() | 18.07% | 19.53% | 25.64% | 48.00% |
save 메서드는 batch 크기를 설정하더라도 큰 차이가 없는 것으로 보입니다.
save() + @Transactional를 사용한 경우에는 수행 속도가 2배~4배로 줄어든 것을 확인할 수 있습니다.
saveAll() 메서드를 사용한 경우에도 수행 속도가 2배~5배로 줄어들었음을 확인할 수 있습니다.
save()와 save() + @Transactional의 개선 비율을 비교해 봄으로 Hibernate에서 batch를 영속성 컨텍스트를 단위로 끊어서 쿼리를 발송하고 있음을 유추해 볼 수 있습니다.
유형별 로그 분석
다음으로 쿼리가 실제로 어떻게 발생하고 있는지 확인하기 위해 데이터베이스의 로그와 hiberate의 쿼리를 활성화한 후 간단히 100개의 쿼리를 발생시켜 로그를 확인해 보겠습니다.
로그 확인을 더 정확하게 하기 위해서 영속성 컨텍스트와, show-sql 설정과 함께 application.yml 에서 datasource로의 url의 쿼리파라미터를 수정해 주어 데이터베이스에서 자바 애플리케이션으로 로그를 작성할 수 있도록 수정해 줍니다.
- rewriteBatchedStatements
- JDBC를 통해 batch 쿼리를 전송할 수 있도록 해 줍니다.
- profileSQL
- JDBC 드라이버가 실행되는 SQL 쿼리를 로그로 출력하게 해 줍니다.
- logger
- 쿼리 로그를 Slf4j로 출력하게 해줍니다.
- maxQuerySizeToLog
- 로그로 출력할 SQL 쿼리의 최대 길이(글자수)를 지정합니다.
spring:
datasource:
url: jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999
jpa:
show-sql: true
logging:
level:
org:
hibernate:
internal:
SessionImpl: TRACE
save()
save()로 앤티티 저장을 100번 수행한 후 로그를 확인해 보면 다음과 같습니다.
batch를 사용했지만 101(엔티티 저장 100개 + tearDown 1개) 개의 영속성 컨텍스트 세션이 생겨나고 있음을 확인할 수 있습니다.
각 영속성 컨텍스트 세션 내에서는 매번 save() 메서드에 대해서 다음과 같은 작업을 수행합니다.
- 영속성 컨텍스트 세션을 open 합니다.
- 사용가능한 id를 조회합니다.
- allocationSize 만큼 마지막 primary key을 증가시킨 next_val을 update 해줍니다.
- save 메서드를 수행해 insert 쿼리를 발생시킵니다.
- 영속성 컨텍스트 세션을 close 합니다.
save() + @Transactional
save() + @Transactaional로 엔티티 저장을 100번 수행한 후 로그를 확인해 보면 다음과 같습니다.
우선 영속성 컨텍스트 세션은 하나만 생성되고 사용되는 것을 확인할 수 있습니다.
영속성 컨텍스트 생성 이후에는 가장 먼저 영속성 컨텍스트에서 저장하는 100개의 엔티티가 사용하기 위한 primary key를 확보합니다.
이번 테스트 케이스의 경우에는 id generator의 allocationSize가 20으로 설정되어 있고, save 할 엔티티의 개수가 100개이기 때문에 5번에 걸쳐 id를 확보를 수행하는 것을 로그를 통해 확인할 수 있습니다.
다음으로 마지막 id 블록 확보 후의 로그를 보면 저장할 엔티티의 primary key가 모두 예정되어 있기 때문에 데이터베이스로 insert 쿼리를 보내기도 전에 호출자 메서드에게 엔티티의 save 된 결과를 먼저 보내는 것을 확인할 수 있습니다.
다음으로 100개의 엔티티를 저장하기 위해 Hibernate가 100개의 insert 문을 발생시킵니다.
앞선 hibernate의 프로퍼티 설정 시에 hibernate.jdbc.batch_size를 50으로 설정해 놨기 때문에 로그를 확인해 보면 100개의 DML이 50개씩 끊어서 두 번에 걸쳐 발송되고 있음을 확인할 수 있습니다.
다음으로 tearDown 메서드를 수행한 후 영속성 컨텍스트 세션이 close 되는 것을 확인할 수 있습니다.
이 과정을 다시 정리해 보면 다음과 같습니다.
- hibernate가 영속성 컨텍스트 세션을 open 합니다.
- 엔티티 저장에 사용할 id 값을 여러 번의 select와 update를 걸쳐 모두 확보합니다.
- 호출자 메서드에게 엔티티의 트랜잭션 작업 이후의 결괏값을 반환합니다.
- 각 엔티티의 정보를 batch 크기로 나누어 insert 쿼리를 발송합니다.
- hibernate가 영속성 컨텍스트 세션을 close 합니다.
saveAll()
save() + @Transactaional를 100번 수행한 후 로그를 확인해 보면 다음과 같습니다.
가장 먼저 영속성 컨텍스트 세션이 하나 open 됩니다.
영속성 컨텍스트 생성 이후에는 가장 먼저 영속성 컨텍스트에서 저장하는 100개의 엔티티가 사용하기 위한 primary key를 확보합니다.
앞선 테스트와 동일하게 id generator의 allocationSize가 20으로 설정되어 있고, save 할 엔티티의 개수가 100개이기 때문에 5번에 걸쳐 id를 확보를 수행하는 것을 로그를 통해 확인할 수 있습니다.
마지막 id 블록 확보 후는 앞선 테스트와 상이합니다. id 확보가 끝난 이후 바로 Transaction 이 끝나면서 영속성 컨텍스트 세션에 존재하던 엔티티들이 flush 됩니다.
다음은 동일하게 100개의 엔티티를 저장하기 위해 Hibernate가 100개의 insert 문을 발생시킵니다.
또한 동일하게 hibernate의 프로퍼티 설정 시에 hibernate.jdbc.batch_size를 50으로 설정해 놨기 때문에 로그를 확인해 보면 100개의 DML이 50개씩 끊어서 두 번에 걸쳐 발송되고 있음을 확인할 수 있습니다.
이후 영속성 컨텍스트 세션이 close 되면서 호출자 메서드로 엔티티의 saveAll()의 결괏값이 전달되는 것을 확인할 수 있습니다.
이 과정을 다시 정리해 보면 다음과 같습니다.
- hibernate가 영속성 컨텍스트 세션을 open 합니다.
- 엔티티 저장에 사용할 id 값을 여러 번의 select와 update를 걸쳐 모두 확보합니다.
- Transaction이 완료가 되어 영속성 컨텍스트 세션에 존재하던 엔티티들이 flush 됩니다.
- 각 엔티티의 정보를 batch 크기로 나누어 insert 쿼리를 발송합니다.
- hibernate가 영속성 컨텍스트 세션을 close 합니다.
- 호출자 메서드에게 엔티티의 트랜잭션 작업 이후의 결괏값을 반환합니다.
결과
save()의 경우 앞서 수행시간으로 유추한 바와 같이 batch size와 연관 없이 영속성 콘텍스트 세션마다 쿼리가 발생하고 있음을 확인할 수 있습니다.
save() + @Transactional 은 saveAll() 이 동일한 결과를 가질 것으로 예상되었지만, 로그를 통해 분석해 보니 save() + @Transactional 은 엔티티를 저장하기 전에 비즈니스 로직을 모두 수행한 후 insert 되는 것을 확인할 수 있었고, saveAll() 은 호출자 메서드가 동일한 영속성 컨텍스트에 존재하지 않기 때문에 엔티티가 모두 저장된 이후 결괏값이 반환되는 것을 확인할 수 있었습니다.
Hiberate의 batch를 활용함으로 DML의 처리 속도 측면에서 2배~5배까지의 개선을 이뤄낼 수 있었습니다.
하지만 엔티티의 primary key인 id의 생성전략으로 IDENTITY를 사용할 수 없다는 점과, 100만 개의 데이터에 대해서는 여전히 분단위가 넘어가는 처리속도가 필요하다는 점에 개선이 필요해 보입니다.
jdbcTemplate의 BatchUpdate()
jdbcTemplate은 내부적으로 JDBC API를 사용하면서 connection이나 statement, ResultSet과 같은 보일러플레이트한 자원 관리 코드를 줄여주는 spring 프레임워크의 JDBC 인터페이스입니다.
JdbcTemplate 라이브러리는 spring data jpa 내에서 이미 사용 중이기 때문에 dependency를 추가해주지 않아도 바로 사용이 가능합니다.
저는 jdbcTemplate의 batchUpdate 메서드를 활용해서 아래와 같이 구현해 봤습니다.
@Autowired
private JdbcTemplate jdbcTemplate;
public void saveJdbcTemplateTest(int test_case) {
// given
List<SimpleEntity> entityList = new ArrayList<>(test_case);
for (int i = 0; i < test_case; i++) {
entityList.add(SimpleEntity.builder()
.simpleData(i)
.build()
);
}
// when
long startTime = System.currentTimeMillis();
int[] impactedRows = jdbcTemplate.batchUpdate(
"INSERT INTO simple_entity (simple_data, created_at) VALUES (?, NOW())",
new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
ps.setInt(1, entityList.get(i).getSimpleData());
}
@Override
public int getBatchSize() {
return entityList.size();
}
});
// then
System.out.println(String.format("Time ---- %d", test_case));
System.out.println((System.currentTimeMillis() - startTime) + "ms");
}
@Order(8)
@TestFactory
public Collection<DynamicTest> saveAllTestes() {
return List.of(
DynamicTest.dynamicTest("엔티티 1개", () -> {
saveJdbcTemplateTest(1);
}),
DynamicTest.dynamicTest("엔티티 100개", () -> {
saveJdbcTemplateTest(100);
}),
DynamicTest.dynamicTest("엔티티 10,000개", () -> {
saveJdbcTemplateTest(10_000);
}),
DynamicTest.dynamicTest("엔티티 1,000,000개", () -> {
saveJdbcTemplateTest(1_000_000);
})
);
}
주의 사항으로 Hibernate를 사용하는 방식이 아니기 때문에 JPA의 편의 기능들은 사용할 수가 없습니다. 따라서 기존에 사용하던 엔티티의 생성 시각을 기록하는 JpaAuditing의 CreatedAt 어노테이션을 활용할 수 없습니다.
그러므로 위의 예시 코드와 같이 데이터베이스에서 직접 현재 시각을 넣어주도록 NOW() 함수를 활용하여 현재 시각을 넣어줘야 합니다.
위의 테스트 코드를 실행해 보면 아래와 같은 결과를 확인할 수 있습니다.
엔티티 개수 | 1개 | 100개 | 10,000개 | 1,000,000 |
---|---|---|---|---|
소요 시간 (ms) | 13ms | 6ms | 170ms | 13,798ms |
이번에도 테스트에 로그 출력에 따른 병목현상이 생기지 않도록 쿼리 생성에 따른 로그는 모두 제거하고 수행했습니다.
소모 시간을 확인해 보면 Hibernate를 사용했을 때의 차원이 다른 처리 속도를 확인할 수 있습니다. 1,000,000개의 저장에서도 30초의 소요시간도 발생하지 않았습니다.
위의 테스트는 batch 크기를 전체 데이터 개수로 지정하여 하나의 쿼리로 모든 저장이 수행이 되도록 했습니다.
그럼 정말로 하나의 쿼리가 발생하는지 확인해 보겠습니다.
hibernate를 사용하지 않기 때문에 hibernate의 show-sql 옵션으로는 쿼리 발생을 확인할 수 없습니다.
따라서 MySQL에서 반환하는 로그를 사용해야 합니다. 앞서 Hibernate의 batch 부분에서 수행했던 로그 분석의 datasource url의 쿼리 파라미터를 사용해줘야 합니다. 해당 옵션을 적용하고 난 후 위의 테스트 코드를 다시 수행해 보면 아래와 같습니다.
엔티티가 1개일 경우 당연히 1개의 쿼리가 발생합니다.
마찬가지로 100개, 10,000개, 1,000,000개의 INSERT 쿼리가 하나로 합쳐져 전송되고 있음을 확인할 수 있습니다.
batch 크기에 의한 속도 차이
그럼 다음으로 batch 크기에 의한 속도의 차이가 존재하는지 알아보겠습니다.
for 문 기반 배치 단위 저장 수행
batch 크기에 따라 소요 시간을 체크해 보기 위해 기존에 사용하던 entityList를 배치 크기 단위로 잘라서 for문으로 수행해 봤습니다.
수정된 테스트 코드는 다음과 같습니다.
public void saveJdbcTemplateTestWithBatch(int batch_size, int entityCnt) throws InterruptedException {
// given
int blocks = entityCnt / batch_size;
List<List<SimpleEntity>> entityList = new ArrayList<>(blocks);
for (int i = 0; i < blocks; i++) {
entityList.add(new ArrayList<>(batch_size));
for (int j = 0; j <batch_size; j++) {
entityList.get(i).add(SimpleEntity.builder()
.simpleData(j)
.build()
);
}
}
// when
long startTime = System.currentTimeMillis();
for(int b = 0; b<blocks; b++) {
int finalB = b;
int[] impactedRows = jdbcTemplate.batchUpdate(
"INSERT INTO simple_entity (simple_data, created_at) VALUES (?, NOW())",
new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
ps.setInt(1, entityList.get(finalB).get(i).getSimpleData());
}
@Override
public int getBatchSize() {
return batch_size;
}
});
}
// then
System.out.println(String.format("Time ---- %d blocks ---- %d batch ---- %d entities", blocks, batch_size, entityCnt));
System.out.println((System.currentTimeMillis() - startTime) + "ms");
}
@Order(9)
@TestFactory
public Collection<DynamicTest> saveJdbcTemplateTestesWithBatch() {
return List.of(
DynamicTest.dynamicTest("배치 크기 1,000,000 - 엔티티 1,000,000개", () -> {
saveJdbcTemplateTestWithBatch(1_000_000, 1_000_000);
}),
DynamicTest.dynamicTest("배치 크기 100,000 - 엔티티 1,000,000개", () -> {
saveJdbcTemplateTestWithBatch(100_000, 1_000_000);
}),
DynamicTest.dynamicTest("배치 크기 10,000 - 엔티티 1,000,000개", () -> {
saveJdbcTemplateTestWithBatch(10_000, 1_000_000);
}),
DynamicTest.dynamicTest("배치 크기 1,000 - 엔티티 1,000,000개", () -> {
saveJdbcTemplateTestWithBatch(1_000, 1_000_000);
}),
DynamicTest.dynamicTest("배치 크기 100 - 엔티티 1,000,000개", () -> {
saveJdbcTemplateTestWithBatch(100, 1_000_000);
})
);
}
위의 테스트 코드로 테스트 한 결과 아래와 같은 소요시간을 얻을 수 있었습니다.
배치 크기 [for문 반복 횟수] | 1,000,000 [1] | 100,000 [10] | 10,000 [100] | 1,000 [1,000] | 100 [10,000] |
---|---|---|---|---|---|
소요 시간 | 11,761ms | 6,042ms | 5,747ms | 7,477ms | 24,542ms |
이번에도 테스트에 로그 출력에 따른 병목현상이 생기지 않도록 쿼리 생성에 따른 로그는 모두 제거하고 수행했습니다.
이번 테스트 결과는 너무나도 의외의 결과를 얻을 수 있었습니다. 분명히 for문으로 blocking 작업을 하고 있지만, 배치 크기가 10,000개, 배치 개수가 100개 일 때 가장 성능이 좋았습니다.
이는 데이터베이스와의 통신과 데이터 처리 과정 외의 메모리 사용이나 데이터베이스로의 쿼리 패킷 크기, 네트워크 상태 등의 복합적인 이유가 있을 것이라 유추할 수 있었습니다.
멀티 스레드 기반 배치 단위 저장 수행
for문으로 배치를 나누어 쿼리 해봤으므로 멀티 스레드 기반으로도 테스트 코드로 개선하여 테스트해보겠습니다.
멀티 스레드 기반의 테스트 코드는 아래와 같이 작성할 수 있습니다.
public void saveJdbcTemplateTestWithBatch(int batch_size, int entityCnt) throws InterruptedException {
// given
int numberOfThreads = entityCnt / batch_size;
List<List<SimpleEntity>> entityList = new ArrayList<>(numberOfThreads);
for (int i = 0; i < numberOfThreads; i++) {
entityList.add(new ArrayList<>(batch_size));
for (int j = 0; j <batch_size; j++) {
entityList.get(i).add(SimpleEntity.builder()
.simpleData(j)
.build()
);
}
}
// when
long startTime = System.currentTimeMillis();
ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
AtomicInteger impactedRowCnt = new AtomicInteger(0);
// when
for (int thread = 0; thread < numberOfThreads; thread++) {
int finalThread = thread;
executorService.submit(() -> {
try {
int[] impactedRows = jdbcTemplate.batchUpdate(
"INSERT INTO simple_entity (simple_data, created_at) VALUES (?, NOW())",
new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
ps.setInt(1, entityList.get(finalThread).get(i).getSimpleData());
}
@Override
public int getBatchSize() {
return batch_size;
}
});
impactedRowCnt.addAndGet(impactedRows.length);
} finally {
latch.countDown();
}
});
}
latch.await();
// then
System.out.println(String.format("Time ---- %d threads ---- %d batch ---- %d entities", numberOfThreads, batch_size, entityCnt));
System.out.println((System.currentTimeMillis() - startTime) + "ms");
}
@Order(10)
@TestFactory
public Collection<DynamicTest> saveJdbcTemplateTestesWithBatch() {
return List.of(
DynamicTest.dynamicTest("배치 크기 1,000,000 - 엔티티 1,000,000개", () -> {
saveJdbcTemplateTestWithBatch(1_000_000, 1_000_000);
}),
DynamicTest.dynamicTest("배치 크기 100,000 - 엔티티 1,000,000개", () -> {
saveJdbcTemplateTestWithBatch(100_000, 1_000_000);
}),
DynamicTest.dynamicTest("배치 크기 10,000 - 엔티티 1,000,000개", () -> {
saveJdbcTemplateTestWithBatch(10_000, 1_000_000);
}),
DynamicTest.dynamicTest("배치 크기 1,000 - 엔티티 1,000,000개", () -> {
saveJdbcTemplateTestWithBatch(1_000, 1_000_000);
}),
DynamicTest.dynamicTest("배치 크기 100 - 엔티티 1,000,000개", () -> {
saveJdbcTemplateTestWithBatch(100, 1_000_000);
})
);
}
테스트 결과는 다음과 같습니다.
배치 크기 [스레드 개수] | 1,000,000 [1] | 100,000 [10] | 10,000 [100] | 1,000 [1,000] | 100 [10,000] |
---|---|---|---|---|---|
소요 시간 | 11,699ms | 4,303ms | 3,060ms | 3,675ms | Error |
이번에도 테스트에 로그 출력에 따른 병목현상이 생기지 않도록 쿼리 생성에 따른 로그는 모두 제거하고 수행했습니다.
멀티 스레드인 만큼 배치 크기가 작아지고 스레드 개수가 늘어날수록 저장의 소요시간이 줄어들다가 HikariPool의 connection pool로 설정해 둔 데이터베이스 측의 최대 connection 개수인 100개를 스레드 개수가 넘어가며 더 이상 병목이 생기는 것을 확인할 수 있습니다.
결국에는 스레드 개수가 10,000개로 테스트를 했을 때 다음과 같은 에러를 내보내며 데이터베이스와의 connection에 문제가 생기게 되었습니다.
org.springframework.dao.DataAccessResourceFailureException: PreparedStatementCallback; SQL [INSERT INTO simple_entity (simple_data, created_at) VALUES (?, NOW())]; Communications link failure
The last packet successfully received from the server was 90 milliseconds ago. The last packet sent successfully to the server was 90 milliseconds ago.
...
Caused by: java.sql.BatchUpdateException: Communications link failure
The last packet successfully received from the server was 90 milliseconds ago. The last packet sent successfully to the server was 90 milliseconds ago.
...
Caused by: com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure
The last packet successfully received from the server was 90 milliseconds ago. The last packet sent successfully to the server was 90 milliseconds ago.
...
Caused by: com.mysql.cj.exceptions.CJCommunicationsException: Communications link failure
The last packet successfully received from the server was 90 milliseconds ago. The last packet sent successfully to the server was 90 milliseconds ago.
...
Caused by: java.net.SocketException: Socket closed
저는 데이터베이스와의 Connection이 부족한 것이라 생각하여 다음 두 가지 설정을 추가적으로 해줬습니다.
hikariPool의 connection 크기 키우기
먼저 데이터베이스와의 connection을 확보하지 못해 병목이 생기지 않도록 HikariPool의 idle connection을 늘려준 후 batch 크기를 늘려주겠습니다. HikariPool의 connection pool을 늘려주기 위해서는 아래와 같이 application.yml을 추가해 주면 됩니다.
또한 현재 테스트 중인 테스트 코드는 시간이 꽤 소요되는 테스트이기 때문에 connection이 살아있도록 connection의 유휴 시간도 설정해 주었습니다.
spring:
datasource:
hikari:
maximum-pool-size: 200
keepalive-time: 600000 # 10분
max-lifetime: 660000 # 11분
minimum-idle: 200
데이터베이스 max connection 크기 확인
데이터베이스 측에서 사용 가능한 connection은 100개로 초기화되어있는 경우가 있습니다.
만약에 테스트 결과가 이상하다면, 데이터베이스로의 connection이 모두 제대로 connection을 가지고 있는지 체크해 볼 필요가 있습니다.
우선 해당 데이터베이스가 현재 어느 정도의 connection을 연결하고 사용하고 있는지 체크해 봅니다. 아래 명령어들은 모두 mysql 기반입니다.
show status like 'threads_connected';
다음으로 현재 데이터베이스에 설정된 최대 connection 개수를 확인합니다.
show variables like 'max_connections';
위의 두 값을 기반으로 새롭게 설정할 max connections를 설정해 줍니다.
set global max_connections=200;
Connection을 개선한 후 테스트 수행
우선 HikariPool의 상태 변화를 체크하기 위해서 HikariPool의 로그가 찍히도록 설정해 줍니다.
logging:
level:
com:
zaxxer:
hikari: debug
정상적으로 동작하는지 확인하기 위해 기존에도 정상적으로 동작하던 테스트인 for문 기반 테스트를 수행해 줍니다.
정상적으로 동작하며 HikariPool에 의한 connection 또한 200개로 잘 가지고 있는 모습을 확인할 수 있습니다.
for문 기반의 테스트는 당연히 블로킹 방식이므로 connection pool에서 하나의 connection만 사용하고 있는 것도 보입니다.
다음으로 문제가 생겼던 멀티스레드 기반 테스트를 확인해 보겠습니다.
1000개의 스레드를 사용하자마자 active connection이 꽉 차고 waiting 하는 스레드들이 보입니다.
결과적으로 connection 개수를 수정하기 전과 동일한 오류가 발생했습니다.
데이터베이스의 CPU 과부하
connection을 늘리는 방식으로는 원인을 찾지 못했습니다.
다시 다른 이유를 찾기 위해 데이터베이스를 띄운 도커의 성능지표를 찾아봤습니다.
for문 기반 테스트와 비교해서 멀티스레드 기반 테스트를 할 때마다 CPU 사용량의 스파이크 발생하는 그래프를 확인할 수 있었습니다.
따라서 계속해서 발생하던 오류는 데이터베이스의 CPU 자원이 충분하지 않아 발생한 문제로 여겨집니다.
결과
jpa의 save(), save() + @Transactional, saveAll()과 비교했을 때 확실하게 비약적인 저장 소요 시간 개선이 되었습니다.
엔티티 개수 (배치 1개) | 1개 | 100개 | 10,000개 | 1,000,000개 |
---|---|---|---|---|
소요 시간 (ms) | 13ms | 6ms | 170ms | 13,798ms |
하지만 DML을 전체를 한 번에 하지 않고 나누어서 할 경우 의외의 양상을 확인할 수 있었습니다.
배치 크기 [for문 반복 횟수] | 1,000,000 [1] | 100,000 [10] | 10,000 [100] | 1,000 [1,000] | 100 [10,000] |
---|---|---|---|---|---|
소요 시간 | 11,761ms | 6,042ms | 5,747ms | 7,477ms | 24,542ms |
배치 크기 [스레드 개수] | 1,000,000 [1] | 100,000 [10] | 10,000 [100] | 1,000 [1,000] | 100 [10,000] |
---|---|---|---|---|---|
소요 시간 | 11,699ms | 4,303ms | 3,060ms | 3,675ms | Error |
for문 기반으로 수행했을 경우에는 어떤 이유에서인지 100만 개의 데이터를 배치 크기를 1만개로 100번 나누어 DML 을 발생시킬 때 최적의 시간을 얻어낼수 있었습니다.
하지만 멀티스레드 기반으로 100만개의 데이터를 나누어 수행할 경우는 10번으로 나누어 수행할 때부터 모두 비슷하게 5초 이내로 처리가 완료되는 것을 확인할 수 있었으며, 배치 크기를 과도하게 작게 잡아 작업하는 스레드 수가 많아질 경우 데이터베이스에도 데이터 처리에 의한 과부하가 생겨 데이터 저장에 실패하는 것도 확인할 수 있었습니다.
결과
simple JPA | 1개 | 100개 | 10,000개 | 1,000,000개 |
---|---|---|---|---|
save() | 42ms | 496ms | 23,360ms | 2,385,574ms |
save() + @Transactional | 38ms | 159ms | 4,333ms | 241,256ms |
saveAll() | 83ms | 128ms | 5,121ms | 285,483ms |
JPA + batch size | 1개 | 100개 | 10,000개 | 1,000,000개 |
---|---|---|---|---|
save() | 57ms | 384ms | 24,495ms | 2,352,363ms |
save() + @Transactional | 6ms | 41ms | 1,953ms | 118,109ms |
saveAll() | 15ms | 25ms | 1,313ms | 137,020ms |
일반적으로 사용하는 jpa와 jpa에서 지원하는 batch를 적용한 결과를 비교하여 비율로 나타내보면 다음 표와 같습니다.
(simple JPA) / (JPA + batch size) | 1개 | 100개 | 10,000개 | 1,000,000개 |
---|---|---|---|---|
save() | 135.71% | 77.42% | 104.86% | 98.61% |
save() + @Transactional | 15.79% | 25.79% | 45.07% | 48.96% |
saveAll() | 18.07% | 19.53% | 25.64% | 48.00% |
JPA batch를 사용하여 일반적인 JPA 사용 시에 비해 어느 정도 성능 개선을 할 수 있었지만, 100만 건 기준 2분의 소요 시간이 발생하며 대용량의 데이터를 다룰 경우엔 적합하지 않은 성능을 확인할 수 있었습니다. 또한 엔티티의 Id 생성 방식 중 가장 많이 사용되는 방식인 IDENTITY 방식을 사용하지 못하고 TABLE 방식을 사용해야 한다는 점에서 아쉬운 부분이 존재했습니다.
다음으로 JPA를 사용하지 않고 JdbcTemplate을 사용하여 대용량의 batch DML을 구현해 봤습니다.
유형 (batch 개수) | 1개 | 100개 | 10,000개 | 1,000,000개 |
---|---|---|---|---|
전체 한꺼번에 (1) | 13ms | 6ms | 170ms | 13,798ms |
for (1) | - | - | - | 11,761ms |
for (10) | - | - | - | 6,042ms |
for (100) | - | - | - | 5,747ms |
for (1,000) | - | - | - | 7,477ms |
for (10,000) | - | - | - | 24,542ms |
multi thread (1) | - | - | - | 11,699ms |
multi thread (10) | - | - | - | 4,303ms |
multi thread (100) | - | - | - | 3,060ms |
multi thread (1,000) | - | - | - | 3,675ms |
multi thread (10,000) | - | - | - | Error |
그 결과 위의 표와 같이 JPA와 비교도 안될 정도의 성능을 가질 수 있었습니다. 100만 건 기준 최소 3초의 성능을 가지며 JPA를 사용했을 때와 비교했을 때 39배~780배의 성능 개선을 확인하였습니다.
이 결과를 통해 대용량의 DML에서는 Jpa를 사용하지 않고 JdbcTemplate를 사용하는 것이 필수적임을 알 수 있습니다.
고찰
이번 포스팅을 통해 JPA와 Jdbc, Hibernate, HikariPool에 대해서 더 자세히 알게 되며 배운 것이 많은 경험이 되었습니다.
포스팅을 작성하기 전에는 save 메서드가 정확히 어떤 방식으로 동작하는지 알지 못했지만, 구현체를 직접 확인해 보며 save, saveAll 메서드도 영속성 컨텍스트를 활용하고 entity manager에 의해 insert 쿼리를 발생시킴을 알 수 있었습니다. 또한 호출 함수에서 Transactional의 사용을 테스트하면 영속성 컨텍스트의 생성과 소멸에 어느 정도 오버헤드가 발생한다는 것을 알게 되었습니다.
Jpa의 batch를 구현하기 위해 Identity 방식이 아닌 table 형식의 id Generate를 구현해 보는 경험도 했습니다. Postgesql의 sequence와 비슷한 듯 보였지만, 다른 엔티티들과 동일하게 테이블로 관리가 되며 next_val을 조회한 후 수정하는 방식으로 작동하며, 직접 수정이 가능하다는 점에서 race condition 이 발생할 가능성이 커 보여 좋지 않은 방식이라 여겨졌습니다.
마지막으로 JdbcTemplate으로 batch를 구현하는 중에 에러 상황의 원인을 찾으려 노력하며 서버와 데이터베이스 간의 연관성을 더 자세히 알게 되었고, 데이터베이스의 CPU 자원 고갈의 가능성도 존재한다는 사실을 다시 한번 인지하게 되었습니다.
처음 포스팅을 시작할 때엔 다른 사람들이 이미 많이 사용하고 있고 관련 포스팅도 무수히 많아서 어렵지 않은 개념일 것이라 생각했지만, 테스트를 하며 포스팅을 적으며 JPA와 데이터베이스에 대해 더 자세히 알게 되어 뿌듯한 경험이 되었습니다.
이번 경험으로 개발자의 편의성이 좋은 JPA가 항상 정답은 아니라는 것을 깨닫게 되었고, 프로젝트에서 성능상의 개선이 필요한 경우 JdbcTemplate을 통해 개선을 일차적으로 고려해 봐야겠다 결심하게 되었습니다.
참고 자료
JPA Batch Insert API 성능 개선기 (GenerationType.IDENTITY의 한계점)
Spring Data JPA Save(insert) 속도 최적화
3.4. Optional configuration properties