[JPA] 배치 insert와 대량 처리 성능: saveAll, rewriteBatchedStatements, flush와 clear
![[JPA] 배치 insert와 대량 처리 성능: saveAll, rewriteBatchedStatements, flush와 clear](https://blog.kakaocdn.net/dna/HEyq5/dJMcafNGtZB/AAAAAAAAAAAAAAAAAAAAAKpiv2_38sNNH75YMeLcwKNjrfXnRSC6oXZ02RsHap5c/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1788188399&allow_ip=&allow_referer=&signature=257snZHbn%2BpPlKdHe%2BdLezeRq5E%3D)
[JPA] 배치 insert와 대량 처리 성능: saveAll, rewriteBatchedStatements, flush와 clear
"엑셀 1만 건 업로드가 3분 걸려요." JPA 대량 처리에서 흔한 호소예요. saveAll을 썼으니 알아서 묶어서 넣어줄 것 같지만, 기본 상태의 JPA는 한 건씩 INSERT를 1만 번 보내요. 설정 몇 줄과 패턴 하나로 이걸 초 단위로 줄일 수 있어요.
saveAll이 실제로 하는 일, JDBC 배치와 rewriteBatchedStatements, 배치가 안 먹는 3대 원인, 대량 루프의 flush/clear 패턴, 그리고 "언제 JPA를 포기하고 JdbcTemplate로 가는지"를 차례로 풀어 볼게요. 영속성 컨텍스트의 쓰기 지연 개념 위에 서 있어요.
01. saveAll은 묶어서 넣지 않아요

saveAll(items)은 내부적으로 루프를 돌며 persist를 부르는 것뿐이에요. 쓰기 지연으로 SQL이 모였다가 flush에 나가긴 하지만, 기본 설정에선 모인 INSERT가 한 건씩 순서대로 전송돼요. 1만 건이면 네트워크 왕복 1만 번이에요. 왕복당 1ms만 잡아도 10초고 실제로는 분 단위로 늘어져요.
이걸 묶는 게 JDBC 배치예요. 두 가지 설정이 한 세트예요.
spring:
jpa:
properties:
hibernate:
jdbc.batch_size: 1000 # 1000건씩 묶어 전송
order_inserts: true # 같은 타입끼리 정렬해 묶음 유지
datasource:
url: jdbc:mysql://localhost:3306/shop?rewriteBatchedStatements=true
여기서 가장 많이 빠뜨리는 게rewriteBatchedStatements=true예요.batch_size만 켜면 "묶어서 전송"까지만 되고, MySQL 드라이버가 그걸 multi-values INSERT 한 문장(VALUES (...), (...), ...)으로 다시 써주는 건 이 JDBC URL 옵션이에요. 이게 빠지면 배치를 켰는데 체감 차이가 없어요. "설정했는데 안 빨라요"의 1순위 원인이에요.
02. 배치가 안 먹는 3대 원인

① ID 생성 전략이 IDENTITY예요
이게 제일 구조적인 원인이에요. GenerationType.IDENTITY는 DB의 auto_increment로 ID를 만들어요. 즉 INSERT를 실제로 실행해야 ID를 알 수 있어요. 그런데 영속성 컨텍스트는 엔티티를 관리하려면 ID가 필요해요(1차 캐시의 키니까요). 그래서 IDENTITY에서는 persist 즉시 INSERT가 나가요 — 쓰기 지연이 무력화되고, Hibernate가 배치를 포기해요.
해법은 INSERT 전에 ID를 알 수 있는 전략으로 바꾸거나(MySQL은 시퀀스가 없어 TABLE 전략이나 앱 채번으로 우회해요), 대량 구간만 JdbcTemplate로 직행하는 거예요. ID 전략의 트레이드오프는 다음 편에서 깊게 다뤄요.
② rewriteBatchedStatements 누락
위에서 본 그 함정이에요. JDBC URL을 확인해요.
③ 엔티티 타입이 섞여요
배치는 같은 SQL끼리만 묶여요. 루프에서 Order, OrderItem을 번갈아 persist하면 묶음이 계속 끊겨요. order_inserts: true가 flush 시점에 타입별로 정렬해 이걸 완화해주고, 코드 차원에서도 타입별로 모아 저장하는 게 좋아요.
03. 대량 루프의 또 다른 적, 1차 캐시 비대
배치를 켜도 100만 건쯤 되면 새로운 문제가 나타나요. 점점 느려지다 OOM이 나요.

persist한 엔티티는 전부 영속 상태로 1차 캐시(+스냅샷)에 남아요. 100만 건이면 캐시에 100만 개가 쌓이고 더티 체킹이 비교할 대상도 100만 개가 돼요. 그래서 대량 루프에서는 주기적으로 비워줘야 해요.
for (int i = 0; i < items.size(); i++) {
em.persist(items.get(i));
if ((i + 1) % 1000 == 0) {
em.flush(); // 쌓인 SQL 전송
em.clear(); // 1차 캐시 비우기 — 메모리 일정 유지
}
}
flush로 SQL을 내보내고 clear로 캐시를 비우는 거예요. batch_size와 같은 주기(1000)로 맞추는 게 자연스러워요. 주의할 건 clear() 이후 기존 엔티티들은 전부 준영속이라는 점이에요 — 그 뒤에 이전 엔티티를 수정해도 반영 안 돼요.
04. 수정·삭제 대량 처리는 벌크 연산으로
"전 상품 가격 10% 인상" 같은 대량 수정을 엔티티로 하면, 100만 건을 읽어서 더티 체킹으로 100만 UPDATE가 나가요. 이건 JPQL 벌크 연산이 정답이에요.
@Modifying(clearAutomatically = true)
@Query("update Item i set i.price = i.price * 1.1 where i.category = :category")
int bulkUpdatePrice(String category);
DB에서 UPDATE 한 방으로 끝나요. 단, 벌크 연산은 영속성 컨텍스트를 거치지 않고 DB로 직행해요. 1차 캐시에 있는 엔티티는 옛날 값 그대로라, 벌크 후 같은 트랜잭션에서 그 엔티티를 읽으면 어긋나요. clearAutomatically = true로 실행 후 캐시를 비워 불일치를 막아요.
05. 언제 JPA를 포기하나요
솔직한 기준도 필요해요. JPA 배치는 "엔티티 모델을 유지하면서 충분히 빠르게"까지예요. 다음 경우는 JdbcTemplate(또는 MyBatis) 직행이 더 맞아요.
- IDENTITY를 못 바꾸는데 insert가 수십만 건 이상 — 구조적으로 배치가 안 되니, 그 구간만
jdbcTemplate.batchUpdate로 처리해요. - 엔티티 라이프사이클이 전혀 필요 없는 단순 적재 — 로그성 데이터 벌크 적재에 영속성 컨텍스트는 오버헤드일 뿐이에요.
한 애플리케이션에서 일반 CRUD는 JPA, 대량 적재 경로만 JdbcTemplate — 이렇게 섞는 건 전혀 이상한 설계가 아니에요. 도구를 도그마로 만들지 않는 게 성능 작업의 시작이에요.
06. 자주 만나는 문제
saveAll이 너무 느려요
batch_size + rewriteBatchedStatements를 확인하고, ID 전략이 IDENTITY인지 봐요. IDENTITY면 배치가 구조적으로 안 돼요.
배치 설정을 다 했는데 로그를 보면 한 건씩 나가요
ID 전략(IDENTITY), URL 옵션 누락, 엔티티 섞임 순으로 점검해요. 실제 SQL이 묶이는지는 MySQL의 general log나 드라이버 프로파일러로 확인하는 게 정확해요.
대량 처리가 갈수록 느려지다 OOM이 나요
1차 캐시 비대예요. 주기적 flush() + clear()를 넣어요.
벌크 UPDATE 후 조회하면 옛날 값이 나와요
벌크 연산이 1차 캐시를 우회해서예요. clearAutomatically = true를 걸거나 벌크 후 clear()를 호출해요.
정리
JPA 대량 처리의 공식은 batch_size + rewriteBatchedStatements + (IDENTITY 회피) + 주기적 flush/clear예요. 수정·삭제는 벌크 연산으로 DB에 직접 보내되 1차 캐시 불일치를 비워 막고요. 그리고 정말 큰 적재는 JdbcTemplate 직행이 솔직한 답일 때도 있어요 — 핵심은 "어디서 네트워크 왕복이 늘어나는가"를 보는 거예요.
그런데 방금 배치의 발목을 잡았던 게 하나 있었죠. ID 생성 전략이 IDENTITY면 구조적으로 배치가 안 된다는 대목이요. 바로 그 IDENTITY vs SEQUENCE를 다음에 정면으로 파고들어요.
출처: Hibernate User Guide — Batching · MySQL Connector/J — rewriteBatchedStatements
'백엔드 > 데이터 & DB' 카테고리의 다른 글
| [JPA] 성능 트러블슈팅 가이드: N+1, 느린 조회, 대량 처리, LazyInitializationException 증상별 대응 (0) | 2026.07.16 |
|---|---|
| [JPA] ID 생성 전략 비교: IDENTITY vs SEQUENCE, allocationSize, UUID 기본키 (0) | 2026.07.15 |
| [JPA] OSIV(open-in-view) 켤까 끌까: 영속성 컨텍스트 범위와 커넥션 고갈 (0) | 2026.07.13 |
| [JPA] 낙관적 락 vs 비관적 락: @Version, SELECT FOR UPDATE, 동시성 충돌 해결 (0) | 2026.07.10 |
| [JPA] 연관관계 매핑 정리: 연관관계 주인 mappedBy, 양방향, cascade, orphanRemoval (0) | 2026.07.09 |
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."