mLog 유령 캐릭터가 단건 데이터와 묶음 데이터를 데이터베이스로 전송하며 JPA 배치 INSERT를 최적화하는 3D 일러스트

JPA saveAll 1만 5천 건이 2분 걸린 이유: PostgreSQL 배치 INSERT 최적화와 MyBatis 비교

개발 Spring Boot 2026년 9월 3일

Spring Boot와 PostgreSQL을 사용하는 프로젝트에서 약 1만 5천 건을 한꺼번에 등록할 일이 생겼습니다. 같은 데이터를 JPA saveAll()로 넣으면 약 2분이 걸렸고, MyBatis foreach로 100건씩 나눠 다중 행 INSERT를 실행하면 약 15초에 끝났습니다. 단순 계산으로도 8배 차이였습니다.

처음에는 “JPA는 영속성 컨텍스트 때문에 원래 느리다”라고 결론 내리기 쉬웠습니다. 실제로는 하나의 원인이 아니라 여러 층의 비용이 겹쳐 있었습니다.

  • PK는 @GeneratedValue가 아니라 애플리케이션에서 직접 채번했습니다.
  • 엔티티는 @IdClass 복합키를 사용했습니다.
  • saveAll() 앞에서 예상하지 못한 SELECT가 반복됐습니다.
  • Persistable을 적용했지만 Lombok 빌더가 신규 상태 플래그를 바꿨습니다.
  • Hibernate 배치 설정을 넣어도 P6Spy에는 SQL이 한 건씩 보이거나 아예 보이지 않았습니다.
  • 설정을 여러 번 확인했는데도 실행 시간이 줄지 않는 구간이 있었습니다.

이 경험에서 가장 크게 배운 점은 JPA, JDBC 배치, PostgreSQL 드라이버 재작성, 다중 행 SQL을 같은 기능으로 보면 안 된다는 것입니다. 설정 하나를 복사하기 전에 어느 단계에서 시간이 사라지는지 분리해서 측정해야 합니다.

3줄 요약

  • 직접 할당한 복합키가 이미 채워진 엔티티는 Spring Data JPA가 기존 엔티티로 판단해 merge() 경로를 선택할 수 있으며, 이때 예상하지 못한 SELECT가 발생할 수 있습니다.
  • Persistable.isNew()persist() 경로를 선택하게 할 뿐입니다. 실제 JDBC 배치를 만들려면 Hibernate 배치 설정, 호환되는 SQL 형태, 올바른 트랜잭션 경계가 함께 필요합니다.
  • P6Spy 출력은 JDBC 관찰 로그이지 PostgreSQL에 전달된 네트워크 패킷의 증거가 아닙니다. 로그 모양보다 실행 시간, 처리량, Hibernate 통계와 DB 측 통계를 함께 봐야 합니다.

목차

  1. 2분 대 15초, 먼저 비교 조건부터 맞춘다
  2. 대량 INSERT에서 구분해야 할 네 단계
  3. MyBatis foreach가 빨랐던 진짜 이유
  4. saveAll은 왜 SELECT 후 INSERT가 될까
  5. Persistable로 신규 엔티티를 정확히 알리는 방법
  6. Lombok Builder가 isNew 플래그를 바꾸는 함정
  7. Persistable 적용 후에도 SELECT가 남는 이유
  8. Hibernate와 PostgreSQL 배치 설정을 정확히 넣기
  9. 트랜잭션 경계가 배치에 미치는 영향
  10. flush와 clear로 영속성 컨텍스트를 제어하기
  11. P6Spy 로그를 배치 성공 증거로 보면 안 되는 이유
  12. batch_versioned_data는 언제 필요한가
  13. 원인을 한 단계씩 분리하는 검증 절차
  14. JPA·MyBatis·JDBC·COPY 중 무엇을 선택할까
  15. 자주 실패하는 패턴과 수정 방향
  16. 최종 체크리스트

1. 2분 대 15초, 먼저 비교 조건부터 맞춘다

JPA 2분, MyBatis 15초라는 결과는 중요한 출발점이지만 곧바로 기술의 우열을 증명하지는 않습니다. 비교한 두 코드가 DB에 전달하는 SQL의 형태와 부가 작업이 다르기 때문입니다.

JPA 쪽에서는 다음 비용이 들어갈 수 있습니다.

  • 각 엔티티가 신규인지 판단하는 비용
  • persist() 또는 merge() 선택
  • 영속성 컨텍스트 등록과 상태 관리
  • 엔티티 콜백, 감사 필드, 연관관계 cascade 처리
  • SQL 문장 정렬과 JDBC 배치 구성
  • 트랜잭션 종료 시 flush

반면 MyBatis foreach로 만든 다중 행 INSERT는 보통 하나의 SQL 안에 여러 VALUES를 직접 붙입니다.

INSERT INTO order_line (order_id, line_no, product_id, quantity)
VALUES
  (?, ?, ?, ?),
  (?, ?, ?, ?),
  (?, ?, ?, ?);

따라서 비교 전에는 최소한 다음 조건을 맞춰야 합니다.

  • 같은 PostgreSQL 인스턴스와 같은 네트워크 경로인가?
  • 동일한 테이블, 인덱스, 제약조건, 트리거를 사용하는가?
  • 같은 데이터와 같은 행 수인가?
  • 두 테스트 모두 같은 트랜잭션 정책인가?
  • P6Spy와 SQL 콘솔 로그의 켜짐 여부가 같은가?
  • DTO 생성, 파일 읽기, 검증 시간까지 측정에 섞이지 않았는가?
  • 첫 실행의 JVM·커넥션 준비 비용과 워밍업 이후 실행을 구분했는가?

