진행 중인 토이 프로젝트에서 약 2만 건 이상의 데이터를 한 번에 RDB에 저장하기 위해 JPA의 saveAll() 메서드를 사용했습니다.
처음에는 saveAll()이 INSERT 쿼리를 모아서 한 번에 전송할 것이라 기대했으나, 실제로 쿼리를 확인해보니 save()와 마찬가지로 각각의 INSERT 쿼리가 하나씩 전송되는 것을 확인했습니다.
그렇다면 save()와 saveAll()의 차이는 무엇일까요? 그리고 INSERT를 한 번에 처리하는 방법은 무엇인지 알아보겠습니다.
먼저 save와 saveAll의 차이를 보겠습니다.
@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 (S)this.entityManager.merge(entity);
}
}
먼저 자주 사용하는 save()를 살펴보면 하나의 트랜잭션에서 관리가 되고 있으며, 매개변수로 주어진 entnty가 새 entity의 경우 persist()를 통해 DB에 저장하며 기존에 존재하는 entity의 경우 merge()를 통해 변경된 점을 DB에 병합합니다.
@Transactional
public <S extends T> List<S> saveAll(Iterable<S> entities) {
Assert.notNull(entities, "Entities must not be null");
List<S> result = new ArrayList();
for(S entity : entities) {
result.add(this.save(entity));
}
return result;
}
saveAll() 메서드를 살펴보면, 이 메서드 역시 하나의 트랜잭션에서 관리됩니다. 내부적으로는 for 문을 통해 save() 메서드를 반복 호출하게 됩니다. 그렇다면 save()와의 차이점은 무엇일까요?
가장 큰 차이는 트랜잭션 관리입니다. saveAll()에서는 트랜잭션이 메서드 레벨에 위치하여 작업 수행 동안 동일한 트랜잭션 내에서 처리됩니다. 따라서 for 문을 통해 save()를 직접 반복 호출하는 것보다 더 효율적일 수 있습니다. 즉, saveAll()을 사용할 경우 트랜잭션이 한 번만 생성됩니다.
save()와 saveAll()의 각 성능차이를 알아보겠습니다.
@SpringBootTest
public class SaveTest {
@Autowired
TestEntityRepository testEntityRepository;
@Test
@Transactional
@Rollback(value = false)
@DisplayName("save 테스트")
void saveTest() {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
TestEntity testEntity = TestEntity.builder().name("테스트" + i)
.build();
testEntityRepository.save(testEntity);
}
long endTime = System.currentTimeMillis();
System.out.println("execution time : " + (endTime - startTime) + "ms"); // 4268ms
}
@Test
@Transactional
@Rollback(value = false)
@DisplayName("save all 테스트")
void saveAllTest() {
long startTime = System.currentTimeMillis();
List<TestEntity> testlist = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
TestEntity testEntity = TestEntity.builder().name("테스트" + i)
.build();
testlist.add(testEntity);
}
testEntityRepository.saveAll(testlist);
long endTime = System.currentTimeMillis();
System.out.println("execution time : " + (endTime - startTime) + "ms"); // 3664ms
}
}
각각 1만 건의 TestEntity를 생성하는 반복문을 실행하면서, 생성된 엔티티를 저장하는 두 가지 방법을 비교했습니다. 첫 번째 방법은 save() 메서드를 사용하여 개별적으로 저장하는 것이고, 두 번째 방법은 List 타입의 testList에 엔티티를 담아 saveAll() 메서드를 통해 한 번에 저장하는 것입니다.
실행 결과는 다음과 같습니다:
결과적으로, saveAll() 메서드가 더 나은 성능을 보이는 것으로 확인되었습니다.
하지만 이 결과는 트랜잭션 관리의 차이에서 나오는것이지 결국 쿼리가 하나씩 나가는것은 동일했습니다. 그렇다면 JPA에서는 어떻게 대용량 데이터를 한번에 insert하는지 알아 보겠습니다.
INSERT INTO post (title) VALUES
('title1'),
('title2'),
('title3'),
('title4');
MySQL8버전 미만
jdbc:mysql://localhost:3306/{db}rewriteBatchedStatements=true
MySQL8 이상부터는 rewriteBatchedStatements=true 옵션의 default = treu로 설정되어 별도의 설정이 없어도 Bulk Insert이 적용됩니다.
하지만 id 채번이 DB에 의해서 정해지는 auto_increment(IDENTITY)인 경우 JPA를 통한 save는 Bulk Insert가 적용되지 않습니다.
그 이유는 아래와 같습니다.
이러한 상황에서의 대처 방법들은 ID 채번 전략 수정 또는 JPA가 아닌 JdbcTemplate와 같은 네이티브 쿼리를 작성하는 방법, QueryDSL, JOOQ 등이 있습니다.
저는 jdbcTemplate를 이용해 Bulk Insert를 구현하기로 했습니다.
datasource:
url: jdbc:mysql://localhost:3306/{db}?profile=true&logger=Slf4JLogger&maxQuerySizeToLog=1000
@Repository
@RequiredArgsConstructor
public class TestEntityJdbcRepository {
private final JdbcTemplate jdbcTemplate;
public void bulkInsert(List<TestEntity> entities) {
jdbcTemplate.batchUpdate("INSERT INTO test_entity(name) VALUES (?)",
new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
ps.setString(1, entities.get(i).getName());
}
@Override
public int getBatchSize() {
return entities.size();
}
});
}
}
@Test
@Transactional
@Rollback(value = false)
@DisplayName("bulk insert 테스트")
void bulkInsertTest() {
long startTime = System.currentTimeMillis();
List<TestEntity> testlist = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
TestEntity testEntity = TestEntity.builder().name("테스트" + i)
.build();
testlist.add(testEntity);
}
jdbcTestEntityRepository.bulkInsert(testlist);
long endTime = System.currentTimeMillis();
System.out.println("execution time : " + (endTime - startTime) + "ms"); //93ms
}
}
10,000개의 데이터를 넣는 테스트 환경에서의 결과 입니다.
save()와 batchUpdate()사이에 약 46배의 성능차이가 발생하는것이 확인되었습니다.