JPA의 SaveAll()은 벌크 연산일까?
Spring Data JPA는 기본적으로 saveAll() 메서드를 제공한다. 이름에 붙은 all() 때문에 흔히 한 번의 쿼리로 여러 건을 삽입하는 벌크 연산으로 오해하는 경우가 많지만, 실제로는 그렇지 않다.
실제 로그를 확인해 보면, saveAll()을 통해 여러 건의 데이터를 저장할 때 엔티티 개수만큼 insert 쿼리가 발생한다. 즉, 단일 쿼리로 묶어서 처리하지 않고, 컬렉션을 순회하며 엔티티마다 개별 쿼리를 실행하는 것이다.
단건 쿼리의 반복이 가지는 문제점
이처럼 여러 개의 데이터를 각각 insert로 실행하면 네트워크 왕복 횟수가 데이터 개수만큼 증가하게 된다. 이 과정에서 불필요한 통신 비용이 발생하고, 대량 데이터를 다룰 경우 응답 속도가 느려지는 문제가 생긴다.
save()와 saveAll()의 차이
save() 메서드는 단건 저장용 메서드이다. 즉, 말그대로 엔티티 하나를 저장할 때 사용되며, 호출 시마다 단일 쿼리를 발생시킨다. 반면 saveAll()은 내부적으로 단순히 반복문을 돌면서 save()를 호출한다. 즉, 여러 개의 엔티티를 한 번에 받아서, 각각데에 대해 save() 메서드를 실행하는 구조이다. 따라서 쿼리 실행 결과만 놓고 본다면, save() 메서드 역시 saveAll()과 마찬가지로 동일하게 단건쿼리가 반복되는 것을 확인할 수 있다. 하지만 왜 saveAll()의 실행속도가 save()의 실행 속도보다 훨씬 빠를까?
정답은 트랜잭션 처리 방식에 있다!
JPA의 구현체인 SimpleJpaRepository에서 그 답을 찾을 수 있다. 먼저 save() 메서드와 saveAll() 메서드에 대한 구현은 아래와 같다.
@Transactional 어노테이션이 적용되면, 메서드는 하나의 트랜잭션 안에서 실행된다. JPA의 기본 전파 속성은 REQUIRED이므로, saveAll() 안에서 반복적으로 save()를 호출하더라도 새로운 트랜잭션이 매번 생성되는 것이 아니라, 처음 시작된 트랜잭션에 합류한다.
반면, 서비스 레이어에서 별도의 트랜잭션을 열지 않고 save()를 N번 호출하면, 호출할 때마다 새로운 트랜잭션이 생성되고 종료되는 과정을 반복한다. 이 경우 트랜잭션 생성과 커밋/롤백에 따른 오버헤드가 성능 차이를 만든다.
즉, saveAll()은 내부적으로 반복문을 쓰지만 하나의 트랜잭션 안에서 모든 insert가 처리되기 때문에, save()를 반복 호출할 때보다 빠른 것이다.
SimpleJpaRepository는 클래스 수준에 @Transactional을 단다. 즉, 외부 트랜잭션이 없을 때만 저장 메서드가 자기 트랜잭션을 시작하는 구조이다. 때문에 현업에서는 Service 레이어에 @Transactional을 붙이는 것이 일반적이기 때문에, 이 경우 save()와 saveAll()의 차이는 거의 사라진다.
단건의 쿼리로 배치 처리는 어떻게 할까?
서비스를 운영하다 보면 대량의 데이터를 한 번에 저장해야 하는 경우가 자주 발생한다. 이때 단순히 반복문을 돌면서 insert 쿼리를 하나씩 실행하는 방식은 성능상 큰 문제가 될 수 있다. 그렇다면 단건의 쿼리로 배치 처리를 할 수 있는 방법에는 무엇이 있을까?
배치 처리를 위한 여러 접근 방식
가장 먼저 생각할 수 있는 방법은 Hibernate의 배치 기능을 사용하는 것이다. JDBC 드라이버 수준에서 제공하는 배치 옵션을 활용해 여러 개의 insert 문을 모아 한 번에 DB로 전달하는 방식이다. 이를 통해 네트워크 왕복 비용을 줄일 수 있지만, 여전히 영속성 컨텍스트를 거치게 되므로 완전히 가볍다고 보기는 어렵다.
그 다음으로 고려할 수 있는 방법이 바로 JdbcTemplate을 사용하는 것이다. JdbcTemplate은 스프링이 제공하는 JDBC 추상화 유틸리티로, SQL을 직접 작성하고 실행하는 방식이다. 영속성 컨텍스트가 개입하지 않기 때문에 오버헤드가 적고, 성능적으로 가장 단순하고 빠른 접근 방식이라고 할 수 있다.
왜 JdbcTemplate을 선택했을까?
이번 케이스에서 중요한 것은 엔티티를 관리하는 것이 아니라 대량 데이터를 빠르고 안정적으로 DB에 저장하는 것이었다. 다시 말해, 영속성 컨텍스트를 통한 객체 관리나 캐싱 같은 JPA의 장점은 필요하지 않았고, 오히려 성능에 불필요한 비용을 추가할 수 있었다.
따라서 단순히 네이티브 SQL을 실행해 배치 삽입을 처리할 수 있는 JdbcTemplate을 선택했다. JdbcTemplate을 활용하면 내가 작성한 SQL이 그대로 DB에 전달되며, JPA처럼 JPQL을 SQL로 변환하는 과정이 없기 때문에 성능적으로 유리하다. 또한, JDBC의 batchUpdate 기능을 제공하므로 대량의 데이터를 묶어서 한 번에 DB에 저장할 수 있어 효율적이다.
JdbcTemplate이란?
JdbcTemplate은 스프링에서 제공하는 JDBC 추상화 클래스다. 내부적으로는 순수 JDBC API(Connection, PreparedStatement, ResultSet)를 사용하지만, 반복되는 예외 처리나 리소스 정리 코드를 대신 처리해준다. 우리가 직접 작성하는 것은 순수한 SQL이며, 그 SQL이 변환 없이 그대로 데이터베이스에 실행된다.
즉, JPA가 제공하는 객체지향 쿼리 언어인 JPQL과 달리, JdbcTemplate은 네이티브 SQL 기반의 단순하고 직관적인 접근 방식을 제공한다. 이런 특징 덕분에 대량 데이터를 다루는 배치 작업에서 불필요한 오버헤드를 최소화할 수 있다.
개선해보자!
실제로 saveAll() 메서드를 통해 다량의 데이터를 삽입했을 때, 실행 시간을 측정해보면 그 시간이 무려 1000ms가 넘게 측정되었다.
이 코드를 JDBC Template를 통해 단건의 Bulk Insert로 리팩토링 한 결과, 실행시간이 60ms까지 최적화 된 것을 확인할 수 있었다.
'JPA' 카테고리의 다른 글
[JPA] 실전! 스프링 데이터 JPA - 섹션 6~8 (0) | 2025.03.17 |
---|---|
[JPA] 실전! 스프링 데이터 JPA - 섹션 1~5 (0) | 2025.03.17 |
[JPA] 실전! 스프링 부트와 JPA 활용 2 - 섹션 5~6 (0) | 2025.03.17 |
[JPA] 실전! 스프링 부트와 JPA 활용 2 - 섹션 1~4 (0) | 2025.03.17 |
[JPA] 실전! 스프링 부트와 JPA 활용1 - 섹션 3~7 (0) | 2025.03.17 |