측정 구간도 명확히 잡습니다. 특히 트랜잭션 안쪽에서 시간을 재면 실제 커밋 전까지만 측정할 수 있습니다. 아래처럼 트랜잭션 프록시 바깥에서 공개 서비스 메서드를 호출해 반환 시점의 커밋까지 포함합니다.

long startedAt = System.nanoTime();

orderLineImportService.importRows(rows);

long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000;
double rowsPerSecond = rows.size() / (elapsedMs / 1000.0);

log.info("rows={}, elapsedMs={}, rowsPerSecond={}",
        rows.size(), elapsedMs, rowsPerSecond);

한 번의 숫자 대신 워밍업 후 최소 3회 이상 실행한 중앙값을 비교하는 편이 좋습니다. 대량 쓰기는 체크포인트, WAL, 캐시, 다른 트랜잭션의 영향도 받기 때문입니다.

목차로 돌아가기

2. 대량 INSERT에서 구분해야 할 네 단계

배치 문제를 풀려면 “묶어서 넣는다”는 말을 네 단계로 나눠야 합니다.

2-1. Spring Data JPA의 saveAll

saveAll()은 전달한 모든 엔티티를 저장하겠다는 저장소 API입니다. 다중 행 SQL 한 문장을 보장하는 API가 아닙니다. 각 엔티티는 신규 상태 판단을 거쳐 persist() 또는 merge() 경로로 들어갑니다.

Spring Data JPA 공식 문서는 신규 엔티티이면 EntityManager.persist(), 그렇지 않으면 EntityManager.merge()를 호출한다고 설명합니다.

2-2. Hibernate의 쓰기 지연과 JDBC batch

Hibernate는 SQL을 즉시 실행하지 않고 flush 시점까지 모을 수 있습니다. hibernate.jdbc.batch_size가 활성화되고 같은 형태의 PreparedStatement가 이어지면 JDBC addBatch()executeBatch()를 이용해 여러 실행을 묶습니다.

여기서도 SQL 문자열은 한 행짜리 INSERT ... VALUES (?)처럼 보일 수 있습니다. JDBC batch는 같은 준비 문장을 여러 파라미터 세트로 실행하는 방식이기 때문입니다.

2-3. pgJDBC의 reWriteBatchedInserts

PostgreSQL JDBC 드라이버의 reWriteBatchedInserts=true이미 JDBC batch로 들어온 호환 가능한 INSERT를 여러 VALUES를 가진 문장으로 다시 쓸 수 있게 합니다. 이 옵션 자체가 JPA의 낱개 실행을 배치로 만들어 주지는 않습니다.

pgJDBC 공식 문서도 이 옵션의 기본값이 false이며, 배치 INSERT를 다중 행 형태로 바꾼다고 설명합니다.

2-4. MyBatis foreach 다중 행 SQL

MyBatis foreach는 문자열 생성 단계에서 여러 VALUES를 한 SQL로 만듭니다. JDBC batch와는 출발점이 다릅니다. MyBatis 동적 SQL 문서foreach가 컬렉션을 순회하며 구분자와 여닫는 문자열을 구성하는 기능임을 설명합니다.

정리하면 다음과 같습니다.

  • saveAll()은 저장 API입니다.
  • Hibernate JDBC batch는 같은 준비 문장의 여러 실행을 묶습니다.
  • pgJDBC rewrite는 이미 만들어진 batch를 다중 행 INSERT로 최적화합니다.
  • MyBatis foreach는 애초에 다중 행 SQL을 생성할 수 있습니다.

이 네 단계를 구분하면 “로그가 한 줄씩 보이니 batch_size가 실패했다” 같은 성급한 결론을 피할 수 있습니다.

목차로 돌아가기

3. MyBatis foreach가 빨랐던 진짜 이유

이번 사례에서 MyBatis는 100건씩 잘라 다음과 같은 문장을 약 150회 실행했습니다.

<insert id="insertBatch">
    INSERT INTO order_line (
        order_id,
        line_no,
        product_id,
        quantity
    )
    VALUES
    <foreach collection="rows" item="row" separator=",">
        (
            #{row.orderId},
            #{row.lineNo},
            #{row.productId},
            #{row.quantity}
        )
    </foreach>
</insert>

JDBC batch가 적용되지 않은 1만 5천 번의 개별 실행과 150번의 다중 행 실행은 네트워크 왕복과 문장 실행 횟수에서 큰 차이를 만들 수 있습니다. 여기에 JPA의 merge() 경로에서 조회까지 붙었다면 차이는 더 커집니다.

그렇다고 MyBatis가 항상 JPA보다 8배 빠르다는 뜻은 아닙니다. JPA가 persist() 경로를 타고 JDBC batch와 드라이버 rewrite가 정상 적용되면 격차가 크게 줄 수 있습니다. 반대로 MyBatis도 한 SQL에 지나치게 많은 행을 넣으면 다음 문제가 생깁니다.

  • SQL과 바인드 파라미터가 너무 커집니다.
  • 오류가 발생한 행을 찾기 어려워집니다.
  • 메모리 사용량과 파싱 부담이 커집니다.
  • PostgreSQL 프로토콜과 드라이버의 파라미터 한도에 가까워질 수 있습니다.
  • 한 트랜잭션이 너무 길어져 락과 WAL 부담이 커질 수 있습니다.

따라서 100이라는 값도 정답이 아니라 시작점입니다. 50, 100, 200처럼 실제 행 크기에 맞춰 비교해야 합니다.

목차로 돌아가기

4. saveAll은 왜 SELECT 후 INSERT가 될까

처음에는 GenerationType.IDENTITY가 Hibernate INSERT batching을 막았을 가능성을 의심했습니다. 실제로 Hibernate 공식 문서는 IDENTITY 식별자 생성기를 쓰면 INSERT의 JDBC batching을 비활성화한다고 설명합니다.

