부제 : async-profiler Flame Graph 로 직접 파헤쳐 본 다건 저장 성능 차이
이 글에서 얻어갈 것들
- saveAll() 이 빠른 진짜 이유를 설명할 수 있게 된다.
- ("INSERT 한 방"이라는 오해를 정확히 깨뜨릴 수 있다)
- SimpleJpaRepository.saveAll() 내부 코드를 보고, saveAll도 결국 save()를 반복 호출함을 이해한다.
- 트랜잭션 경계가 다건 저장 성능에 미치는 영향을 async-profiler Flame Graph 실측 데이터로 확인한다.
- 서비스 계층 @Transactional의 의미를 성능 관점에서 이해한다.
- saveAll()과 JDBC Batch insert 를 구분할 수 있게된다.
1. 시작 - 흔한 오해
처음 saveAll()을 쓸 때 나도 당연하게 생각했다.
"save() 여러 번 호출하는 것보다 한 방에 INSERT 날리니까 빠르겠지."
실제로 빠르긴 하다. 그런데 이유가 틀렸다.
직접 확인해봤다.
2.코드부터 보기 - save() vs saveAll() 내부
먼저 saveAll() 의 코드를 보자.
Spring Data JPA의 SimpleJpaRepository 에서 발췌한 내용이다. (버전에 따라 세부 코드는 다를 수 있지만 핵심 구조는 동일하다)
// SimpleJpaRepository.java
@Transactional
public <S extends T> List<S> saveAll(Iterable<S> entities) {
List<S> result = new ArrayList<>();
for (S entity : entities) {
save(entity); // ← 결국 save()를 호출한다
}
return result;
}
@Transactional
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
entityManager.persist(entity);
} else {
entityManager.merge(entity);
}
return entity;
}
saveAll() 내부를 보면 루프를 돌며 save()를 하나씩 호출한다.
SQL 을 한 방에 날리는 마법 같은 건 없다.
그렇다면 왜 saveAll()이 빠를까?
두 방식의 진짜 차이는 @Transactional의 범위에 있다.
// 방식 1: save() 반복 (서비스 메서드에 @Transactional 없음)
public void saveUsersIndividually(List<String> names) {
for (String name : names) {
userRepository.save(new User(name));
// save()마다 SimpleJpaRepository의 @Transactional이 동작
// → 트랜잭션 시작 → INSERT → 커밋 이 N번 반복
}
}
// 방식 2: saveAll()
public void saveUserBatch(List<String> names) {
List<User> users = names.stream().map(User::new).toList();
userRepository.saveAll(users);
// saveAll() 하나의 @Transactional 안에서 전부 처리
// → 트랜잭션 시작 → INSERT × N → 커밋 1번
}
이론적으로는 이렇다. 실제로 얼마나 차이가 나는지 직접 측정해봤다.
3. 성능 측정 - async-profiler + Flame Graph
단순 시간 측정은 JVM 워밍업 없이 재면 신뢰하기 어렵다.
async-profiler를 사용해서 CPU 샘플 기반으로 측정했다.
@Test
public void testPerformanceComparisonWithProfiler() throws Exception {
profiler = AsyncProfiler.getInstance();
// JVM 워밍업 (측정 전 필수)
warmup();
final int MEASURE_COUNT = 3;
// ...
for (int i = 0; i < MEASURE_COUNT; i++) {
// save() 측정
profiler.execute("start,event=cpu,interval=10ms");
userService.saveUsersIndividually(names1);
profiler.execute("stop,file=save-profile-" + i + ".html");
// saveAll() 측정
profiler.execute("start,event=cpu,interval=10ms");
userService.saveUserBatch(names2);
profiler.execute("stop,file=saveAll-profile-" + i + ".html");
}
}


측정 조건: 10만 건, 3회 측정 후 대표값 기재, JVM 워밍업 포함
| 방식 | CPU Samples (대표값) |
| save() 반복 | 849 |
| saveAll() | 274 |
대표 CPU profile 기준으로 save() 반복 쪽이 saveAll() 보다 약 3.1배 더 많은 CPU sample을 보였다.
단 이는 wall-clock 실행 시간이 정확히 3.1배라는 뜻이 아니라, CPU sample 기준으로 약 3.1 배 더 많이 관측된 것이다.
그런데 왜일까?
4. Flame Graph 분석 — 느린 진짜 이유
Flame Graph에서 save() 반복 쪽 경로를 보면 이런 프레임이 넓게 잡힌다.
UserService.saveUsersIndividually
└─ $Proxy.save
└─ TransactionAspectSupport.invokeWithinTransaction
├─ createTransactionIfNecessary ← 트랜잭션 시작
│ └─ JpaTransactionManager.doBegin
│ └─ HikariDataSource.setAutoCommit
└─ commitTransactionAfterReturning ← 트랜잭션 커밋
└─ JpaTransactionManager.doCommit
└─ HikariProxyConnection.commit

