Batch Insert 설정 이해하기 그리고 JPA, JDBC 성능 비교

MySQL Bulk Insert 적용하기

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 50 # 드라이버에 일괄 처리를 요청하기 전 hibernate가 일괄 처리할 쿼리의 최대 개수
        order_inserts: true # 삽입되는 엔티티 유형과 기본 키 값을 기준으로 재정렬
        order_updates: true # 변경되는 엔티티 유형과 기본 키 값을 기준으로 재정렬

주의할점으로는 IDENTITY ID 전략은 배치 INSERT를 사용할 수 없다.

참고자료 : https://docs.jboss.org/hibernate/orm/6.4/userguide/html_single/Hibernate_User_Guide.html#batch-jdbcbatch

IDENTITY 전략은 DB에 저장되어야지만 ID 값을 알 수 있기 때문이다.

 

이제 Batch 기능을 활성화하기 위한 각 옵션들에 대해 알아보자


rewriteBatchedStatements=true 파라미터

참고자료 : https://dev.mysql.com/doc/connectors/en/connector-j-connp-props-performance-extensions.html MySQL 공식 문서를 참고해 해석하면 이 옵션은 MySQL 드라이버가 배치 연산을 처리하는 방식을 바꾸는 설정이다.

"rewriteBatchedStatements=true"로 설정하면, 여러 개의 INSERT 또는 REPLACE 쿼리를 하나의 최적화된 쿼리로 재작성하는 것이다.

 

차이를 보면 다음과 같다.

jdbc.batch 옵션을 적용해 JDBC API 레벨에서 다음과 같은 배치 작업이 처리되고 있다.

PreparedStatement pstmt = conn.prepareStatement("INSERT INTO table VALUES (?)");
for (int i = 0; i < 3; i++) {
    pstmt.setInt(1, i);
    pstmt.addBatch();  // JDBC 배치에 추가
}
int[] results = pstmt.executeBatch(); // JDBC 배치 실행

rewriteBatchedStatements=false인 경우엔 pstmt에 쿼리를 모아서 실행하기 때문에 커넥션은 1개를 사용할 것이다.

하지만 1개의 커넥션 내에서 MySQL 서버로 실제 전송되는 쿼리는 다음과 같이 3개가 전송될 것이다.

(네트워크 패킷 1) INSERT INTO table VALUES (0)
(네트워크 패킷 2) INSERT INTO table VALUES (1)
(네트워크 패킷 3) INSERT INTO table VALUES (2)

이는 곧 MySQL 서버가 각 쿼리마다 파싱, 전처리, 옵티마이저, 실행을 반복하게 된다.

 

반면 rewriteBatchedStatements=true인 경우엔 드라이버가 이러한 개별 쿼리들을 하나의 최적화된 다중값 INSERT 문으로 변환해

단일 SQL 문으로 재작성하여 네트워크 효율성과 데이터베이스 성능을 크게 향상시킬 수 있다.

(네트워크 패킷 1) INSERT INTO table VALUES (0), (1), (2)

jdbc batch 옵션은 jdbc 레벨의 처리이며 어떻게 전송될 지는 드라이버에 따라 달라진다.

즉, 이는 드라이버 수준의 최적화이기 때문에, 애플리케이션 코드나 ORM 설정만으로는 제어할 수 없는 부분이니

자신이 사용하는 드라이버에 필요한 옵션을 적용해주어야할 것이다.

 

예시로 MariaDB는 다음과 같이 설정해야한다고 한다.

MariaDB Driver 에는 useBatchMultiSend 라는 속성이 있는데 기본값이 true 로 설정되어 있다.

MariaDB Driver는 rewriteBatchedStatements 속성을 가장 먼저 확인하고

rewriteBatchedStatements 가 false 로 설정되어 있다면 useBatchMultiSend 여부를 판단하여 쿼리를 배치로 실행한다고 한다.

따라서, 마리아DB는 별도로 설정안해도 된다고 한다.


JDBC Batch 옵션

JPA에서 batch size는 한 번에 몇 개의 쿼리를 모을지를 의미한다.

그리고 flush가 호출되면 모은 쿼리를 DB에 전송하게 된다.

모든 엔티티는 flush 전까지 메모리에 유지되므로, 수만 개의 대용량 엔티티를 영속화하면 OutOfMemoryError가 발생할 수 있다.

중요한건 batch_size와 영속성 컨텍스트에 남아 있는 엔티티는 별개라는 것이다.

batch_size가 100개고 저장할 엔티티가 1000개라면 100개씩 잘라서 DB에 전송을해 총 10번의 패킷을 보내지만

결국 영속성 컨텍스트는 모든 엔티티를 캐싱해놓고 있다.

만약 메모리가 부담된다면 batch_size 만큼 영속성 컨텍스트를 clear() 해줄 수도 있다.

 

또한 JDBC는 영속성 컨텍스트 개념이 없어 batch_size 관련 메모리 문제가 덜하지만

공통적으로 배치 크기 = 트랜잭션 크기이기 때문에 크기가 크면 롤백 비용도 증가하며

너무 큰 배치는 데이터베이스에 일시적 부하 집중을 유발할 수 있다.

고로 batch_size의 크기를 적절한 크기로 나눠 DB 리소스 사용이 분산할 수 있도록 해야한다.

batch_size의 크기는 레코드 크기, 시스템 리소스를 고려해 적절히 설정하자.

(hibernate 공식 문서의 경우에는 10~50의 크기를 설정할 것을 권장한다.)

 

재밌는점은 MySQL의 경우 max_allowed_packet 설정(기본값은 약 4MB)에 의해 패킷 크기가 제한된다.

