🚩 1. 들어가며...
Spring Data JPA를 사용하는 환경에서 여러 개의 데이터 삽입을 위해 saveAll() 메소드를 실행시키면, N개의 데이터마다 N개의 Insert쿼리를 날린다. 이렇게 되면 데이터베이스의 성능이 매우 떨어지게 되는데, 이런 문제를 해결하기 위해 Bulk Insert를 구현하게 되는데 이 과정에서 발생한 문제와 해결 방법을 포스팅한다.
🚩 2. 문제 상황: N개의 데이터 만큼 N개의 Insert Query를 날리는 saveAll()
여러 개의 데이터를 저장할 때 사용하는 saveAll() 메소드는 마치 Bulk(Multi Row) Insert로 동작할 것 같지만, 사실은 그렇지 않다. 만약 하나의 상품에 여러 개의 이미지를 저장한다고 할 때 saveAll() 메소드는 이미지 데이터 하나 당 하나의 Insert 쿼리를 충실하게 날리고 있다.
이렇게 저장되는 데이터마다 쿼리를 날리게 되면 데이터의 양이 많아질수록 많은 수의 데이터베이스 연산을 발생시켜, 수행 시간이 늘어나고 데이터베이스 성능 저하를 일으킬 수 있다.
🚩 3. 이유: 결국 순회하며 save()
데이터마다 Insert 쿼리를 날리는 이유는 사실, saveAll()의 내부구조를 보면 알 수 있다.
saveAll() 메소드의 인자값으로 받은 엔티티 리스트를 순회하며, 리스트의 각 엔티티에 대하여 Spring Data JPA는 내부적으로 'save()' 메소드를 호출하고 있다. 이 save() 메소드는 개별 엔티티를 저장하는 로직을 담고 있고, 반복적인 메소드 호출에 따라 당연히 N개의 Insert 문이 실행되었다.
🚩 4. 해결방법 - JPA Batch 기능과 JdbcTemplate
📌 4-1. 첫 번째 후보 JPA Batch 기능 ❌
이 방법은 JPA의 장점을 유지하면서 여러 개의 데이터베이스 작업을 모아 한 번에 처리하여 대량의 삽입을 최적화할 수 있다. 하지만 이 기능을 사용하기 위해서는 ID 채번 방식 @GeneratedType을 SEQUNECE나 TABLE 전략을 사용하여야 한다. 왜냐면
- IDENTITY 전략은 데이터베이스에 행이 실제로 삽입된 후에 ID를 반환받기 때문에 삽입마다 즉시 데이터베이스에 반영되기를 기대한다.
- 하지만 JPA Batch의 쓰기지연(Write Behind) 전략은 여러 데이터베이스 작업을 모아두었다가 가능한 한 늦게까지 연기하여 트랜잭션 커밋 시에 한 번에 데이터베이스에 전송한다.
- 따라서 IDENTITY 전략과 JPA의 쓰기지연 전략은 서로 상충되므로 JPA에서는 IDENTITY 전략에서 JPA Batch 기능을 지원하지 않는다.
하지만 MySQL에서는 SEQUNCE 기능을 제공하지 않고 TABLE 전략은 별도의 ID를 저장하는 TABLE이 존재해야 하는데 이 전략은 성능 떨어지고 테이블 구조를 변경해야 한다는 부담이 있었다.
📌 4-2. 두 번째 후보 NamedParameterJdbcTemplate ⭕
이 방법은 Spring이 제공하는 기존의 JdbcTemplate의 기능에 이름 기반의 파라미터 바인딩을 추가한 확장 클래스 이다. 이를 통해 JDBC를 좀 더 쉽고 가독성있게 데이터베이스 작업을 수행한다.
- 데이터베이스와 직접적인 상호작용을 하기 때문에 처리 속도가 빠르다.
- JdbcTemplate은 Batch 처리를 지원하며, IDENTITY 전략과 같은 JPA의 Batch 제약 사항에 구애받지 않는다.
- 기존의 JdbcTemplate에서 위치와 '?'로 표현되었던 바인딩에서 이름 기반의 명확한 파라미터 관리로 가독성 있는 JDBC사용을 할 수 있다.
SQL을 직접 작성하고 추가적인 코드를 작성해야하는 부담을 감수해야하지만, 데이터베이스와 직접 상호 작용하기 때문 수행 속도 자체가 빠르고 무엇보다 현재 환경에서 Bulk Insert를 확실하게 구현할 수 있는 방법이기에 해당 방법으로 구현하기로 했다.
🚩 5. 실제 코드 및 테스트 결과
📌 5-1. Bulk Insert를 위한 JDBC 코드
@Repository
@RequiredArgsConstructor
public class AdminProductJdbcRepository {
private final NamedParameterJdbcTemplate jdbcTemplate;
public void bulkInsertImages(List<Image> images) {
String sql = "INSERT INTO image (product_no, server_name, origin_name, file_size) VALUES (:productNo, :serverName, :originName, :fileSize)";
jdbcTemplate.batchUpdate(sql, images.stream()
.map(image -> new MapSqlParameterSource()
.addValue("productNo", image.getProduct().getNo())
.addValue("serverName", image.getServerName())
.addValue("originName", image.getOriginName())
.addValue("fileSize", image.getFileSize()))
.toArray(SqlParameterSource[]::new));
}
}
이렇게 여러 개의 Image 데이터를 Bulk Insert로 구현하기 위한 클래스를 만들고, 실제로 상품을 생성할 때 상품의 이미지를 삽입할 때 부분적으로 사용하였다.
📌 5-2. 테스트 코드 및 테스트 결과
@SpringBootTest
class AdminProductJdbcRepositoryTest {
@Autowired
private ImageRepository imageRepository;
@Autowired
private AdminProductJdbcRepository adminProductJdbcRepository;
@Test
@Transactional
@DisplayName("JDBC Bulk Insert 성능 테스트")
void testJdbcPerformance() {
long beforeMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
List<Image> images = prepareTestData(10000);
// JDBC 성능 테스트
StopWatch jdbcStopWatch = new StopWatch();
jdbcStopWatch.start();
adminProductJdbcRepository.bulkInsertImages(images);
jdbcStopWatch.stop();
long afterMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
System.out.println("JDBC Bulk Insert Time: " + jdbcStopWatch.getTotalTimeMillis() + "ms");
System.out.println("Memory Used: " + (afterMemory - beforeMemory) + " bytes");
}
@Test
@Transactional
@DisplayName("JPA saveAll() 성능 테스트")
void testJpaPerformance() {
long beforeMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
List<Image> images = prepareTestData(10000);
// JPA 성능 테스트
StopWatch jpaStopWatch = new StopWatch();
jpaStopWatch.start();
imageRepository.saveAll(images);
jpaStopWatch.stop();
long afterMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
System.out.println("JPA Save Time: " + jpaStopWatch.getTotalTimeMillis() + "ms");
System.out.println("Memory Used: " + (afterMemory - beforeMemory) + " bytes");
}
private List<Image> prepareTestData(int numberOfImages) {
List<Image> images = new ArrayList<>();
Product product = Product.builder()
.no(3L)
.build();
for (int i = 0; i < numberOfImages; i++) {
Image image = Image.builder()
.product(product)
.serverName("serverName" + i)
.originName("originName" + i)
.fileSize(1024L)
.build();
images.add(image);
}
return images;
}
}
데이터 1만 건을 저장하는 데 걸리는 시간을 초로 환산 하면 JPA는 50.42초, JDBC는 0.41초로이다. JDBC를 사용한 방법이 약 123배 더 빠르게 작업을 수행하였다. 이 둘의 처리 속도는 확연한 차이를 보이고 있으며, 데이터의 양이 많아질 수록 데이터베이스 성능을 크게 향상시킬 수 있을 것으로 예상할 수 있다.
🚩 6. 마치며...
데이터베이스 성능 향상을 위해 JPA와 같은 고수준의 추상화 기술과 JDBC와 같은 저수준의 세밀한 제어 기술의 조합으로 서로 보완적인 관계를 통해 프로그램의 성능을 크게 향상 시킬 수 있다는 걸 알게 되었다. 처음에는 JPA만을 사용하여 데이터베이스 작업을 수행하였고 SQL쿼리 없이도 CRUD 작업을 간단히 처리할 수 있다는 편리함에 감탄했다. 하지만 성능 최적화와 복잡한 쿼리 처리에 있어 JPA의 한계를 느끼게 되었다. 그러다 정말 초기에 배웠던 JDBC와 같이 JPA에 비하면 상대적으로 고전적인 기술을 다시금 사용하게 되었는데, 눈에 띄게 나아진 성능 포퍼먼스를 볼 수 있었다.
이는 기술의 선택이 단순히 모던함과 편리함에만 기반해야하는 것이 아니라 필요에 따라 고수준과 저수준/새기술과 옛기술 사이에서 적절한 균형을 찾아, 최적의 솔루션을 찾아야 한다는 것이다.