하지만 이번 엔티티는 @GeneratedValue를 사용하지 않았습니다. 애플리케이션이 PK를 직접 만들어 넣었고 @IdClass 복합키를 사용했습니다. 이때 더 중요한 것은 Spring Data JPA의 신규 엔티티 판별 방식입니다.

직접 할당한 복합키는 IDENTITY처럼 INSERT 직후 생성 키를 회수할 필요가 없으므로 복합키 자체가 Hibernate batching을 막지는 않습니다. 다만 복합키 값이 저장 전부터 채워져 있어 Spring Data 저장소가 신규 객체로 판단하지 못할 수 있다는 문제는 별개입니다. 따라서 “복합키라서 배치 불가”가 아니라 저장소가 persist()merge() 중 어느 경로를 선택했는지 확인해야 합니다.

기본 판별은 다음 순서입니다.

  1. nullable한 @Version 속성이 있으면 값이 null인지 봅니다.
  2. 그런 버전 속성이 없으면 식별자 값이 null인지 봅니다.
  3. 식별자가 이미 채워져 있으면 기존 엔티티로 판단할 수 있습니다.

직접 할당한 복합키는 저장 전부터 값이 모두 존재합니다. 버전 속성으로 신규 여부를 판단하지 않는다면 Spring Data는 새 객체인데도 “기존 엔티티”로 볼 수 있습니다. 그러면 persist()가 아니라 merge()가 호출됩니다.

merge()는 전달한 객체 자체를 관리 상태로 바꾸는 기능이 아닙니다. 상태를 관리 인스턴스에 복사하고 그 관리 인스턴스를 반환합니다. 엔티티 상태와 매핑에 따라 기존 행이나 연관 그래프를 확인하는 조회가 발생할 수 있습니다.

즉, 다음과 같이 단정하면 안 됩니다.

직접 할당 ID를 쓰면 언제나 정확히 1건의 SELECT 후 INSERT가 실행된다.

정확한 표현은 다음입니다.

직접 할당 ID와 버전 속성이 없는 엔티티는 기본 신규 판별에서 기존 엔티티로 분류되어 merge() 경로를 탈 수 있고, 그 결과 현재 엔티티나 연관 엔티티를 확인하는 SELECT가 발생할 수 있다.

로그에서 먼저 확인할 것은 SELECT의 존재보다 어느 테이블을 어떤 조건으로 조회했는지입니다.

SELECT ...
FROM order_line
WHERE order_id = ?
  AND line_no = ?;

이처럼 대상 테이블을 복합키로 조회한다면 신규 판별과 merge 경로를 우선 의심합니다. 부모나 코드 테이블을 조회한다면 연관관계, 리스너, 검증 로직을 따로 봐야 합니다.

목차로 돌아가기

5. Persistable로 신규 엔티티를 정확히 알리는 방법

Spring Data JPA는 직접 할당 식별자를 쓰는 엔티티를 위해 Persistable을 통한 신규 상태 판별을 지원합니다. 엔티티가 Persistable을 구현하면 저장소는 isNew()의 결과를 사용합니다.

Spring Boot 3와 jakarta.persistence 기준 예시는 다음과 같습니다.

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.PostLoad;
import jakarta.persistence.PostPersist;
import jakarta.persistence.Transient;
import org.springframework.data.domain.Persistable;

@Entity
@IdClass(OrderLineId.class)
public class OrderLine implements Persistable<OrderLineId> {

    protected OrderLine() {
    }

    @Id
    @Column(name = "order_id", nullable = false)
    private Long orderId;

    @Id
    @Column(name = "line_no", nullable = false)
    private Integer lineNo;

    @Column(name = "product_id", nullable = false)
    private Long productId;

    @Column(name = "quantity", nullable = false)
    private Integer quantity;

    @Transient
    private boolean newEntity = true;

    @Override
    public OrderLineId getId() {
        return new OrderLineId(orderId, lineNo);
    }

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

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

복합키 클래스도 JPA 요구사항에 맞춰 구현합니다.

import java.io.Serializable;
import java.util.Objects;

public class OrderLineId implements Serializable {

    private Long orderId;
    private Integer lineNo;

    protected OrderLineId() {
    }

    public OrderLineId(Long orderId, Integer lineNo) {
        this.orderId = orderId;
        this.lineNo = lineNo;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) return true;
        if (!(other instanceof OrderLineId that)) return false;
        return Objects.equals(orderId, that.orderId)
                && Objects.equals(lineNo, that.lineNo);
    }

    @Override
    public int hashCode() {
        return Objects.hash(orderId, lineNo);
    }
}

isNew()true이면 Spring Data는 persist()를 선택합니다. 그러나 이 플래그는 엔티티의 생명주기와 일치해야 합니다.

  • 새로 생성한 등록 객체는 true여야 합니다.
  • DB에서 로딩된 객체는 @PostLoad 이후 false여야 합니다.
  • 저장이 끝난 객체는 @PostPersist 이후 false여야 합니다.
  • 기존 PK로 객체를 새로 만들어 save()를 호출하는 우회 업데이트 패턴에는 사용하면 안 됩니다.

isNew()를 무조건 true로 반환하면 SELECT를 없앨 수 있다는 조언도 있습니다. 하지만 공용 엔티티에 적용하면 업데이트까지 INSERT로 처리해 PK 중복 오류를 만들 수 있습니다. 이 방식은 INSERT 전용 모델이나 제한된 성능 실험이 아니라면 권장하기 어렵습니다.

대량 등록 경로가 완전히 분리돼 있다면 커스텀 writer에서 EntityManager.persist()를 직접 호출하는 편이 의도를 더 분명하게 드러냅니다.

목차로 돌아가기

6. Lombok Builder가 isNew 플래그를 바꾸는 함정

Persistable을 적용하고 newEntity = true로 초기화했는데도 isNew()false를 반환했습니다. 원인은 Lombok @Builder였습니다.

