save() vs saveAll() vs bulk insert

김신영·2025년 6월 18일
0

1. 용어 정리

round-trip

애플리케이션(자바) <-> db 사이의 왕복 통신 1회를 의미.
많이 발생할수록 네트워크, db 대기 시간이 증가한다.

memberRepository.save(member); //DB에 INSERT 요청을 보냄
이 순간 자바 -> db -> 응답 받음
이게 round-trip 1회이다.


영속성 컨텍스트

JPA가 관리하는 1차 캐시이자 작업 공간
DB에 넣기 전, 엔티티들이 머무는 메모리 캐시 공간.
persist로 1만 건을 넣으면 → 영속성 컨텍스트에 1만 개 쌓임


flush()

컨텍스트 -> DB로 SQL 전송
트랜잭션 말미에 자동 실행 or 수동 호출도 가능


JDBC Batch

동일 SQL을 묶어 한 패킷으로 전송
hibernate.jdbc.batch_size로 개수 설정
JPA 사양엔 batch가 없어
Hibernate 같은 JPA 구현체가, 내부에서 JDBC Batch API를 호출해 “JPA 배치” 기능을 제공한다.
따라서 개념적으로는 JDBC 기능이고, JPA는 래퍼일 뿐이다.


2. save() vs saveAll() vs bulk insert

save()

영속성 컨텍스트가 관리한다

엔티티 1개마다 EntityManager.persist() 호출.

flush는 트랜잭션 종료 시점 1회.

Round-trip N회 (엔티티 수만큼).

트랜잭션도 N번 ― 서비스·AOP 레이어에서 매 호출이 새 트랜잭션을 연다.


saveAll()

내부 코드:

for (T entity : list) {
save(entity);
}

flush/트랜잭션은 1회로 줄지만,
persist·Round-trip은 N회여서 네트워크 비용은 변함없음.

즉, saveAll()은 코드만 깔끔할 뿐, 성능 관점에선 N건 단건 삽입이다.

트랜잭션 종료 = flush 실행

hibernate가 컨텍스트를 스캔하며 각 엔티티마다 PreparedStatement.execute() 호출
SQL × N 개가 JDBC 커넥션을 통해 순차 전송
→ 왕복 N 회

참고:
flush()는 “지금부터 모아 둔 SQL을 실행하라”는 트리거이지
“한 번만 보내라”는 약속이 아니다.
배치를 켜지 않으면 flush 1회라도 SQL·네트워크 왕복은 엔티티 수만큼 발생한다.


bulk insert(batch insert)

동작:

for (Customer c : bulk) {
em.persist(c); // 컨텍스트에만 적재
if (++i % 1000 == 0) {
em.flush(); // 1,000건마다 SQL 묶음 전송
em.clear(); // 메모리 비우기(영속성 컨텍스트 초기화)
}
}

  1. 여러 persist() → 컨텍스트에 누적

  2. flush()에서 같은 INSERT를 묶어 JDBC Batch API 호출

  3. 물리적 round-trip 감소

배치가 한 트랜잭션 안에서만 의미가 있다 → flush 타이밍 과 메모리 사용량 을 직접 관리해야 함.

컨텍스트에 1만 건, 2만 건을 오래 쌓으면 dirty-check O(n²) 로 꺾이므로
주기적 flush+clear 패턴이 사실상 표준.


3. 작은 데이터 셋에서 saveAll과 batch 비교

10건 삽입

단건(saveAll): Round-trip 10회 (≒ 5 ms ×10)

배치: 컨텍스트 관리 + 배치 준비 비용이 더 큼 → 이득 미미

10,000건 삽입

단건(saveAll) : Round-trip 10,000회 (수백 ms+)

배치 : 배치 10·20회면 끝 — 네트워크·DB 부하 급감

작은 데이터셋이면 관리 오버헤드 > 통신 절감

배치 처리는 성능을 높여주는 것 같지만
모든 insert 작업을 한 트랜잭션 안에서 묶어서 처리하다 보니,
처리 도중에 메모리나 캐시가 많이 사용되고,
flush() 타이밍도 복잡해져서
오히려 작은 데이터에는 손해일 수 있음.

의문점

사실상 saveAll()에서 sql 쿼리를 한번만 날리도록 설계했으면 되는 게 아닐까?
해답:
jpa는 db 최적화보단 java 객체의 상태관리와 영속성 컨텍스트 동기화에 초점을 맞춘 기술이다
saveAll()을 진짜 배치처럼 해버리면, EntityManager.persist()를 여러 개 생략하거나 건너뛰어야 함
그러면 JPA가 객체 상태를 추적하지 못해 1차 캐시, dirty checking, flush, cascade 등 JPA의 핵심 기능이 무너짐.

JPA에서 흔히 쓰는 @GeneratedValue(strategy = IDENTITY)는 DB에 insert가 실제로 실행되어야 ID 값을 알 수 있음.
따라서 미리 insert들을 모아두고 한번에 실행하는 batch insert가 불가능.
saveAll()도 결국 IDENTITY 전략에 맞춰 설계돼 있어서, 한 건 한 건 insert가 실제로 일어나야 함.

0개의 댓글