🔗

JPA saveAll이 느린 이유와 개선 방향

최민석·2025-11-07

JPA saveAll이 느린 이유와 개선 방향

💡 “JPA를 포함한 ORM은 은총알이 될 수 없습니다.”

우리는 종종 ORM을 사용하면 SQL과 트랜잭션의 복잡함에서 해방될 거라 믿습니다. ORM은 데이터를 객체로 다루는 자유를 주지만, 동시에 성능과 제어의 일부를 포기하는 대가를 요구합니다.

Hibernate의 saveAll()은 한 줄의 코드로 모든 걸 해결할 수 있을 것처럼 보입니다. 그러나 그 한 줄 뒤에서 ORM은 수십, 수백 개의 INSERT를 순차적으로 실행하며 JDBC의 배치 성능을 스스로 봉인하고 있습니다.

기존 saveAll()은 엔티티 단위로 INSERT가 반복되어 네트워크 라운드트립과 엔티티 라이프사이클 오버헤드가 컸습니다.

JdbcTemplate.batchUpdate()로 전환하고 드라이버 배치 리라이트를 켜서 한 번의 배치로 밀어 넣으니 60초 → 7.3초로 단축됐습니다.

다대다 테이블을 매핑하는 가상의 TableMapper 엔티티의 예시를 통해 아래에 원인, 설정, 로그 차이, 주의사항을 정리하겠습니다.


배경: 기존 코드 vs 개선 코드

이전 (JPA saveAll)

다대다 엔티티인 A와 B를 이어주는 엔티티인 TableMapper가 있습니다. 엄청나게 많은 B에 대해 이 B들을 묶어주는 A를 만들었을 때, 이 매퍼 엔티티도 새로 생성해야 합니다. 만약 B가 만 개였다면 만 개를, 백만 개였다면 백만 개를 만들어야 합니다.

@Override
public Long saveAllMapper(List<TableMapper> mappers) {
    return (long) tableMapperJpaRepository.saveAll(
        mappers.stream().map(tableMapperMapper::toEntity).toList()
    ).size();
}

실제 로그(발췌)

JPA/Hibernate가 엔티티 개수만큼 INSERT를 날립니다.

Hibernate: insert into table_mapper (...) values (?,?,?,?,?,?)
Hibernate: insert into table_mapper (...) values (?,?,?,?,?,?)
Hibernate: insert into table_mapper (...) values (?,?,?,?,?,?)
... (반복)

이후 (JdbcTemplate 배치)

@Override
public Long saveAllMapper(List<TableMapper> mappers) {
    // created_at/updated_at 직접 세팅
    final String sql = "INSERT INTO table_mapper (created_at, deleted_at, tm_first_pk, tm_second_pk, tm_type, updated_at) VALUES (?, ?, ?, ?, ?, ?)";
    final Timestamp now = Timestamp.from(Instant.now());

    int[] result = jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
        public void setValues(PreparedStatement ps, int i) throws SQLException {
            TableMapper m = mappers.get(i);
            ps.setTimestamp(1, now);
            ps.setTimestamp(2, null);
            ps.setLong(3, m.getTmFirstPk());
            ps.setLong(4, m.getTmSecondPk());
            ps.setString(5, m.getTmType().name());
            ps.setTimestamp(6, now);
        }
        public int getBatchSize() { return mappers.size(); }
    });

    long inserted = Arrays.stream(result)
        .filter(r -> r == Statement.SUCCESS_NO_INFO || r > 0)
        .count();
    return inserted;
}

실제 로그(발췌)

Hibernate 로그는 거의 없고, Spring JDBC 로그가 핵심입니다.

DEBUG o.s.jdbc.core.JdbcTemplate : Executing SQL batch update [INSERT INTO table_mapper (...)]
DEBUG o.s.jdbc.core.JdbcTemplate : Executing prepared SQL statement [INSERT INTO table_mapper (...)]

왜 saveAll()은 INSERT를 반복 호출했나

JPA의 saveAll()은 내부적으로 다음 과정을 거칩니다:

  1. 엔티티 매핑/검증 (변환, null 체크)
  2. ID 생성 전략 고려
    • GenerationType.IDENTITY인 경우 INSERT를 즉시 수행하고 키를 회수해야 하므로 배치가 깨지거나 효과가 약해집니다.
  3. 영속성 컨텍스트 합류 (1차 캐시)
  4. 라이프사이클 콜백 (@PrePersist 등)
  5. 플러시 타이밍에 엔티티 한 건씩 INSERT (혹은 소배치)

결과적으로 엔티티 수(=N)만큼 SQL이 반복되고, 매번 네트워크 라운드트립과 Hibernate 계층 오버헤드가 발생합니다.

Hibernate도 배치 처리가 가능하지만(아래 설정 참고), IDENTITY 전략, 관계 매핑, 콜백, flush 타이밍 등의 제약이 겹치면 기대만큼 한 번에 합쳐지지 않습니다.


JDBC 배치가 문제를 어떻게 우회했나

JdbcTemplate.batchUpdate()는 엔티티 관리, 콜백, 영속성 컨텍스트를 건너뜁니다. 즉:

  • PreparedStatement.addBatch()로 파라미터만 쌓습니다.
  • 마지막에 executeBatch()로 한 번에 보냅니다.
  • MySQL 드라이버의 rewriteBatchedStatements=true가 켜져 있으면 다수의 VALUES를 갖는 멀티-밸류 INSERT로 재작성되어 라운드트립이 1회 수준으로 감소합니다.
  • 전송 페이로드도 효율화됩니다.

핵심 효과: 네트워크 왕복 횟수 급감 + ORM 계층 오버헤드 제거 → 즉시 체감 성능 향상.


로그로 확인하는 방법

application.properties

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE   # 바인딩 값까지
logging.level.org.springframework.jdbc.core.JdbcTemplate=DEBUG
logging.level.org.springframework.jdbc.core.StatementCreatorUtils=TRACE # 바인딩 값 확인용

JDBC 배치를 위해 필요한 설정 (체크리스트)

공통/드라이버 설정

  • JDBC URL에 다음 옵션 추가:
    • useServerPrepStmts=true
    • rewriteBatchedStatements=true ← 필수
  • 예: jdbc:mysql://.../db?useSSL=true&useUnicode=true&useServerPrepStmts=true&rewriteBatchedStatements=true&zeroDateTimeBehavior=convertToNull&maxQuerySizeToLog=999999

JdbcTemplate 배치 사용 시

  • 위 드라이버 옵션만으로 충분합니다.
  • 추가로 트랜잭션 경계를 서비스 메서드에 잡아 한 배치가 하나의 트랜잭션으로 커밋되게 합니다.
@Transactional
public Long saveAllMapper(...) { ... }

실제 결과 차이와 원인

구분 방식 네트워크 라운드트립 계층 오버헤드 예상 로그 측정 시간
개선 전 JPA saveAll() N회 큼 (엔티티 라이프사이클/플러시) Hibernate: insert ... N줄 60초
개선 후 JdbcTemplate.batchUpdate() + 드라이버 리라이트 ~1회 작음 (ORM 우회) JdbcTemplate: Executing SQL batch update 1~수줄 7.3초

차이가 나는 이유

  1. 라운드트립 횟수: N → 1 (또는 극소)
  2. ORM 오버헤드 제거: 엔티티 변환/영속/콜백/플러시 비용 제거
  3. 드라이버 최적화: rewriteBatchedStatements가 멀티-밸류 INSERT로 재작성