Lombok 빌더는 별도 빌더 객체에 필드 값을 모아 생성자를 호출합니다. 빌더에서 값이 지정되지 않은 필드는 일반적인 필드 초기화식을 기대한 방식으로 유지하지 않을 수 있습니다. Lombok 공식 @Builder 문서는 기본값을 유지하려면 @Builder.Default를 사용하도록 안내합니다.

@Transient
@Builder.Default
private boolean newEntity = true;

다음 테스트를 추가하면 회귀를 빨리 발견할 수 있습니다.

@Test
void builderCreatesNewEntity() {
    OrderLine entity = OrderLine.builder()
            .orderId(100L)
            .lineNo(1)
            .productId(200L)
            .quantity(3)
            .build();

    assertThat(entity.isNew()).isTrue();
}

더 안전한 방법은 대량 등록 객체의 생성 경로를 정적 팩터리로 제한하는 것입니다.

public static OrderLine create(
        Long orderId,
        Integer lineNo,
        Long productId,
        Integer quantity
) {
    OrderLine entity = new OrderLine();
    entity.orderId = orderId;
    entity.lineNo = lineNo;
    entity.productId = productId;
    entity.quantity = quantity;
    entity.newEntity = true;
    return entity;
}

이 방식은 신규 상태를 생성 규칙 안에 가두고, 호출부가 실수로 newEntity=false를 넣지 못하게 합니다.

또 하나 주의할 점은 Lombok이 Persistable.isNew()와 충돌하는 메서드를 자동으로 만든다고 막연히 단정하지 않는 것입니다. 실제로 더 흔한 문제는 빌더가 필드 기본값을 유지하지 못한 것입니다. 컴파일된 메서드와 테스트 결과로 확인해야 합니다.

목차로 돌아가기

7. Persistable 적용 후에도 SELECT가 남는 이유

isNew()가 확실히 true인데 SELECT가 계속 보인다면, 그 조회가 어떤 경로에서 나왔는지 분리해야 합니다. markNotNew() 로그가 찍혔다는 사실만으로 merge가 실행됐다고 결론 낼 수도 없습니다. @PostPersist@PostLoad 중 어느 콜백이 호출됐는지 구분해야 하기 때문입니다.

대상 테이블을 다시 조회하는 경우

먼저 isNew()에 임시 로그나 디버거를 걸어 실제 반환값을 확인합니다. Spring Data 저장소가 아닌 다른 저장 경로를 호출하거나, 엔티티 타입과 Persistable의 ID 타입이 맞지 않거나, 생성 과정에서 플래그가 바뀌었을 수 있습니다.

연관 엔티티를 조회하는 경우

다음과 같은 요소가 추가 SELECT를 만들 수 있습니다.

  • merge()가 연관 그래프를 복사하는 과정
  • cascade로 함께 처리되는 부모·자식 엔티티
  • 엔티티 리스너와 감사 로직
  • Bean Validation 또는 서비스 계층의 중복 확인
  • 프록시가 초기화되는 getter, toString(), JSON 직렬화
  • DB 존재 여부를 확인하는 애플리케이션 코드

대량 등록에서 부모 엔티티의 식별자만 필요하다면 매 행마다 부모를 조회하지 말고 참조 프록시를 재사용할 수 있습니다.

Parent parentRef = entityManager.getReference(Parent.class, parentId);

for (RowDto row : rows) {
    OrderLine entity = OrderLine.create(
            row.orderId(),
            row.lineNo(),
            row.productId(),
            row.quantity()
    );
    entity.assignParent(parentRef);
    entityManager.persist(entity);
}

getReference()도 만능은 아닙니다. 프록시의 일반 속성을 읽거나 toString()이 연관 필드를 건드리면 SELECT가 발생할 수 있고, 참조한 ID가 실제로 없다면 flush 시 FK 오류가 납니다.

가장 중요한 진단 질문은 단순합니다.

  1. SELECT 대상이 지금 INSERT할 테이블인가?
  2. WHERE 조건이 복합 PK인가?
  3. SELECT 바로 앞의 호출 스택은 save()인가, 연관 getter인가?
  4. isNew()가 호출됐고 실제로 true를 반환했는가?
  5. @PostLoad@PostPersist 로그를 구분했는가?

목차로 돌아가기

8. Hibernate와 PostgreSQL 배치 설정을 정확히 넣기

SELECT를 제거해도 INSERT가 실제로 묶이지 않으면 왕복 횟수는 크게 줄지 않습니다. Spring Boot 설정은 속성 계층을 정확히 맞춰야 합니다.

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/appdb?reWriteBatchedInserts=true

  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 100
        order_inserts: true
        order_updates: true

여기서 자주 틀리는 부분이 있습니다.

  • batch_size의 전체 키는 hibernate.jdbc.batch_size입니다.
  • order_inserts의 전체 키는 hibernate.order_inserts입니다. hibernate.jdbc 아래에 넣지 않습니다.
  • order_updates는 UPDATE batching이 필요할 때 의미가 있습니다.
  • JDBC URL에 기존 파라미터가 있다면 ?가 아니라 &로 이어 붙입니다.
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/appdb?sslmode=require&reWriteBatchedInserts=true

각 설정의 역할도 다릅니다.

hibernate.jdbc.batch_size

Hibernate가 한 JDBC batch에 모을 수 있는 문장 수를 정합니다. 기본값 0은 batching 비활성화입니다. Hibernate 공식 가이드에는 보통 10~50에서 시작하는 예가 나오지만, 실제 적정값은 행 크기와 환경에 따라 다릅니다. 이번처럼 100으로 이미 비교했다면 50·100·200을 같은 조건에서 측정합니다.

hibernate.order_inserts

