Spring JPA Batch Insert 최적화

왜 JPA Batch Insert가 느린가?

Spring Data JPA의 saveAll()은 내부적으로 각 엔티티에 대해 개별 INSERT를 실행한다. 10,000건을 저장하면 10,000개의 INSERT 쿼리가 발생한다. 이는 네트워크 왕복(round-trip)과 트랜잭션 오버헤드로 인해 심각한 성능 저하를 유발한다.

JPA Batch Insert의 성능 문제는 크게 세 가지 원인으로 나뉜다:

  • ID 생성 전략: IDENTITY 전략은 배치 INSERT를 완전히 무력화한다
  • Hibernate 배치 설정 미비: 기본값은 배치가 비활성화되어 있다
  • 영속성 컨텍스트 관리: 대량 엔티티가 1차 캐시에 쌓여 메모리 폭발

IDENTITY vs SEQUENCE: 배치의 핵심

@GeneratedValue(strategy = GenerationType.IDENTITY)를 사용하면 Hibernate는 절대 배치 INSERT를 수행하지 않는다. IDENTITY 전략은 INSERT 후 DB가 생성한 ID를 즉시 알아야 하므로, 각 INSERT를 개별 실행해야 하기 때문이다.

// ❌ 배치 INSERT 불가능
@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
}

// ✅ 배치 INSERT 가능
@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE,
                    generator = "order_seq")
    @SequenceGenerator(name = "order_seq",
                       sequenceName = "order_sequence",
                       allocationSize = 50)
    private Long id;
}

allocationSize = 50은 Hibernate가 한 번에 50개의 ID를 미리 할당받는다는 의미다. DB에 매번 시퀀스를 조회하지 않으므로 성능이 대폭 향상된다.

MySQL에서 SEQUENCE 대체: TABLE 전략

MySQL은 네이티브 SEQUENCE를 지원하지 않는다(8.0+에서도 불가). 대안으로 TABLE 전략 또는 UUID를 사용한다:

// MySQL: UUID 기반 ID (배치 INSERT 가능)
@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
}

// MySQL: TABLE 전략 (성능은 SEQUENCE보다 낮음)
@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.TABLE,
                    generator = "order_gen")
    @TableGenerator(name = "order_gen",
                    table = "id_generator",
                    pkColumnValue = "order",
                    allocationSize = 50)
    private Long id;
}

Hibernate 배치 설정

SEQUENCE/TABLE/UUID로 ID 전략을 변경한 후, Hibernate 배치 설정을 활성화한다:

# application.yml
spring:
  jpa:
    properties:
      hibernate:
        # 배치 크기 (한 번에 묶어서 전송할 INSERT 수)
        jdbc.batch_size: 50
        # INSERT 문을 같은 테이블끼리 그룹핑
        order_inserts: true
        # UPDATE 문도 그룹핑
        order_updates: true
        # 배치 버전 UPDATE 활성화
        jdbc.batch_versioned_data: true
설정 기본값 설명
jdbc.batch_size 0 (비활성) 한 번에 묶는 Statement 수
order_inserts false 같은 엔티티 INSERT를 연속 배치
order_updates false 같은 엔티티 UPDATE를 연속 배치

주의: order_insertsfalse이면, 서로 다른 엔티티의 INSERT가 섞여서 배치가 깨진다. 반드시 true로 설정하라.

영속성 컨텍스트 관리: flush + clear

10만 건을 한 번에 saveAll()하면, 모든 엔티티가 1차 캐시에 쌓여 OOM이 발생할 수 있다. 주기적으로 flush + clear로 메모리를 해제해야 한다:

@Service
@RequiredArgsConstructor
public class OrderBulkService {

    private final EntityManager em;

    @Transactional
    public void bulkInsert(List<OrderCreateDto> dtos) {
        int batchSize = 50;

        for (int i = 0; i < dtos.size(); i++) {
            Order order = Order.from(dtos.get(i));
            em.persist(order);

            // batch_size 단위로 flush + clear
            if ((i + 1) % batchSize == 0) {
                em.flush();
                em.clear();
            }
        }
        // 나머지 처리
        em.flush();
        em.clear();
    }
}

flush()는 쌓인 SQL을 DB에 전송하고, clear()는 1차 캐시를 비운다. batchSizejdbc.batch_size와 동일하게 맞추는 것이 최적이다.

JdbcTemplate: 극한 성능이 필요할 때

JPA 배치로도 부족한 극한의 성능이 필요하면, JdbcTemplatebatchUpdate()를 직접 사용한다:

@Repository
@RequiredArgsConstructor
public class OrderJdbcRepository {

    private final JdbcTemplate jdbcTemplate;

    public void bulkInsert(List<Order> orders) {
        String sql = "INSERT INTO orders (id, customer_id, amount, status, created_at) "
                   + "VALUES (?, ?, ?, ?, ?)";

        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i)
                    throws SQLException {
                Order o = orders.get(i);
                ps.setObject(1, o.getId());
                ps.setLong(2, o.getCustomerId());
                ps.setBigDecimal(3, o.getAmount());
                ps.setString(4, o.getStatus().name());
                ps.setTimestamp(5, Timestamp.valueOf(o.getCreatedAt()));
            }

            @Override
            public int getBatchSize() {
                return orders.size();
            }
        });
    }
}

JPA 배치 대비 2~5배 빠르다. 영속성 컨텍스트를 거치지 않으므로 메모리 효율도 높다. 단, 엔티티 라이프사이클 이벤트(@PrePersist 등)가 동작하지 않는다는 점에 주의하라.

MySQL rewriteBatchedStatements

MySQL을 사용한다면 JDBC URL에 rewriteBatchedStatements=true를 추가하면, 여러 INSERT를 하나의 INSERT INTO ... VALUES (...), (...), (...)로 합쳐서 전송한다:

# application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mydb?rewriteBatchedStatements=true
    # PostgreSQL은 기본적으로 배치 최적화가 잘 되어 있어 별도 설정 불필요

이 한 줄로 MySQL 배치 INSERT 성능이 5~10배 향상된다.

Spring Data JPA saveAll() 개선

saveAll()은 내부적으로 save()를 반복 호출하는데, 각 호출마다 entityManager.merge() 또는 persist()를 판단한다. 새 엔티티임이 확실하면 이 판단을 생략할 수 있다:

// Persistable 구현으로 isNew() 판단 최적화
@Entity
public class Order implements Persistable<UUID> {

    @Id
    private UUID id;

    @Transient
    private boolean isNew = true;

    @Override
    public UUID getId() {
        return id;
    }

    @Override
    public boolean isNew() {
        return isNew;
    }

    @PostPersist
    @PostLoad
    void markNotNew() {
        this.isNew = false;
    }

    // 팩토리 메서드에서 ID 사전 할당
    public static Order create(OrderCreateDto dto) {
        Order order = new Order();
        order.id = UUID.randomUUID();
        order.isNew = true;
        return order;
    }
}

Persistable을 구현하면, saveAll()이 매번 SELECT로 존재 여부를 확인하지 않고 바로 persist()를 호출한다.

성능 벤치마크: 전략별 비교

10,000건 INSERT 기준 (PostgreSQL, 로컬 환경):

전략 소요 시간 비고
saveAll() + IDENTITY ~12초 개별 INSERT, 배치 불가
saveAll() + SEQUENCE + batch 50 ~2.5초 Hibernate 배치 활성화
EntityManager + flush/clear ~2초 메모리 효율적
JdbcTemplate batchUpdate ~0.5초 영속성 컨텍스트 무시
COPY (PostgreSQL 전용) ~0.1초 최고 성능, JPA 영역 아님

배치 INSERT 디버깅

배치가 실제로 동작하는지 확인하는 방법:

# 로그로 확인 (SQL 카운트 비교)
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.engine.jdbc.batch.internal: TRACE
    # "Executing batch size: 50" 로그가 보이면 성공

# datasource-proxy로 정밀 측정
# 실제 DB에 전송된 쿼리 수를 카운트
spring:
  datasource:
    url: jdbc:p6spy:postgresql://localhost:5432/mydb

실전 가이드라인

  • 1,000건 이하: saveAll() + SEQUENCE + batch 설정으로 충분
  • 1,000~10만 건: EntityManager flush()/clear() 패턴 사용
  • 10만 건 이상: Spring Batch 또는 JdbcTemplate batchUpdate() 사용
  • 100만 건 이상: DB 네이티브 벌크 로드 (COPY, LOAD DATA INFILE) 사용

정리

JPA Batch INSERT 최적화의 핵심은 세 가지다: IDENTITY 전략 회피(SEQUENCE/UUID 사용), Hibernate 배치 설정 활성화(batch_size + order_inserts), 영속성 컨텍스트 주기적 비우기(flush/clear). 이 세 가지만 적용해도 대량 INSERT 성능이 5~10배 향상된다. 극한의 성능이 필요하면 JdbcTemplate으로 내려가되, 트레이드오프를 인지하고 선택하라.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux