Spring

Spring Data JPA save() vs saveAll(), 진짜 차이는 SQL 이 아니라 트랜잭션 경계였다

경딩 2026. 5. 25. 02:04

부제 : 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()가 느린 이유 우선순위 (이번 실험 기준)

  1. 트랜잭션 반복 비용 — AOP doBegin/doCommit이 N번 실행 (Flame Graph에서 직접 확인)
  2. 네트워크 왕복 — 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 없음)이 결과에 핵심적인 영향을 미쳤으며,
표현에서도 "이번 실험 기준", "이번 조건에서는" 등을 의도적으로 사용했습니다.
다른 환경에서는 결과가 다를 수 있으므로, 직접 측정해보는 것을 권장합니다.