엔티티 타입과 키를 기준으로 INSERT 순서를 정리해 같은 SQL이 연속되게 도와줍니다. 서로 다른 엔티티 INSERT가 번갈아 나오면 batch가 자주 끊길 수 있기 때문입니다. 정렬 비용도 있으므로 전후 성능을 측정해야 합니다.

reWriteBatchedInserts

pgJDBC가 호환되는 batched INSERT를 다중 행 형태로 다시 쓰게 합니다. pgJDBC 공식 문서는 이 옵션이 성능을 개선할 수 있다고 설명하지만, 효과 크기는 문장·행 크기·네트워크 환경에 따라 달라집니다. “이 옵션 하나만 넣으면 무조건 수십 초가 줄어든다”라고 말할 수는 없습니다.

IDENTITY 전략

Hibernate는 IDENTITY 식별자 생성기를 사용할 때 INSERT batching을 비활성화합니다. 하지만 직접 할당 복합키인 이번 사례에는 이 설명을 그대로 적용하면 안 됩니다. 먼저 실제 ID 전략을 확인해야 합니다.

서로 다른 SQL이 섞이는지 확인

동적 INSERT, 여러 엔티티 타입, 중간 flush, cascade INSERT가 섞이면 같은 문장을 충분히 모으지 못할 수 있습니다. @DynamicInsert로 행마다 포함 컬럼이 달라지는 경우도 batch grouping에 불리할 수 있으므로 실제 생성 SQL을 확인합니다.

목차로 돌아가기

9. 트랜잭션 경계가 배치에 미치는 영향

JDBC batch는 같은 세션과 트랜잭션 안에서 문장이 모일 때 가장 이해하기 쉽습니다. 대량 등록 유스케이스의 경계를 서비스 계층에 명시합니다.

@Service
@RequiredArgsConstructor
public class OrderLineImportService {

    private final OrderLineBulkWriter bulkWriter;

    @Transactional
    public void importRows(List<RowDto> rows) {
        bulkWriter.persistAll(rows, 100);
    }
}

다만 “서비스에 @Transactional이 없으면 saveAll()이 1만 5천 개의 트랜잭션을 만든다”는 설명은 정확하지 않습니다. Spring Data JPA의 저장소 쓰기 메서드 자체에도 트랜잭션 경계가 적용됩니다. 서비스 트랜잭션이 특히 중요한 경우는 다음과 같습니다.

  • 여러 저장소 호출을 하나의 원자적 작업으로 묶을 때
  • 커스텀 EntityManager.persist() 루프를 실행할 때
  • 여러 saveAll() 청크가 하나의 트랜잭션이어야 할 때
  • 저장 전후의 검증·상태 갱신까지 함께 롤백해야 할 때

반대로 1만 5천 건 전체를 무조건 하나의 아주 긴 트랜잭션으로 묶는 것도 정답은 아닙니다. 실패 시 전체 롤백이 필요한지, 1000건 단위 재시도가 가능한지에 따라 경계를 결정합니다.

Spring 프록시 기반 트랜잭션에서는 같은 객체 내부에서 this.importRows()처럼 자기 자신을 호출하면 @Transactional이 적용되지 않을 수 있습니다. 실제 트랜잭션 활성 여부도 확인합니다.

boolean active = TransactionSynchronizationManager
        .isActualTransactionActive();

log.debug("transactionActive={}", active);

목차로 돌아가기

10. flush와 clear로 영속성 컨텍스트를 제어하기

Hibernate 공식 가이드는 대량 INSERT 중 1차 캐시가 계속 커지지 않도록 주기적으로 flush()clear()를 실행하는 패턴을 제시합니다.

@Repository
@RequiredArgsConstructor
public class OrderLineBulkWriter {

    private final EntityManager entityManager;

    public void persistAll(List<RowDto> rows, int batchSize) {
        for (int i = 0; i < rows.size(); i++) {
            RowDto row = rows.get(i);

            entityManager.persist(OrderLine.create(
                    row.orderId(),
                    row.lineNo(),
                    row.productId(),
                    row.quantity()
            ));

            if ((i + 1) % batchSize == 0) {
                entityManager.flush();
                entityManager.clear();
            }
        }

        entityManager.flush();
        entityManager.clear();
    }
}

이 패턴의 효과는 두 가지입니다.

  • flush가 현재까지 모인 SQL을 JDBC로 보냅니다.
  • clear가 관리 중인 엔티티를 분리해 메모리 증가를 제한합니다.

주의할 점도 있습니다.

  • clear() 이후 기존 관리 엔티티의 변경 감지는 더 이상 동작하지 않습니다.
  • 지연 로딩 프록시를 나중에 접근하면 LazyInitializationException이 날 수 있습니다.
  • 한 청크 실패 시 어디까지 재시도할지 정책이 필요합니다.
  • batch size와 flush 간격을 반드시 같게 할 필요는 없지만, 처음에는 같게 두면 분석하기 쉽습니다.
  • 마지막 나머지 데이터도 flush해야 합니다.

saveAll() 한 번으로도 Hibernate batching은 가능하지만, 1만 5천 엔티티를 모두 영속성 컨텍스트에 유지하면 메모리와 dirty checking 부담이 늘어납니다. 대량 등록 전용 경로를 분리하는 이유입니다.

목차로 돌아가기

11. P6Spy 로그를 배치 성공 증거로 보면 안 되는 이유

배치 설정 후 가장 혼란스러웠던 부분은 P6Spy였습니다.

  • 설정 전에는 INSERT가 한 건씩 보였습니다.
  • 설정 후에도 여전히 한 건씩 보이는 것 같았습니다.
  • 어떤 테스트에서는 SQL 로그가 거의 사라졌습니다.

여기서 “한 건씩 보이면 배치 실패”, “로그가 안 보이면 배치 성공”으로 판단하면 안 됩니다.