이 경로가 save() 1번마다 반복된다. 10만 건이면 이 흐름이 10만번 생성된다.
이번 실험처럼 서비스 메서드에 @Transacional이 없는 상태에서 save()를 반복 호출하면, Repository save() 의 트랜잭션 경계가 호출 횟수만큼 반복될 수 있다.
saveAll()쪽은 다르다.
UserService.saveUserBatch
└─ SimpleJpaRepository.saveAll ← 트랜잭션 1번 시작
└─ SimpleJpaRepository.save ← N번 반복, 외부 트랜잭션에 참여 (새 트랜잭션 생성 안 함)
└─ EntityManager.persist
└─ ActionQueue
└─ EntityInsertAction
doBegin, doCommit, setAutoCommit 같은 트랜잭션 관리 프레임이 눈에 띄지 않는다.
트랜잭션이 딱 1번만 열리기 때문이다.
save()가 느린 핵심 원인은 SQL 실행이 아니라, AOP 트랜잭션 처리가 N번 반복되는 것이다.
5. 그럼 @Transactional을 붙이면 같아질까?
여기서 자연스럽게 따라오는 질문이 있다.
"서비스 메서드에 @Transactional을 붙이면 save() 반복도 빨라지지 않을까?"
@Transactional // ← 추가
public void saveUsersIndividually(List<String> names) {
for (String name : names) {
userRepository.save(new User(name));
// 이제 외부 트랜잭션에 참여 → save() 내부 @Transactional은 새 트랜잭션을 만들지 않고 기존 트랜잭션에 합류
}
}
이렇게 하면 save() 반복도 트랜잭션이 1번으로 묶인다.
이 상태에서 비교하면 save() 와 saveAll()의 성능 차이는 현저히 줄어든다.
결국 saveALL() 이 빠르다는 말은 조건이 붙는다.
서비스 메서드에 @Transactional이 없을 때, saveAll()이 save() 반복보다 빠르다. @Transactional 로 묶이 상태라면 두 방식은 거의 같다.
6. JDBC Batch - 추가로 얻을 수 있는 것
트랜잭션을 하나로 묶는 것만으로도 큰 차이가 난다. 여기서 한발 더 나아가면 JDBC Batch를 활용할 수 있다.
application.yml에 설정을 추가한다.
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 2
generate_statistics: true # 아래 로그 확인용
batch_size 를 설정하면 Hibernate는 INSERT 를 즉시 보내지 않고, 지정한 수만큼 쌓아뒀다가 한 번에 DB로 전송한다.
// batch_size=2, 엔티티 3개일 때 내부 동작
ps.setValues("User 1"); ps.addBatch(); // 큐에 쌓기만
ps.setValues("User 2"); ps.addBatch();
ps.executeBatch(); // ← 1번째 전송 (2개 → 네트워크 1번)
ps.setValues("User 3"); ps.addBatch();
ps.executeBatch(); // ← 2번째 전송 (1개 → 네트워크 1번)
결과는 generate_statistics 로그에서 확인할 수 있다.
// batch 적용 후 Session Metrics 로그
executing 1 JDBC statements;
executing 2 JDBC batches; ← batch_size=2로 3개 처리
배치 없이 3번의 네트워크 왕복이 2번으로 줄었다.
10만 건에 batch_size=1000이면 100번의 왕복으로 끝난다.
주의할 점
saveAll() != JDBC batch insert 자동 적용
- batch_size 설정이 되어 있어야 한다
- GenerationType.IDENTITY는 사용 불가 — INSERT 직후 DB가 ID를 생성해야 해서 Hibernate가 배치를 원천 차단한다
- GenerationType.SEQUENCE 또는 TABLE을 써야 한다
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE) // ← 필수
private Long id;
}
7. 정리
| save() 반복 | saveAll() | |
| 내부 동작 | save() N번 호출 | save() N번 호출 (동일) |
| 트랜잭션 (@Tx 없을 때) | N번 | 1번 |
| 성능 차이 원인 | 트랜잭션 AOP 반복 | 트랜잭션 1번 |
| JDBC Batch 적용 | @Tx + batch_size + SEQUENCE 필요 | batch_size + SEQUENCE 필요 |
save()가 느린 이유 우선순위 (이번 실험 기준)
- 트랜잭션 반복 비용 — AOP doBegin/doCommit이 N번 실행 (Flame Graph에서 직접 확인)
- 네트워크 왕복 — batch_size 설정으로 추가 개선 가능 (CPU profile로는 단정 불가)
"saveAll()이 더 빠릅니다" — 맞는 말이다.
"INSERT를 한 방에 날려서요" — 틀린 이유다.
이번 실험 기준을 saveAll() 이 빠른 진짜 이유는 이거다.
saveAll() 은 하나의 @Transactional 안에서 모든 저장을 처리한다.
save() 반복은 (서비스 트랜잭션 없이) 호출할 때마다 트랜잭션이 반복된다.
그 차이가 AOP, JpaTransactionManager, Hikari Connection 비용을 n 배로 키운다.
save() 반복 방식도 서비스 메서드 명에 @Transactional 을 붙이면 saveAll() 과 거의 같은 성능이 나온다.
그렇다면 saveAll() 을 쓰는 이유는 성능 때문만이 아니다.
"다건 저장" 이라는 의도를 코드로 명확하게 표현하는데 있다.
다건 저장 성능을 개선할 때는 "어떤 메서드를 쓸까" 보다 "트랜잭션 경계가 어디에 있는가" 를 먼저 확인하는 것이 맞다.
이 글은 async-profiler CPU profile과 Flame Graph 실측 데이터를 기반으로 작성되었습니다.
측정 조건(서비스 메서드 @Transactional 없음)이 결과에 핵심적인 영향을 미쳤으며,
표현에서도 "이번 실험 기준", "이번 조건에서는" 등을 의도적으로 사용했습니다.
다른 환경에서는 결과가 다를 수 있으므로, 직접 측정해보는 것을 권장합니다.
'Spring' 카테고리의 다른 글
| 트랜잭션 전파 (0) | 2026.01.28 |
|---|---|
| 스프링 트랜잭션 전파 - 커밋, 롤백 (0) | 2026.01.17 |
| 스프링 트랜잭션 rollbackFor (1) | 2026.01.08 |
| Java 리플렉션과 스프링 DI 동작 원리 정리 : 직접 만들어보며 이해하기 (0) | 2025.09.27 |
| 프록시와 내부 호출 문제 (@Transactional self-invocation) (0) | 2025.09.21 |