왜 JPA Batch Insert가 느린가?
Spring Data JPA의 saveAll()은 내부적으로 각 엔티티에 대해 개별 INSERT를 실행한다. 10,000건을 저장하면 10,000개의 INSERT 쿼리가 발생한다. 이는 네트워크 왕복(round-trip)과 트랜잭션 오버헤드로 인해 심각한 성능 저하를 유발한다.
JPA Batch Insert의 성능 문제는 크게 세 가지 원인으로 나뉜다:
- ID 생성 전략:
IDENTITY전략은 배치 INSERT를 완전히 무력화한다 - Hibernate 배치 설정 미비: 기본값은 배치가 비활성화되어 있다
- 영속성 컨텍스트 관리: 대량 엔티티가 1차 캐시에 쌓여 메모리 폭발
IDENTITY vs SEQUENCE: 배치의 핵심
@GeneratedValue(strategy = GenerationType.IDENTITY)를 사용하면 Hibernate는 절대 배치 INSERT를 수행하지 않는다. IDENTITY 전략은 INSERT 후 DB가 생성한 ID를 즉시 알아야 하므로, 각 INSERT를 개별 실행해야 하기 때문이다.
// ❌ 배치 INSERT 불가능
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
}
// ✅ 배치 INSERT 가능
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE,
generator = "order_seq")
@SequenceGenerator(name = "order_seq",
sequenceName = "order_sequence",
allocationSize = 50)
private Long id;
}
allocationSize = 50은 Hibernate가 한 번에 50개의 ID를 미리 할당받는다는 의미다. DB에 매번 시퀀스를 조회하지 않으므로 성능이 대폭 향상된다.
MySQL에서 SEQUENCE 대체: TABLE 전략
MySQL은 네이티브 SEQUENCE를 지원하지 않는다(8.0+에서도 불가). 대안으로 TABLE 전략 또는 UUID를 사용한다:
// MySQL: UUID 기반 ID (배치 INSERT 가능)
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
}
// MySQL: TABLE 전략 (성능은 SEQUENCE보다 낮음)
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.TABLE,
generator = "order_gen")
@TableGenerator(name = "order_gen",
table = "id_generator",
pkColumnValue = "order",
allocationSize = 50)
private Long id;
}
Hibernate 배치 설정
SEQUENCE/TABLE/UUID로 ID 전략을 변경한 후, Hibernate 배치 설정을 활성화한다:
# application.yml
spring:
jpa:
properties:
hibernate:
# 배치 크기 (한 번에 묶어서 전송할 INSERT 수)
jdbc.batch_size: 50
# INSERT 문을 같은 테이블끼리 그룹핑
order_inserts: true
# UPDATE 문도 그룹핑
order_updates: true
# 배치 버전 UPDATE 활성화
jdbc.batch_versioned_data: true
| 설정 | 기본값 | 설명 |
|---|---|---|
jdbc.batch_size |
0 (비활성) | 한 번에 묶는 Statement 수 |
order_inserts |
false | 같은 엔티티 INSERT를 연속 배치 |
order_updates |
false | 같은 엔티티 UPDATE를 연속 배치 |
주의: order_inserts가 false이면, 서로 다른 엔티티의 INSERT가 섞여서 배치가 깨진다. 반드시 true로 설정하라.
영속성 컨텍스트 관리: flush + clear
10만 건을 한 번에 saveAll()하면, 모든 엔티티가 1차 캐시에 쌓여 OOM이 발생할 수 있다. 주기적으로 flush + clear로 메모리를 해제해야 한다:
@Service
@RequiredArgsConstructor
public class OrderBulkService {
private final EntityManager em;
@Transactional
public void bulkInsert(List<OrderCreateDto> dtos) {
int batchSize = 50;
for (int i = 0; i < dtos.size(); i++) {
Order order = Order.from(dtos.get(i));
em.persist(order);
// batch_size 단위로 flush + clear
if ((i + 1) % batchSize == 0) {
em.flush();
em.clear();
}
}
// 나머지 처리
em.flush();
em.clear();
}
}
flush()는 쌓인 SQL을 DB에 전송하고, clear()는 1차 캐시를 비운다. batchSize는 jdbc.batch_size와 동일하게 맞추는 것이 최적이다.
JdbcTemplate: 극한 성능이 필요할 때
JPA 배치로도 부족한 극한의 성능이 필요하면, JdbcTemplate의 batchUpdate()를 직접 사용한다:
@Repository
@RequiredArgsConstructor
public class OrderJdbcRepository {
private final JdbcTemplate jdbcTemplate;
public void bulkInsert(List<Order> orders) {
String sql = "INSERT INTO orders (id, customer_id, amount, status, created_at) "
+ "VALUES (?, ?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i)
throws SQLException {
Order o = orders.get(i);
ps.setObject(1, o.getId());
ps.setLong(2, o.getCustomerId());
ps.setBigDecimal(3, o.getAmount());
ps.setString(4, o.getStatus().name());
ps.setTimestamp(5, Timestamp.valueOf(o.getCreatedAt()));
}
@Override
public int getBatchSize() {
return orders.size();
}
});
}
}
JPA 배치 대비 2~5배 빠르다. 영속성 컨텍스트를 거치지 않으므로 메모리 효율도 높다. 단, 엔티티 라이프사이클 이벤트(@PrePersist 등)가 동작하지 않는다는 점에 주의하라.
MySQL rewriteBatchedStatements
MySQL을 사용한다면 JDBC URL에 rewriteBatchedStatements=true를 추가하면, 여러 INSERT를 하나의 INSERT INTO ... VALUES (...), (...), (...)로 합쳐서 전송한다:
# application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb?rewriteBatchedStatements=true
# PostgreSQL은 기본적으로 배치 최적화가 잘 되어 있어 별도 설정 불필요
이 한 줄로 MySQL 배치 INSERT 성능이 5~10배 향상된다.
Spring Data JPA saveAll() 개선
saveAll()은 내부적으로 save()를 반복 호출하는데, 각 호출마다 entityManager.merge() 또는 persist()를 판단한다. 새 엔티티임이 확실하면 이 판단을 생략할 수 있다:
// Persistable 구현으로 isNew() 판단 최적화
@Entity
public class Order implements Persistable<UUID> {
@Id
private UUID id;
@Transient
private boolean isNew = true;
@Override
public UUID getId() {
return id;
}
@Override
public boolean isNew() {
return isNew;
}
@PostPersist
@PostLoad
void markNotNew() {
this.isNew = false;
}
// 팩토리 메서드에서 ID 사전 할당
public static Order create(OrderCreateDto dto) {
Order order = new Order();
order.id = UUID.randomUUID();
order.isNew = true;
return order;
}
}
Persistable을 구현하면, saveAll()이 매번 SELECT로 존재 여부를 확인하지 않고 바로 persist()를 호출한다.
성능 벤치마크: 전략별 비교
10,000건 INSERT 기준 (PostgreSQL, 로컬 환경):
| 전략 | 소요 시간 | 비고 |
|---|---|---|
| saveAll() + IDENTITY | ~12초 | 개별 INSERT, 배치 불가 |
| saveAll() + SEQUENCE + batch 50 | ~2.5초 | Hibernate 배치 활성화 |
| EntityManager + flush/clear | ~2초 | 메모리 효율적 |
| JdbcTemplate batchUpdate | ~0.5초 | 영속성 컨텍스트 무시 |
| COPY (PostgreSQL 전용) | ~0.1초 | 최고 성능, JPA 영역 아님 |
배치 INSERT 디버깅
배치가 실제로 동작하는지 확인하는 방법:
# 로그로 확인 (SQL 카운트 비교)
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.engine.jdbc.batch.internal: TRACE
# "Executing batch size: 50" 로그가 보이면 성공
# datasource-proxy로 정밀 측정
# 실제 DB에 전송된 쿼리 수를 카운트
spring:
datasource:
url: jdbc:p6spy:postgresql://localhost:5432/mydb
실전 가이드라인
- 1,000건 이하:
saveAll()+ SEQUENCE + batch 설정으로 충분 - 1,000~10만 건: EntityManager
flush()/clear()패턴 사용 - 10만 건 이상: Spring Batch 또는 JdbcTemplate
batchUpdate()사용 - 100만 건 이상: DB 네이티브 벌크 로드 (
COPY,LOAD DATA INFILE) 사용
정리
JPA Batch INSERT 최적화의 핵심은 세 가지다: IDENTITY 전략 회피(SEQUENCE/UUID 사용), Hibernate 배치 설정 활성화(batch_size + order_inserts), 영속성 컨텍스트 주기적 비우기(flush/clear). 이 세 가지만 적용해도 대량 INSERT 성능이 5~10배 향상된다. 극한의 성능이 필요하면 JdbcTemplate으로 내려가되, 트레이드오프를 인지하고 선택하라.