P6Spy 공식 문서의 custom formatter에는 준비 문장 형태의 %(effectiveSql)과 바인드 값을 끼워 사람이 읽기 쉽게 재구성한 %(sql) 같은 항목이 있습니다. 특히 값이 들어간 %(sql)도 PostgreSQL 서버에 전달된 최종 네트워크 메시지나 pgJDBC rewrite 이후의 바이트를 그대로 보여 주는 패킷 캡처가 아닙니다.

또한 P6Spy의 출력은 다음 설정에 따라 달라집니다.

  • 포함·제외한 로그 category
  • SingleLineFormat, MultiLineFormat, custom format
  • SLF4J 로그 레벨과 appender
  • batch 이벤트를 출력하는 방식
  • executionThreshold 같은 기록 임계값
  • IDE 콘솔 버퍼와 한 줄 길이 제한

P6Spy에서는 addBatch()executeBatch() 이벤트가 모두 batch category로 기록될 수 있고, 기본 category 제외 설정이나 포맷에 따라 일부 이벤트가 숨거나 누적돼 보일 수도 있습니다. 따라서 INSERT 로그 한 줄을 DB 왕복 한 번으로 세면 안 됩니다. P6Spy는 어떤 JDBC 작업과 SQL이 애플리케이션에서 발생했는지 찾는 진단 도구로 쓰고, batch 왕복 횟수의 유일한 증거로 쓰지 않습니다.

성능 테스트에서는 SQL 로그를 끈다

1만 5천 행의 바인드 값을 콘솔에 쓰는 비용은 작지 않습니다. 기능 확인용 테스트와 성능 측정을 분리합니다.

  1. 작은 데이터로 SQL과 값이 맞는지 확인합니다.
  2. P6Spy와 show_sql을 끕니다.
  3. 같은 데이터로 처리 시간과 초당 행 수를 측정합니다.
  4. 필요하면 DB 측 통계로 문장 호출을 확인합니다.

PostgreSQL 측에서 확인한다

개발·검증 환경에서 pg_stat_statements를 사용할 수 있다면 서버에서 집계한 실행 횟수와 처리 행, 실행 시간을 교차 확인할 수 있습니다. 확장은 별도 설치·권한·설정이 필요합니다. 운영 전역 통계를 초기화하기보다 테스트 전후 값을 저장해 해당 DB·사용자·쿼리의 차이를 비교합니다.

SELECT
    queryid,
    query,
    calls,
    rows,
    total_exec_time,
    mean_exec_time,
    rows::numeric / NULLIF(calls, 0) AS rows_per_server_execution
FROM pg_stat_statements
WHERE query ILIKE 'insert into order_line%'
ORDER BY total_exec_time DESC;

PostgreSQL 공식 문서pg_stat_statements가 서버에서 실행된 SQL의 계획·실행 통계를 추적한다고 설명합니다.

여기서도 calls는 JDBC executeBatch() 횟수나 네트워크 왕복 횟수가 아닙니다. rows_per_server_execution 역시 JDBC batch size가 아니라 서버 문장 실행 한 번당 영향을 받은 평균 행 수입니다. Hibernate가 JDBC batch 경로를 탔는지는 hibernate.generate_statistics=true를 잠깐 적용한 검증 환경에서 세션 통계의 JDBC batches도 함께 확인하고, 최종 성능은 P6Spy를 끈 상태의 커밋 포함 wall-clock과 성공적으로 커밋된 초당 행 수로 판단합니다.

운영 DB에서 log_statement = 'all'을 켜는 방식은 권장하지 않습니다. 성능 부담뿐 아니라 개인정보와 민감한 바인드 값이 로그에 남을 수 있습니다.

목차로 돌아가기

12. batch_versioned_data는 언제 필요한가

hibernate.jdbc.batch_versioned_data를 켜면 모든 INSERT가 빨라진다는 설명도 정확하지 않습니다. 이 설정은 주로 @Version을 사용하는 버전 엔티티의 batched DML과 optimistic lock 행 수 검증에 관련됩니다.

@Version
private Long version;

영향을 판단할 때는 다음 순서로 봅니다.

  • 엔티티에 @Version이 있는가?
  • 문제의 작업이 순수 INSERT인가, 대량 UPDATE·DELETE인가?
  • 사용 중인 Hibernate 버전의 기본값과 지원 상태는 무엇인가?
  • JDBC 드라이버가 executeBatch()의 행 수를 신뢰할 수 있게 반환하는가?

버전별 차이도 큽니다. Hibernate 3.x·4.x에서는 기본값이 false였고, 5.0~6.x에서는 일반적으로 true로 바뀌었지만 6.6 공식 API는 Dialect에 따라 달라질 수 있다고 설명합니다. Hibernate 7.x의 공식 설정 목록에는 이 항목이 더 이상 노출되지 않습니다. 따라서 7.x 공통 설정 예제에 관성적으로 복사하지 말고 프로젝트가 실제 사용하는 버전의 문서를 확인해야 합니다.

결론은 간단합니다.

  • @Version이 없는 순수 INSERT라면 이 설정을 첫 번째 해결책으로 볼 이유가 없습니다.
  • @Version이 있는 UPDATE batching이라면 의미가 있을 수 있습니다.
  • 무조건 true로 복사하기보다 현재 Hibernate 문서를 확인하고 optimistic lock 검증 테스트를 해야 합니다.

목차로 돌아가기

13. 원인을 한 단계씩 분리하는 검증 절차

여러 설정을 한꺼번에 바꾸면 무엇이 효과가 있었는지 알 수 없습니다. 다음 순서로 한 변수씩 확인하는 편이 빠릅니다.