하지만 MysQL은 드라이버는  max_allowed_packet 값과 쿼리의 길이를 비교하여 max_allowed_packet 미만으로 분할하여 전송하고 있어

"Packet for query is too large" 예외가 발생하지 않는다고 한다.

즉, max_allowed_packet 크기에 대한 고려 보단 로직에 알맞은 적절한 batch_size에 집중할 수 있다.

 

order_inserts와 order_updates 옵션에 대해서는 아래 포스팅에서 설명한다.

그리고 성능에 문제가 생길 수 있는데 왜 그런지에 대해서도 함께 설명하니 궁금하면 참고하자.

2025.04.22 - [◼ JPA] - hibernate batch 옵션 order_inserts와 order_updates는 항상 좋을까 ?


MySQL에서 배치 인서트 로그 확인하기

JDBC 드라이버 레벨에서의 배치 처리와 DB 레벨의 배치 처리는 서로 다르다.

JDBC 드라이버 레벨은 addBatch()로 여러 SQL을 모아서 한 번에 DB로 전송한다면

MySQL DB 배치 최적화는 MySQL 엔진이 실제로 받은 쿼리를 내부적으로 최적화하여 처리한다.

Insert 문의 경우 위에서 설명한데로 실제로 받은 쿼리 묶음들을 벌크 INSERT 문으로 재작성한다.

 

실제로 벌크 쿼리가 만들어지는 건 DB 레벨이기 때문에 확인하고 싶다면 다음과 같이 드라이버의 로그를 출력하도록 설정해야한다.

연결할 db 주소 쿼리파라미터에 다음과 같은 옵션을 추가하여 db 레벨에서 발생하는 로그를 확인할 수 있다.

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999

 

각 옵션별 특징은 다음과 같다.

rewriteBatchedStatements=true : Batch Insert를 하기 위한 설정
profileSQL=true : Driver에서 전송하는 쿼리를 출력
logger=Slf4JLogger : Driver에서 쿼리 출력 시 사용할 Logger 설정
maxQuerySizeToLog=999999 : 출력할 쿼리 길이 설정

 

이제 벌크 인서트가 잘 작동하는지 확인해보면 다음과 같은 형태로 INSERT문이 나가는 것을 볼 수 있다.

[QUERY] insert into pdf_bookmark (document_id,level,page_number,parent_id,title,id)
VALUES
    (...),
    (...),
    ...

MySQL - JDBC vs JPA 성능 비교

성능 비교는 MySQL을 사용하고 있어 IDENTITY ID 전략은 Batch 처리가 안되기 때문에 비교할 수 없었고

UUID 전략을 사용해 JPA vs JDBC 성능 비교를 측정했다.

 

아래 수치는 10번 평균을 계산하여 하여 나온 수치이다.

  JPA JDBC
10만건 Batch INSERT 8.21초 0.93초
1,000건 Batch INSERT 0.48 초 0.03초

데이터의 크기가 커질 수록 성능 차이는 크게 차이나는 것을 볼 수 있다.

여기서 궁금해지는 점은 “왜 이런 차이가 날까?”이다.

 

JPA는 다음과 같은 추가적인 작업이 생긴다.

  • 영속성 컨텍스트 내에서 엔티티 생명주기를 관리한다.
  • 영속화 시 변경 감지(dirty checking)를 위한 스냅샷을 생성하고 관리 한다.
  • Lazy 로딩 사용 시 프록시 생성과 관리가 추가된다.

이런 JPA의 추가적인 작업으로 인해 데이터 크기가 커질 수록 영속성 컨텍스트가 매우 커지기 때문에 저수준의 JDBC가 월등히 빠른 거라고 생각된다.

IDENTITY ID 전략을 사용하고 있는 상황에서 Batch Insert가 필요하다면 JDBC를 사용하는 것도 좋은 대안이 될 수 있다.

하지만 이 경우 한 트랜잭션내에서 JDBC를 사용한 작업과 JPA를 사용하는 작업이 공존한다면

JDBC로 직접 삽입한 데이터는 JPA 영속성 컨텍스트에 자동으로 반영되지 않기 때문에 일관성 문제가 발생할 수 있으니 주의하자.

이 경우 JDBC 작업 후 entityManager.clear()를 호출하거나 새 트랜잭션에서 JPA 작업을 수행하도록 해 해결할 수 있다.


정리하기

위 MySQL의 JPA, JDBC 성능 차이 테스트는 로컬의 MySQL과 연결하긴 했지만 어느정도 성능차이는 볼 수 있엇다.

외부와 통신의 경우에는 더욱 명확한 차이가 있을 수 있고 또 데이터가 늘어 날 수록 배치 처리의 효율은 올라간다.

 

필자의 경우에는 만들고 있는 프로그램의 특징이 PDF 문서를 열면 해당 문서의 내용을 추출하고 저장해 뷰어에 보여주는데

개인 로컬에서만 사용하기 때문에 H2 DB를 사용했다. H2를 사용하든 배치 처리 전 후가 큰 차이가 없었다. 인 메모리 DB인 H2는 JVM 메모리 내에서 실행되고 있었기 때문이라 보인다.

 

결론은 외부 DB와 통신하는 상황에서는 Batch 설정을 적용해 큰 성능 향상을 얻을 수 있을 것이다.

이번 경험을 통해 성능 개선은 다른 라이브러리나 큰 변경을 통해 이루기 보단 저수준에서 해결할 수 있느냐를

먼저 검토하는 것도 좋은 방법이라는 것을 느끼게 되었다.


참고자료
반응형