JPA 배치 쓰기 완전 정복 — `hibernate.jdbc.batch_size`와 `IDENTITY` 함정
배치 쓰기, 왜 따로 알아야 하나요?
대량 INSERT나 UPDATE가 느릴 때 대부분의 첫 반응은 "DB가 느려서"입니다. 그러나 실제 원인은 DB가 아니라 네트워크 왕복(round-trip) 인 경우가 압도적으로 많습니다. 한 행씩 INSERT를 반복하면 행 수 × 왕복 비용이 그대로 응답 시간에 더해집니다.
- 사용자 데이터 1만 건을 한 번에 저장하는데 응답이 수 분 걸립니다
- SQL 로그를 보니
INSERT가 한 건씩 수천 번 반복됩니다 hibernate.jdbc.batch_size를 설정했는데 여전히 개별 쿼리가 나갑니다- 배치 옵션을 다 켰는데도 MySQL에서는 속도 차이가 작습니다
이 증상들의 원인은 전부 JPA의 배치 쓰기가 어떤 조건에서 켜지고, 어떤 조건에서 꺼지는지를 몰라서 생깁니다. 특히 IDENTITY ID 생성 전략은 배치를 구조적으로 막는 가장 큰 이유인데, MySQL 환경에서는 이걸 우회하기가 까다롭습니다. 이 글은 배치가 동작하는 구조, 막히는 이유, 그리고 실무에서 우회하는 방법을 순서대로 정리합니다.
기준: 이 글은 Jakarta Persistence 3.1 명세와 Hibernate 6.x, MySQL 8.4 Connector/J 기준으로 작성합니다. Hibernate 배치 옵션은 Hibernate 6 User Guide — §13. Batching, MySQL JDBC 드라이버의
rewriteBatchedStatements파라미터는 MySQL Connector/J Reference를 참조합니다. 영속성 컨텍스트와 flush 동작은 앞 글을 전제로 합니다. 코드 예시는 Kotlin + Spring Data JPA입니다.
먼저 가장 짧은 답부터 보면
- JDBC 드라이버의 배치(
addBatch/executeBatch) 는 여러 SQL을 한 번의 네트워크 왕복으로 전송하는 메커니즘입니다 - Hibernate는
hibernate.jdbc.batch_size가 설정되어 있어야 배치를 시도합니다. 기본값은 비활성화입니다 IDENTITYID 전략은 배치를 구조적으로 막습니다. ID를 DB가 만들어 주기 때문에persist()순간 개별INSERT가 필요합니다- MySQL은
IDENTITY = AUTO_INCREMENT이므로 이 함정을 정면으로 맞습니다. 애플리케이션에서 ID를 만드는 방식(UUIDv7, Snowflake)이 실질적 대안입니다 - MySQL 드라이버의
rewriteBatchedStatements=true를 켜면 다중INSERT가 단일multi-VALUES문으로 재작성되어 실제 효과가 큽니다
Phase 1. JDBC 배치가 실제로 해결하는 것
핵심: 네트워크 왕복 수를 줄입니다
INSERT 한 건의 비용은 대부분 DB의 쓰기 자체가 아니라 네트워크 왕복 + SQL 파싱입니다. 한 건을 1ms에 처리할 수 있는 DB라도, 왕복이 5ms면 10,000건을 차례로 보내는 데 수십 초가 걸립니다.
개별 INSERT 10,000건
app ──INSERT──▶ db 왕복 1
◀──ack────
app ──INSERT──▶ db 왕복 2
◀──ack────
... × 10,000
JDBC 배치는 여러 SQL을 한 번의 왕복으로 전송합니다.
배치 INSERT 10,000건 (batch_size = 500)
app ──INSERT × 500──▶ db 왕복 1
◀───acks──────
... × 20 (왕복 수 1/500로 축소)
JDBC 수준에서의 동작
JDBC 드라이버는 Statement#addBatch()로 쌓인 SQL을 executeBatch()로 한 번에 전송합니다. Hibernate의 hibernate.jdbc.batch_size는 이 executeBatch()가 언제 호출될지를 조정합니다.
Phase 2. Hibernate 배치 설정
기본 설정
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 100
order_inserts: true
order_updates: true
각 설정의 역할은 이렇습니다.
| 속성 | 역할 |
|---|---|
hibernate.jdbc.batch_size |
한 배치에 묶을 최대 SQL 수. 기본값 0 (비활성화) |
hibernate.order_inserts |
INSERT를 테이블별로 정렬해서 같은 테이블 것끼리 연속 배치 가능하게 함 |
hibernate.order_updates |
UPDATE도 같은 방식으로 정렬 |
왜 order_inserts가 필요한가요?
JDBC 배치는 같은 SQL 문자열만 묶을 수 있습니다. Hibernate는 엔티티를 영속화한 순서대로 INSERT 큐를 만들기 때문에, 여러 종류의 엔티티가 섞여 있으면 다음처럼 됩니다.
INSERT user ...
INSERT order ...
INSERT user ...
INSERT order ...
이 상태에서는 배치 크기가 아무리 커도 같은 테이블 INSERT가 연달아 있지 않아 묶이지 않습니다. order_inserts=true는 이 큐를 테이블별로 정렬해 다음처럼 바꿉니다.
INSERT user ...
INSERT user ...
INSERT order ...
INSERT order ...
이 상태가 되어야 배치가 실제로 작동합니다.
배치가 실행되는 시점
Hibernate는 다음 중 하나의 시점에 배치를 내보냅니다.
- 배치 큐 크기가
batch_size에 도달할 때 - 다른 테이블에 대한 SQL이 끼어들 때 (같은 문자열이 아니므로 먼저 flush)
- 커밋 직전의 최종
flush
이 동작은 order_inserts와 결합되어야 효과가 납니다. 둘 다 켜지지 않은 상태에서는 설정 값만 바꿔도 실제 배치로 묶이지 않을 수 있습니다.
Phase 3. IDENTITY 전략이 배치를 막는 진짜 이유
핵심: ID를 DB가 만들어야 해서 INSERT를 하나씩 보낼 수밖에 없습니다
GenerationType.IDENTITY는 AUTO_INCREMENT 같은 DB 기능으로 ID를 만듭니다. 이 전략의 문제는 "INSERT를 실행해야 ID를 알 수 있다"는 점입니다.
영속성 컨텍스트는 엔티티를 1차 캐시에 넣을 때 ID를 키로 씁니다. 그래서 persist() 순간 ID를 알아야 하고, 결과적으로 INSERT를 즉시 실행합니다. 이 순간 배치 큐는 사용할 수 없습니다.
@Transactional
fun insertMany(users: List<User>) {
users.forEach { em.persist(it) } // 각 persist()마다 INSERT 실행
}
batch_size 설정이 아무리 커도 위 코드는 건별 INSERT를 냅니다. MySQL 환경에서 배치가 "안 먹는" 듯 보이는 가장 큰 원인이 이것입니다.
Hibernate는 이 상황에서 조용히 배치를 끕니다
Hibernate는 IDENTITY 전략의 엔티티에 대해 경고 없이 배치를 비활성화합니다. 별도의 로그 메시지가 뜨지 않기 때문에 "batch_size를 줬는데 왜 안 빨라지지?" 하고 의아한 상황을 만들기 쉽습니다. SQL 로그에서 개별 INSERT가 줄줄이 찍히는지를 직접 확인하는 것이 가장 빠른 진단입니다.
Phase 4. IDENTITY를 우회하는 네 가지 방법
1. SEQUENCE (PostgreSQL / Oracle 환경)
SEQUENCE 전략은 DB에 "다음 ID"를 미리 물어볼 수 있습니다. Hibernate는 시퀀스를 pooled 방식으로 여러 ID를 한꺼번에 가져와 캐싱할 수 있어, persist() 시점에 INSERT 없이도 ID를 확보할 수 있습니다.
@Entity
class User(
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq")
@SequenceGenerator(name = "user_seq", sequenceName = "user_seq", allocationSize = 50)
val id: Long = 0,
val name: String
)
이 전략에서는 배치가 자연스럽게 동작합니다. 다만 MySQL에는 SEQUENCE가 없습니다.
2. TABLE 전략 (모든 DB 사용 가능, 그러나 느림)
TABLE 전략은 별도의 ID 관리 테이블을 만들어 ID를 할당합니다. 이식성은 좋지만 매 할당마다 ID 테이블을 UPDATE 해야 하기 때문에 성능이 좋지 않습니다. MySQL 환경에서도 대안으로 거론되지만 대규모 환경에서는 거의 사용되지 않습니다.
3. 애플리케이션이 ID를 생성 — UUIDv7, Snowflake
가장 실무적인 선택은 ID를 애플리케이션이 만드는 것입니다. ID를 영속화 전에 이미 알고 있기 때문에 persist() 순간 INSERT를 내보낼 필요가 없습니다. 배치 큐에 그대로 쌓일 수 있습니다.
@Entity
class User(
@Id
val id: UUID, // UUIDv7 등 애플리케이션에서 생성
val name: String
)
UUIDv7은 RFC 9562로 표준화되어 있고, 시간 단조 증가 속성이 있어 BIGINT AUTO_INCREMENT에 가까운 인덱스 성능을 보입니다. 완전 랜덤 UUIDv4는 InnoDB 클러스터드 인덱스에 삽입할 때 페이지 분할 비용이 커서, 대량 INSERT에는 적합하지 않습니다. 애플리케이션 ID를 선택할 때는 시간 단조 증가 여부를 반드시 확인합니다.
4. JdbcTemplate / 네이티브 배치 — 최후의 수단
정말 큰 INSERT(수십만 건 이상)는 JPA를 우회해 JdbcTemplate.batchUpdate() 를 쓰는 편이 현실적입니다. 영속성 컨텍스트를 거치지 않고 직접 SQL을 배치로 보내기 때문에, ID 전략과 무관하게 최고 성능이 나옵니다.
jdbcTemplate.batchUpdate(
"INSERT INTO user (id, name) VALUES (?, ?)",
users.chunked(1000).flatMap { chunk ->
chunk.map { arrayOf<Any>(it.id, it.name) }
}
)
이 방법의 트레이드오프는 명확합니다. 영속성 컨텍스트의 장점(변경 감지, 연관 관리 등)이 사라집니다. 대량 초기 로딩이나 로그 적재처럼 영속성 컨텍스트가 오히려 짐이 되는 경우에만 선택합니다.
Phase 5. MySQL에서 반드시 확인해야 할 드라이버 설정
rewriteBatchedStatements=true
MySQL Connector/J는 기본적으로 배치 INSERT를 여러 개의 개별 INSERT 문으로 전송합니다. 즉, 네트워크 왕복 수는 줄어들어도 SQL 파싱 비용은 그대로입니다. 이 문제를 풀기 위한 드라이버 옵션이 rewriteBatchedStatements=true입니다.
spring:
datasource:
url: jdbc:mysql://.../mydb?rewriteBatchedStatements=true
이 옵션을 켜면 드라이버가 다음처럼 SQL을 재작성합니다.
-- 옵션 꺼짐
INSERT INTO user (id, name) VALUES (?, ?);
INSERT INTO user (id, name) VALUES (?, ?);
...
-- 옵션 켜짐
INSERT INTO user (id, name) VALUES (?, ?), (?, ?), (?, ?), ...;
이 한 줄의 설정만으로 대량 INSERT 성능이 5~10배 이상 빨라지는 경우가 많습니다. MySQL 환경에서 배치를 켜고도 효과가 작게 느껴진다면, 가장 먼저 이 옵션을 확인합니다.
max_allowed_packet
rewriteBatchedStatements를 켜면 한 INSERT 문의 크기가 커집니다. MySQL 서버의 max_allowed_packet 이 너무 작으면 "packet too large" 에러가 납니다. MySQL 8.x의 기본값은 64MB 로 충분하지만, 운영 환경에서는 명시적으로 확인합니다.
Phase 6. UPDATE 배치와 @Version
낙관적 잠금이 켜진 엔티티도 배치로 묶입니다
@Version으로 낙관적 잠금이 걸린 엔티티의 UPDATE를 배치로 묶는 것은 Hibernate 5 이후부터 기본값으로 활성화되어 있습니다. 관련 설정은 hibernate.jdbc.batch_versioned_data 이고, 기본값이 true 입니다 (pre-12c Oracle dialect처럼 일부 환경에서만 꺼져 있습니다).
spring:
jpa:
properties:
hibernate:
jdbc:
batch_versioned_data: true # 기본값, 명시적 확인용
이때 Hibernate는 executeBatch() 반환 값에서 각 행의 업데이트 수를 확인해 버전 충돌을 감지합니다. 드라이버가 배치 결과의 개별 row count를 지원해야 동작하고, MySQL Connector/J는 이 기능을 지원합니다. 즉 현대 MySQL 환경이라면 이 설정은 이미 켜진 상태로 동작합니다.
Phase 7. 실전 점검 순서
"배치를 켰는데 왜 안 빨라지지?"라는 의심이 들 때, 다음 순서로 점검하면 대부분의 원인이 드러납니다.
- ID 생성 전략이
IDENTITY가 아닌가? — MySQL이라면 애플리케이션 ID(UUIDv7 등)로 바꾸는 것이 가장 큰 효과 hibernate.jdbc.batch_size가 설정되어 있고 0이 아닌가? — 기본값이 0이라 많은 프로젝트가 놓칩니다order_inserts/order_updates가 켜져 있는가? — 엔티티가 섞여 있을 때 반드시 필요- MySQL 드라이버의
rewriteBatchedStatements=true가 켜져 있는가? — MySQL 환경에서는 이 한 줄이 결정적 @Version이 걸린 엔티티의UPDATE를 쓰고 있다면batch_versioned_data가 꺼져 있지 않은지 확인 (기본값true이므로 보통 문제없음)- 대량 처리 중
em.flush() + em.clear()를 주기적으로 호출하고 있는가? — 영속성 컨텍스트가 커지면 변경 감지 비용만으로도 병목이 됩니다
대량 처리 루프의 일반적인 틀
@Transactional
fun bulkInsert(items: List<Item>) {
items.chunked(1000).forEach { chunk ->
chunk.forEach { em.persist(it) }
em.flush()
em.clear() // 영속성 컨텍스트를 비워 메모리와 dirty-check 비용을 통제
}
}
이 구조에 애플리케이션 ID + 배치 설정 + rewriteBatchedStatements 가 맞물리면 JPA로도 수만 건/초 수준의 처리량이 나옵니다. 그 이상의 처리량이 필요하면 JdbcTemplate로 우회합니다.
정리
JPA 배치 쓰기의 효과는 "네트워크 왕복 수를 줄인다" 이 한 줄에 집약됩니다. 그러나 이 효과를 실제로 보려면 여러 조각이 동시에 맞아야 합니다.
| 조각 | 확인할 것 |
|---|---|
| Hibernate | batch_size, order_inserts, order_updates |
UPDATE 배치 |
batch_versioned_data (Hibernate 5+ 기본 true) + 드라이버의 개별 row count 지원 |
| ID 전략 | IDENTITY면 배치 불가, 애플리케이션 ID로 대체 |
| MySQL 드라이버 | rewriteBatchedStatements=true + max_allowed_packet 확인 |
| 메모리 관리 | 주기적 em.flush() + em.clear() |
MySQL 환경에서 이 조합 중 IDENTITY 회피와 rewriteBatchedStatements 가 가장 큰 레버입니다. 둘 중 하나라도 빠지면 배치 효과의 상당 부분을 잃습니다. 설계 시점부터 ID 생성 전략을 고민하는 것이, 뒤늦게 튜닝으로 회복하는 것보다 훨씬 저렴합니다.
다음 글은 JPA 시리즈의 마지막 — merge vs persist와 detached 엔티티 다루기를 정리합니다. 지금까지 쌓인 영속성 컨텍스트·fetch 전략·트랜잭션·배치의 개념이 모여, 실무에서 엔티티 상태를 언제 어떻게 올려야 하는지가 정돈됩니다.
Move Zeroes — 쓰기 포인터 하나로 `O(n)`/`O(1)` 제자리 재배치
다음 글Is Subsequence — 투 포인터 `O(|s| + |t|)`로 부분 수열 판정
다음으로 읽어볼 글
Group Anagrams — 정렬 키와 문자 개수 키로 애너그램 묶기
애너그램 문자열을 같은 그룹으로 묶는 LeetCode 49번, 정렬된 문자열을 키로 쓰는 풀이부터 문자 개수 배열을 해시 키로 바꾸는 `O(n * k)` 풀이까지 정리합니다.
Most Common Word — 금지어를 제외하고 가장 자주 나온 단어 찾기
문단에서 금지어를 제외하고 가장 많이 등장한 단어를 찾는 LeetCode 819번, 문장 부호 제거와 소문자 정규화, 해시 카운팅 풀이를 정리합니다.
Find All Numbers Disappeared in an Array — 음수 마킹으로 `O(n)`/`O(1)` 누락 숫자 찾기
1부터 n까지의 숫자 중 배열에 나타나지 않은 값을 찾는 LeetCode 448번, 완전 탐색부터 존재 배열, 음수 제자리 마킹 풀이까지 정리합니다.