13-1. 기준 성능을 다시 측정한다

  • P6Spy와 SQL 콘솔 로그를 끕니다.
  • 같은 1만 5천 건을 사용합니다.
  • 테이블과 인덱스 상태를 동일하게 맞춥니다.
  • 총 시간과 초당 처리 행을 기록합니다.
  • 워밍업 후 세 번 이상 반복합니다.

13-2. SELECT 출처를 찾는다

데이터를 10건 정도로 줄이고 P6Spy를 켭니다.

  • 대상 테이블 PK SELECT인지 확인합니다.
  • 연관 테이블 SELECT인지 확인합니다.
  • 한 엔티티당 몇 번 반복되는지 셉니다.
  • isNew() 반환값과 콜백 종류를 기록합니다.

13-3. persist 경로만 검증한다

Persistable 또는 커스텀 EntityManager.persist()를 적용한 뒤 대상 테이블 사전 SELECT가 사라졌는지 확인합니다. 이 단계에서는 아직 속도가 MyBatis와 같아질 필요가 없습니다.

13-4. Hibernate JDBC batch를 켠다

먼저 hibernate.jdbc.batch_size=50 또는 100만 적용합니다. 같은 SQL이 연속되는지, 중간 flush나 서로 다른 엔티티 INSERT가 batch를 끊지 않는지 봅니다.

13-5. pgJDBC rewrite를 켠다

그다음 reWriteBatchedInserts=true를 추가해 전후 처리량을 비교합니다. 이 순서를 지키면 Hibernate batch 자체의 효과와 드라이버 rewrite 효과를 분리할 수 있습니다.

13-6. order_inserts를 비교한다

여러 엔티티 타입이 섞인 경우에만 특히 유용합니다. 정렬 비용이 있으므로 켜기 전후를 측정합니다.

13-7. flush·clear 간격을 조정한다

50, 100, 200 단위로 실행 시간과 메모리를 함께 비교합니다. 너무 작은 값은 왕복을 늘리고, 너무 큰 값은 영속성 컨텍스트와 문장 크기를 키울 수 있습니다.

13-8. DB 병목을 확인한다

JPA 경로를 정리했는데도 느리면 애플리케이션 밖을 봅니다.

  • INSERT 대상 인덱스 수
  • FK·UNIQUE·CHECK 제약조건
  • INSERT 트리거
  • WAL과 디스크 지연
  • 동시 트랜잭션의 락 대기
  • 커넥션 풀 대기
  • 같은 행에 대한 중복 검증 쿼리

이 절차의 목표는 “마법의 설정 조합”을 찾는 것이 아니라, 각 단계가 실제로 무엇을 바꿨는지 증명하는 것입니다.

목차로 돌아가기

14. JPA·MyBatis·JDBC·COPY 중 무엇을 선택할까

최적화가 끝나도 모든 대량 입력을 JPA로 통일할 필요는 없습니다. 데이터 성격에 맞춰 선택합니다.

JPA가 잘 맞는 경우

  • 도메인 규칙과 엔티티 콜백이 중요합니다.
  • 등록과 함께 연관관계를 일관되게 관리해야 합니다.
  • 처리량보다 유지보수성과 객체 모델의 일관성이 더 중요합니다.
  • JDBC batch로 목표 성능을 충분히 만족합니다.

MyBatis 다중 행 INSERT가 잘 맞는 경우

  • SQL 형태와 컬럼을 명시적으로 통제해야 합니다.
  • 한 번에 수십~수백 행을 넣는 작업이 반복됩니다.
  • JPA 생명주기나 dirty checking이 필요하지 않습니다.
  • 기존 프로젝트에서 MyBatis mapper와 운영 경험이 이미 있습니다.

JdbcTemplate 또는 JDBC batch가 잘 맞는 경우

  • 객체 매핑 비용을 줄이면서 PreparedStatement batch를 직접 제어하고 싶습니다.
  • JPA와 MyBatis 전체 기능은 필요 없지만 재시도·청크·오류 처리가 필요합니다.

PostgreSQL COPY를 검토할 경우

  • 수십만~수백만 행의 적재가 반복됩니다.
  • 입력 데이터가 행 중심으로 정리돼 있습니다.
  • JPA 콜백과 개별 엔티티 검증을 우회해도 됩니다.
  • 실패 행 처리, 스테이징 테이블, 사후 검증 절차를 별도로 설계할 수 있습니다.

이번 사례처럼 1만 5천 건을 주기적으로 등록하고 MyBatis 100건 단위 방식이 이미 15초를 만족한다면, 대량 등록 경로만 MyBatis로 유지하고 일반 CRUD는 JPA를 쓰는 하이브리드 구조도 충분히 합리적입니다.

중요한 것은 “JPA로도 반드시 MyBatis와 같은 숫자를 만들어야 한다”가 아니라, 목표 처리량·복잡도·운영 위험을 함께 만족하는 경로를 고르는 것입니다.

목차로 돌아가기

15. 자주 실패하는 패턴과 수정 방향

saveAll 전에 SELECT가 계속 나온다

확인 순서는 다음과 같습니다.

  1. 직접 할당 ID와 nullable @Version 여부를 확인합니다.
  2. isNew()가 실제로 호출되고 true를 반환하는지 봅니다.
  3. Lombok 빌더가 플래그를 false로 만들지 테스트합니다.
  4. SELECT 대상 테이블이 현재 엔티티인지 연관 엔티티인지 구분합니다.
  5. save() 외의 서비스 검증 쿼리와 리스너를 찾습니다.

batch_size를 넣어도 INSERT가 한 건씩 보인다

SQL 로그 모양만으로 결론 내리지 않습니다.

  • 실제 활성 프로필의 설정인지 확인합니다.
  • order_inserts의 위치가 hibernate.jdbc 아래로 잘못 들어가지 않았는지 봅니다.
  • IDENTITY 생성기를 쓰는지 확인합니다.
  • 같은 SQL 문장이 연속되는지 확인합니다.
  • 중간 flush, cascade, 여러 엔티티 타입이 섞이는지 확인합니다.
  • P6Spy를 끈 처리량과 DB 통계를 비교합니다.

