에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/데이터 & DB

[JPA] 배치 insert와 대량 처리 성능: saveAll, rewriteBatchedStatements, flush와 clear

반응형
[JPA] 배치 insert와 대량 처리 성능: saveAll, rewriteBatchedStatements, flush와 clear

[JPA] 배치 insert와 대량 처리 성능: saveAll, rewriteBatchedStatements, flush와 clear

"엑셀 1만 건 업로드가 3분 걸려요." JPA 대량 처리에서 흔한 호소예요. saveAll을 썼으니 알아서 묶어서 넣어줄 것 같지만, 기본 상태의 JPA는 한 건씩 INSERT를 1만 번 보내요. 설정 몇 줄과 패턴 하나로 이걸 초 단위로 줄일 수 있어요.

saveAll이 실제로 하는 일, JDBC 배치와 rewriteBatchedStatements, 배치가 안 먹는 3대 원인, 대량 루프의 flush/clear 패턴, 그리고 "언제 JPA를 포기하고 JdbcTemplate로 가는지"를 차례로 풀어 볼게요. 영속성 컨텍스트의 쓰기 지연 개념 위에 서 있어요.

01. saveAll은 묶어서 넣지 않아요

JPA saveAll 기본 동작과 JDBC 배치 비교. 기본 상태는 INSERT를 1만 번 왕복하지만, batch_size와 rewriteBatchedStatements를 켜면 multi-values INSERT로 묶여 왕복이 수십 배 줄어든다

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대 원인

JPA 배치 insert가 안 먹는 3대 원인. IDENTITY 전략은 쓰기 지연이 불가해 배치가 안 되고, rewriteBatchedStatements 누락은 성능 차이를 없애며, 엔티티 타입이 섞이면 묶음이 끊긴다

① 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이 나요.

JPA 대량 처리의 1차 캐시 비대 문제. 루프에서 persist한 엔티티가 전부 영속 상태로 1차 캐시에 쌓여 메모리가 폭증하므로, 배치 단위마다 flush와 clear로 캐시를 비워 메모리를 일정하게 유지한다

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

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."