reWriteBatchedInserts를 켰는데 달라지지 않는다

이 옵션은 JDBC batch를 만들어 주지 않습니다. Hibernate가 executeBatch()를 호출하지 않으면 재작성할 대상도 없습니다. 실제 DataSource에 해당 속성이 전달됐는지와 JDBC URL의 활성 프로필을 확인합니다.

P6Spy에서 로그가 사라졌다

배치 성공의 증거로 보지 않습니다. category 필터, appender, 로그 레벨, 포맷, 콘솔 제한을 확인합니다. 행 수와 실행 시간, DB 측 통계가 함께 맞아야 합니다.

Persistable을 적용한 뒤 업데이트가 INSERT가 된다

신규 플래그 관리가 잘못됐거나 isNew()를 항상 true로 반환했을 가능성이 큽니다. DB에서 조회한 엔티티는 @PostLoad 이후 false여야 합니다. 기존 PK로 새 객체를 만들어 save()하는 우회 업데이트 패턴은 명시적인 update 쿼리로 분리합니다.

속도는 줄었지만 메모리가 크게 늘어난다

1만 5천 엔티티가 트랜잭션 끝까지 1차 캐시에 남아 있을 수 있습니다. 일정 간격으로 flush()clear()를 실행하고, DTO·엔티티 전체 목록을 중복 보관하지 않도록 스트리밍 또는 청크 읽기를 검토합니다.

MyBatis foreach 크기를 계속 키우면 더 빨라질 것 같다

일정 지점부터는 SQL 크기, 파라미터 수, 메모리, 오류 복구 비용이 커집니다. 처리량뿐 아니라 실패 시 재시도 단위와 DB 부하도 함께 측정합니다.

목차로 돌아가기

16. 최종 체크리스트

  • [ ] JPA와 MyBatis가 같은 데이터·스키마·트랜잭션 조건에서 비교됐는가?
  • [ ] P6Spy와 SQL 콘솔 로그를 끈 기준 시간을 측정했는가?
  • [ ] saveAll()이 다중 행 SQL을 보장하지 않는다는 점을 구분했는가?
  • [ ] IDENTITY, SEQUENCE, 직접 할당 중 실제 식별자 전략을 확인했는가?
  • [ ] 직접 할당 ID라면 Spring Data의 신규 엔티티 판별 결과를 확인했는가?
  • [ ] Persistable.isNew()의 반환값을 생성·조회·저장 생명주기별로 테스트했는가?
  • [ ] Lombok @Builder가 신규 플래그 기본값을 바꾸지 않는가?
  • [ ] SELECT가 현재 테이블인지 연관 테이블인지 확인했는가?
  • [ ] hibernate.jdbc.batch_size가 실제 활성 설정에 적용됐는가?
  • [ ] hibernate.order_inserts를 올바른 위치에 넣었는가?
  • [ ] reWriteBatchedInserts=true가 실제 PostgreSQL DataSource에 전달됐는가?
  • [ ] 같은 형태의 INSERT가 연속돼 batch로 묶일 수 있는가?
  • [ ] 대량 등록 코드가 의도한 트랜잭션 안에서 실행되는가?
  • [ ] 일정 간격의 flush()·clear()로 1차 캐시 크기를 제한했는가?
  • [ ] batch_versioned_data를 일반 INSERT 가속 옵션으로 오해하지 않았는가?
  • [ ] 실행 시간, 초당 행 수, Hibernate 통계, DB 통계를 함께 봤는가?
  • [ ] 인덱스·FK·트리거·WAL·락 대기 같은 DB 병목을 확인했는가?
  • [ ] 목표 성능을 이미 만족한다면 MyBatis 하이브리드 구조도 검토했는가?
  • [ ] 대량 적재 전용 경로의 오류 행과 재시도 정책을 준비했는가?

마무리

이번 문제는 “JPA가 원래 느리다”는 한 문장으로 끝나지 않았습니다. 직접 할당한 @IdClass 복합키 때문에 신규 엔티티가 merge() 경로로 들어갈 수 있었고, Persistable을 적용한 뒤에는 Lombok 빌더가 신규 플래그를 바꾸는 문제가 드러났습니다. 그다음에는 Hibernate JDBC batch와 pgJDBC rewrite를 구분해야 했고, 마지막에는 P6Spy 로그를 실제 DB 왕복의 증거처럼 읽는 오류도 바로잡아야 했습니다.

대량 INSERT 최적화의 순서는 다음처럼 정리할 수 있습니다.

  1. 먼저 같은 조건에서 시간을 잽니다.
  2. 불필요한 SELECT의 출처를 찾습니다.
  3. 신규 엔티티를 persist() 경로로 보냅니다.
  4. Hibernate JDBC batch를 활성화합니다.
  5. PostgreSQL 드라이버 rewrite의 추가 효과를 측정합니다.
  6. P6Spy를 끄고 처리량과 DB 통계로 확인합니다.
  7. 그래도 목표에 못 미치면 MyBatis, JDBC batch, COPY를 비교합니다.

이 순서를 지키면 설정을 여러 개 넣고도 무엇이 달라졌는지 모르는 상태를 피할 수 있습니다. 그리고 JPA를 고집할지 MyBatis를 유지할지도 감이 아니라 실제 처리량과 운영 비용으로 결정할 수 있습니다.

참고 자료

태그

mLog

8년 이상 풀스택 개발자로 일하고 있습니다. Spring Boot, PostgreSQL, Redis, Vue·TypeScript와 Docker·Linux 서버를 다루며, 직접 운영하고 해결한 내용을 공식 문서와 실행 결과를 바탕으로 정리합니다.