안녕하세요. 배민상품시스템팀 권순규 입니다.
저희팀에서 하이버네이트 배치 설정을 통해 대량 insert/update 시의 속도개선을 경험하여 공유드리고자 합니다.

전체 예제 파일은 github 에서 확인 할 수 있습니다.

하이버네이트 배치

배치 작업은 보통 대량의 작업을 한번에 처리하는 경우를 말합니다. 이와 유사하게 하이버네이트 배치는 JDBC 에서 제공하는 배치기능을 활용합니다.
하이버네이트 배치기능을 적용하게 되면 설정한 배치 갯수에 도달할때 까지 PreparedStatement.addBatch() 를 호출 하여 실행할 쿼리를 추가하고, 설정한 배치 갯수에 도달하게 되면 PreparedStatement.executeBatch() 를 호출합니다.
PreparedStatement.executeBatch() 가 호출되면 DB 드라이버에서는 PreparedStatement.addBatch()를 통해 추가된 쿼리를 재조합하여 DB 로 한번에 전송하게 됩니다.
여러개의 쿼리를 한번에 모아서 처리하기 때문에 단건씩 쿼리를 수행할 때에 비해 DB 와 통신하는 횟수도 줄어들고, DB 에서도 락을 잡는 횟수가 줄어들어 실행 속도가 향상되게 됩니다.

저희 팀에서는 하이버네이트의 배치 기능을 적용하여 만큼 걸리던 배치 애플리케이션을 만큼으로 실행 시간을 거의 절반가량 줄이는 경험을 했습니다.

설정 적용하기

application.yml 파일에 아래와 같이 한줄만 추가해 주면 하이버네이트 배치 설정이 적용됩니다.
batch_size 는 한꺼번에 insert/update 를 실행할 크기만큼 설정해 주면 됩니다.

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 100

주의할 점은 hiberanate 프로퍼티가 spring.jpa.hibernate 에도 존재하기 때문에 IDE 의 자동완성을 이용하게 되면 여기에 설정하는 실수를 하게 됩니다.
반드시 spring.jpa.properties.hibernate 에 설정하셔야 합니다.
spring.jpa.properties 는 자동완성이 되지 않아 설정하실때 다시한번 확인해 보시길 권장드립니다.

SAVE

위와 같이 application.yml 파일에 하이버네이트 배치 설정을 적용 했으니, 이제 설정이 잘 적용되는지 확인하기 위해 먼저 엔티티를 저장해 보겠습니다.

ID 가 자동증가 값이 아닐때

GenerationType.IDENTITY 를 적용했을 때는 batch insert 가 되지 않는다는것을 알았으니 이번에는 GenerationType.IDENTITY 를 적용하지 않은 엔티티로 테스트를 해보겠습니다.
NonIdentityEntity 라는 이름으로 엔티티를 만들었습니다. 이 엔티티의 아이디는 생성자로 입력받은 아이디 값을 사용하도록 하였습니다.

public class NonIdentityEntity {

    @Id
    @Column(name = "id", updatable = false)
    @EqualsAndHashCode.Include
    private Long id;
    //...    
}

엔티티를 생성하여 아래와 같이 한번 저장해보겠습니다.

NonIdentityEntityRepositoryTest.saveAll

    void saveAll() throws Exception {
        final int size = 3;
        final List<NonIdentityEntity> nonIdentityEntities = createNonIdentityEntities(size);

        for (NonIdentityEntity nonIdentityEntity : nonIdentityEntities) {
            nonIdentityEntityRepository.save(nonIdentityEntity);
        }
        flush();
    }

아래의 콘솔창에 출력된 결과를 보면 기대와는 달리 insert 쿼리가 단건씩 출력되고 있습니다.

[main] org.hibernate.SQL                        : 
    insert 
    into
        non_identity
        (c1, c2, c3, c4, c5, version, id) 
    values
        (?, ?, ?, ?, ?, ?, ?)
[main] org.hibernate.SQL                        : 
    insert 
    into
        non_identity
        (c1, c2, c3, c4, c5, version, id) 
    values
        (?, ?, ?, ?, ?, ?, ?)
[main] org.hibernate.SQL                        : 
    insert 
    into
        non_identity
        (c1, c2, c3, c4, c5, version, id) 
    values
        (?, ?, ?, ?, ?, ?, ?)

SpringDataJPA 의 saveAll() 은 전달받은 리스트를 반복하며 save() 를 호출하고 있는것을 알지만, 그래도 뭔가 찝찝하니 아래처럼 saveAll() 을 호출해 보겠습니다.

NonIdentityEntityRepositoryTest.saveAll2

    void saveAll2() throws Exception {
        final int size = 3;
        final List<NonIdentityEntity> nonIdentityEntities = createNonIdentityEntities(size);

        nonIdentityEntityRepository.saveAll(nonIdentityEntities);
        flush();
    }

여전히 기대와는 달리 쿼리문이 단건씩 출되고 있습니다.

[main] org.hibernate.SQL                        : 
    insert 
    into
        non_identity
        (c1, c2, c3, c4, c5, version, id) 
    values
        (?, ?, ?, ?, ?, ?, ?)
[main] org.hibernate.SQL                        : 
    insert 
    into
        non_identity
        (c1, c2, c3, c4, c5, version, id) 
    values
        (?, ?, ?, ?, ?, ?, ?)
[main] org.hibernate.SQL                        : 
    insert 
    into
        non_identity
        (c1, c2, c3, c4, c5, version, id) 
    values
        (?, ?, ?, ?, ?, ?, ?)

rewriteBatchedStatements

구글링을 통해 발견한 블로그 글 에서는

MySQL JDBC의 경우 JDBC URL에 rewriteBatchedStatements=true 옵션을 추가해주면 된다.

라고 적혀 있습니다.
이 블로그를 따라 url 에 rewriteBatchedStatements=true 옵션을 추가해 보도록 하겠습니다.

spring:
  datasource:
    hikari:
      jdbc-url: jdbc:mysql://localhost:3306/hibernate_batch?rewriteBatchedStatements=true

다시한번 NonIdentityEntityRepositoryTest.saveAll2 를 실행해 보겠습니다.
하지만 여전히 쿼리문은 단건씩 출력 되고 있습니다.

[main] org.hibernate.SQL                        : 
    insert 
    into
        non_identity
        (c1, c2, c3, c4, c5, version, id) 
    values
        (?, ?, ?, ?, ?, ?, ?)
[main] org.hibernate.SQL                        : 
    insert 
    into
        non_identity
        (c1, c2, c3, c4, c5, version, id) 
    values
        (?, ?, ?, ?, ?, ?, ?)
[main] org.hibernate.SQL                        : 
    insert 
    into
        non_identity
        (c1, c2, c3, c4, c5, version, id) 
    values
        (?, ?, ?, ?, ?, ?, ?)

블로그 글을 다시 한번 살펴보니

MySQL의 경우 실제로 생성된 쿼리는 logger=com.mysql.jdbc.log.Slf4JLogger&profileSQL=true 옵션으로 로그를 통해 확인할 수 있다.

라는 문장이 있습니다. 한번 따라서 적용해 보겠습니다.

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

profileSQL=truelogger=Slf4JLogger 에 더불어 maxQuerySizeToLog=999999 를 추가로 더 설정하였습니다.

  • profileSQL=true : Driver 에서 전송하는 쿼리를 출력합니다.
  • logger=Slf4JLogger : Driver 에서 쿼리 출력시 사용할 로거를 설정합니다.
    • MySQL 드라이버 : 기본값은 System.err 로 출력하도록 설정되어 있기 때문에 필수로 지정해 줘야 합니다.
    • MariaDB 드라이버 : Slf4j 를 이용하여 로그를 출력하기 때문에 설정할 필요가 없습니다.
  • maxQuerySizeToLog=999999 : 출력할 쿼리 길이
    • MySQL 드라이버 : 기본값이 0 으로 지정되어 있어 값을 설정하지 않을경우 아래처럼 쿼리가 출력되지 않습니다.
      [main] MySQL : [QUERY]  ... (truncated) [Created on: Mon Sep 21 01:03:10 KST 2020, duration: 3, connection-id: 325, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
      
    • MariaDB 드라이버 : 기본값이 1024 로 지정되어 있습니다. MySQL 드라이버와는 달리 0으로 지정시 쿼리의 글자 제한이 무제한으로 설정됩니다.

설정과 관련한 자세한 내용은 MySQL 드라이버 홈페이지MariaDB 드라이버 홈페이지 에서 확인하실 수 있습니다.

이제 드라이버 관련 설정을 추가했으니 다시한번 NonIdentityEntityRepositoryTest.saveAll2 를 실행해 보겠습니다.

[main] org.hibernate.SQL                        : 
    insert 
    into
        non_identity
        (c1, c2, c3, c4, c5, version, id) 
    values
        (?, ?, ?, ?, ?, ?, ?)
[main] org.hibernate.SQL                        : 
    insert 
    into
        non_identity
        (c1, c2, c3, c4, c5, version, id) 
    values
        (?, ?, ?, ?, ?, ?, ?)
[main] org.hibernate.SQL                        : 
    insert 
    into
        non_identity
        (c1, c2, c3, c4, c5, version, id) 
    values
        (?, ?, ?, ?, ?, ?, ?)
[main] MySQL : [QUERY] insert into non_identity (c1, c2, c3, c4, c5, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 1),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 2),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 3) [Created on: Mon Sep 21 00:15:45 KST 2020, duration: 3, connection-id: 305, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

설정을 추가하고 동일한 테스트를 다시 실행하자 가장 마지막 줄에 MySQL Driver 에서 실제 전송되는 쿼리가 찍히는것을 확일 할 수 있습니다.

[main] MySQL : [QUERY] insert into non_identity (c1, c2, c3, c4, c5, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 1),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 2),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 3) [Created on: Mon Sep 21 00:15:45 KST 2020, duration: 3, connection-id: 305, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

실제 전송된 쿼리문을 확인했을때 3건의 저장내역이 하나의 insert 로 합쳐져 출력된것으로 보아 batch insert 가 잘 적용된 것을 확인 할 수 있습니다.

1위의 로그를 통해 확인할 수 있는 것은 하이버네이트는 배치설정여부와 관계없이 무조건 쿼리를 엔티티마다 한건씩 출력한다는 것입니다.
또한, 하이버네이트 배치 설정이 되었더라도 하이버네이트는 PreparedStatement.addBatch()를 호출하기만 할 뿐 쿼리가 합쳐진다는 것을 모르는 상태이고, 실제로 쿼리를 하나로 합치는 것은 MySQL 드라이버 차원에서 이뤄지는 것임을 알 수 있습니다.
이제 더이상 하이버네이트 쿼리는 필요가 없으니 application.yml 파일에서 하이버네이트 쿼리를 출력하는 부분을 주석처리 하겠습니다.

logging:
  level:
    org.hibernate:
#      SQL: debug

MySQL 환경에서 MySQL Driver 뿐만 아니라 MariaDB Driver 를 사용하는 경우도 많으니 이번에는 MariaDB Driver 를 통해 테스트 해보겠습니다. 먼저 rewriteBatchedStatements=false 로 설정후 NonIdentityEntityRepositoryTest.saveAll2 를 실행해 보겠습니다.

spring:
  datasource:
    hikari:
      jdbc-url: jdbc:mysql://localhost:3306/hibernate_batch?profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999&rewriteBatchedStatements=false
      driver-class-name: org.mariadb.jdbc.Driver # MariaDB Driver

콘솔창에 출력된 쿼리를 보면 rewriteBatchedStatements=false 로 설정했음에도, 예상과는 달리 쿼리 실행결과는 insert 쿼리가 한번만 출력되었습니다.

[main] o.m.j.i.logging.ProtocolLoggingProxy : conn=315(M) - 9.272 ms - Query: insert into non_identity (c1, c2, c3, c4, c5, version, id) values (?, ?, ?, ?, ?, ?, ?), parameters [1,'c2-1','c3-1','c4-1','c5-1',0,1],[1,'c2-1','c3-1','c4-1','c5-1',0,2],[1,'c2-1','c3-1','c4-1','c5-1',0,3]

MariaDB Driver 에는 useBatchMultiSend 라는 속성이 있는데 기본값이 true 로 설정되어 있습니다. MariaDB Driver 는 rewriteBatchedStatements 속성을 가장 먼저 확인하고, rewriteBatchedStatements 가 false 로 설정되어 있다면 useBatchMultiSend 여부를 판단하여 쿼리를 배치로 실행하고 있습니다.
주의할 점은 useBatchMultiSend 속성은 AWS 의 Aurora DB 의 경우에는 지원하지 않으니 batch insert/update 를 적용하려면 rewriteBatchedStatements=true 로 설정하는 편이 좋아 보입니다.
useBatchMultiSend 속성에 대한 자세한 내용은 MariaDB 홈페이지 에서 확인하실 수 있습니다.

ID 가 자동증가 값일때

ID 가 자동증가 엔티티는 id 필드에 @GeneratedValue(strategy = GenerationType.IDENTITY) 를 적용하였고 엔티티이름은 IdentityEntity 로 만들었습니다.

public class IdentityEntity {

    @Id
    @Column(name = "id", updatable = false)
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @EqualsAndHashCode.Include
    private Long id;
    //...
}

아래와 같이 자동증가 엔티티 목록을 생성 후 saveAll() 을 호출해 보겠습니다.(IdentityEntityRepositoryTest.saveAll())

    void saveAll() throws Exception {
        final List<IdentityEntity> identityEntities = Stream.generate(IdentityEntity::of)
                .limit(3)
                .collect(Collectors.toList());

        identityEntityRepository.saveAll(identityEntities);
    }

콘솔창을 보면 아래와 같이 insert 쿼리가 3건이 발생한 것을 볼 수 있습니다.

[main] org.hibernate.SQL                        : 
    insert 
    into
        identity
        (c1, c2, c3, c4, c5, version) 
    values
        (?, ?, ?, ?, ?, ?)
[main] org.hibernate.SQL                        : 
    insert 
    into
        identity
        (c1, c2, c3, c4, c5, version) 
    values
        (?, ?, ?, ?, ?, ?)
[main] org.hibernate.SQL                        : 
    insert 
    into
        identity
        (c1, c2, c3, c4, c5, version) 
    values
        (?, ?, ?, ?, ?, ?)

아래의 하이버네이트 문서 에 쓰여진 내용처럼 GenerationType.IDENTITY 를 적용한 경우에는 insert 가 배치로 실행되지 않는다는것을 알 수 있습니다.

하이버네이트 커미터인 Vlad Mihalcea 의 블로그 글 에는 아래와 같은 이유로 GenerationType.IDENTITY 을 사용할 경우 하이버 네이트는 batch insert 를 비활성화 한다고 합니다.
안되는 영어로 이해해 보면 Persistence Context 내부에서 엔티티를 식별할때는 엔티티 타입과 엔티티의 id 값으로 엔티티를 식별하지만 IDENTITY 의 경우 DB에 insert 문을 실행해야만 id 값을 확인 가능하기 때문에 batch insert 를 비활성화 한다고 쓴것 같습니다.

Whenever an entity is persisted, Hibernate must attach it to the currently running Persistence Context which acts as a Map of entities. The Map key is formed of the entity type (its Java Class) and the entity identifier.

For IDENTITY columns, the only way to know the identifier value is to execute the SQL INSERT. Hence, the INSERT is executed when the persist method is called and cannot be disabled until flush time.

For this reason, Hibernate disables JDBC batch inserts for entities using the IDENTITY generator strategy.

UPDATE

이제는 저장된 엔티티를 조회하여 업데이트를 해보겠습니다.

ID 가 자동증가 값일때

먼저 GenerationType.IDENTITY 를 적용한 IdentityEntity 를 업데이트 해보겠습니다.
아래와 같이 먼저 4개의 값을 저장 후 저장된 값을 모두 조회하여 IdentityEntity 내부 필드를 모두 변경시키는 IdentityEntity.plus() 를 호출하도록 하겠습니다.

IdentityEntityRepositoryTest.updateAll

    void updateAll() throws Exception {
        int size = 4;
        insertTestValues(INSERT_IDENTITY, identityParameters(size));

        final List<IdentityEntity> identityEntities = identityEntityRepository.findAll();
        identityEntities.forEach(IdentityEntity::plus);

        flush();
    }

saveAll() 호출때와는 달리 쿼리가 한번에 출력된 것을 확인할 수 있습니다.

[main] MySQL : [QUERY] update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=1 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=2 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=3 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=4 and version=0 [Created on: Mon Sep 21 01:39:16 KST 2020, duration: 3, connection-id: 345, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

이번에는 갯수를 3개로 줄여서 업데이트 해보겠습니다.

IdentityEntityRepositoryTest.updateAll2

    void updateAll2() throws Exception {
        int size = 3;
        insertTestValues(INSERT_IDENTITY, identityParameters(size));

        final List<IdentityEntity> identityEntities = identityEntityRepository.findAll();
        identityEntities.forEach(IdentityEntity::plus);

        flush();
    }

갯수를 3개로 줄여서 업데이트를 실행하니 업데이트 쿼리가 3번에 나눠 출력되었습니다.

[main] MySQL : [QUERY] update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=1 and version=0 [Created on: Mon Sep 21 01:45:32 KST 2020, duration: 2, connection-id: 355, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=2 and version=0 [Created on: Mon Sep 21 01:45:32 KST 2020, duration: 2, connection-id: 355, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=3 and version=0 [Created on: Mon Sep 21 01:45:32 KST 2020, duration: 2, connection-id: 355, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

MySQL 드라이버는 배치 업데이트시 실행할 쿼리가 3개 이하인 경우에는 한건씩 쿼리를 실행하도록 되어있습니다.
드라이버 소스코드 를 보면 3건 이하일 때 한건씩 쿼리를 전송하는 부분에 cost of option setting rt-wise 라는 주석을 남겨 두었는데 이 주석이 쿼리를 단건씩 호출하는 이유가 아닐까 생각됩니다. 업데이트가 아닌 insert 의 경우에는 3건 이하인 경우에도 쿼리를 한번에 실행합니다.

이번에는 MariaDB 드라이버에서도 3건 이하를 업데이트 할 경우 동일하게 쿼리가 한건씩 실행되는지 확인해 보겠습니다. 드라이버를 MariaDB 드라이버로 변경 후 IdentityEntityRepositoryTest.updateAll2 를 다시 실행해 보면 MySQL 드라이버와는 달리 3건 이하일 경우에도 쿼리가 한번에 실행되는 것을 확인할 수 있습니다.

[main] o.m.j.i.logging.ProtocolLoggingProxy     : conn=365(M) - 2.156 ms - Query: update identity set c1=?, c2=?, c3=?, c4=?, c5=?, version=? where id=? and version=?, parameters [2,'c2-2','c3-2','c4-2','c5-2',1,1,0],[2,'c2-2','c3-2','c4-2','c5-2',1,2,0],[2,'c2-2','c3-2','c4-2','c5-2',1,3,0]

ID 가 자동증가 값이 아닐때

이번에는 GenerationType.IDENTITY 를 적용하지 않은 NonIdentityEntity 에 대한 업데이트를 실행해 보겠습니다.

NonIdentityEntityRepositoryTest.updateAll

    void updateAll() throws Exception {
        final int size = 5;
        insertTestValues(INSERT_NON_IDENTITY, nonIdentityParameters(size));
        final List<NonIdentityEntity> nonIdentityEntities = nonIdentityEntityRepository.findAll();
        nonIdentityEntities.forEach(NonIdentityEntity::plus);

        flush();
    }

실행결과를 보니 다행히도 한번의 쿼리로 업데이트가 실행되는것을 확인할 수 있습니다.

[main] MySQL : [QUERY] update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=1 and version=0;update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=2 and version=0;update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=3 and version=0;update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=4 and version=0;update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=5 and version=0 [Created on: Mon Sep 21 02:13:56 KST 2020, duration: 2, connection-id: 375, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

하나의 트랜잭션에서 동일한 종류의 엔티티를 여러번 SELECT 하며 업데이트

아래와 같이 하나의 트랜잭션 안에서 NonIdentityEntity 목록을 두번 조회하여 업데이트를 실행해 보겠습니다.

IdentityEntityRepositoryTest.updateAll3

    void updateAll3() throws Exception {
        final int insertSize = 10;
        final int pageSize = 5;
        insertTestValues(INSERT_IDENTITY, identityParameters(insertSize));

        Pageable pageable = PageRequest.of(0, pageSize);
        while (true) {
            final Slice<IdentityEntity> slice = identityEntityRepository.findAllIdentityEntities(pageable);
            final List<IdentityEntity> identityEntities = slice.getContent();
            identityEntities.forEach(IdentityEntity::plus);

            if (slice.isLast()) {
                break;
            }
            pageable = slice.nextPageable();
        }

        flush();
    }

예상한 결과는 flush() 를 호출한 시점에 업데이트된 엔티티의 쿼리를 한번에 실행 할 것이라 예상했지만, 실제 결과는 아래와 같이 두번째 select 쿼리 실행전에 update 쿼리가 실행된 것을 확인할 수 있습니다.

[main] MySQL : [QUERY] select identityen0_.id as id1_1_, identityen0_.c1 as c2_1_, identityen0_.c2 as c3_1_, identityen0_.c3 as c4_1_, identityen0_.c4 as c5_1_, identityen0_.c5 as c6_1_, identityen0_.version as version7_1_ from identity identityen0_ order by identityen0_.id limit 6 [Created on: Mon Sep 21 02:32:54 KST 2020, duration: 2, connection-id: 415, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52)]
[main] MySQL : [QUERY] update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=1 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=2 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=3 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=4 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=5 and version=0 [Created on: Mon Sep 21 02:32:54 KST 2020, duration: 3, connection-id: 415, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] select identityen0_.id as id1_1_, identityen0_.c1 as c2_1_, identityen0_.c2 as c3_1_, identityen0_.c3 as c4_1_, identityen0_.c4 as c5_1_, identityen0_.c5 as c6_1_, identityen0_.version as version7_1_ from identity identityen0_ order by identityen0_.id limit 5, 6 [Created on: Mon Sep 21 02:32:54 KST 2020, duration: 2, connection-id: 415, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52)]
[main] MySQL : [QUERY] update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=6 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=7 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=8 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=9 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=10 and version=0 [Created on: Mon Sep 21 02:32:54 KST 2020, duration: 4, connection-id: 415, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

두번째 select 쿼리 실행전에 update 쿼리가 먼저 실행된 이유는, 하이버네이트는 select 를 하기전에 select 대상 엔티티에 대해 flush() 를 먼저 호출하기 때문입니다.
하이버네이트가 select 를 하기전에 엔티티에 대해 flush() 를 호출하는 이유는 SessionImpl.autoFlushIfRequired() 에 적어놓은 주석을 통해 확인 할 수 있습니다.

	/**
	 * detect in-memory changes, determine if the changes are to tables
	 * named in the query and, if so, complete execution the flush
	 */
	protected boolean autoFlushIfRequired(Set querySpaces) throws HibernateException {
	    //...
	}

하나의 트랜잭션에서 다른 종류의 엔티티를 SELECT 후 업데이트

동일한 종류를 select 하는게 아닌, 다른 종류의 엔티티를 select 하는 경우에도 update 쿼리가 먼저 실행될까요? 아래와 같이 IdentityEntity 를 먼저 조회하여 업데이트 후, NonIdentityEntity 를 조회후 업데이트를 해 보겠습니다.

CompositeTest.updateAllIdentityEntityAndNonIdentityEntity()

    void updateAllIdentityEntityAndNonIdentityEntity() throws Exception {
        final int size = 4;
        insertTestValues(INSERT_IDENTITY, identityParameters(size));
        insertTestValues(INSERT_NON_IDENTITY, nonIdentityParameters(size));

        final List<IdentityEntity> identityEntities = identityEntityRepository.findAll();
        identityEntities.forEach(IdentityEntity::plus);

        final List<NonIdentityEntity> nonIdentityEntities = nonIdentityEntityRepository.findAll();
        nonIdentityEntities.forEach(NonIdentityEntity::plus);

        flush();
    }

동일한 엔티티를 조회했을 때와는 달리 조회가 끝나고 flush() 를 호출했을때 update 쿼리가 실행된 것을 확인할 수 있습니다.

[main] MySQL : [QUERY] select identityen0_.id as id1_1_, identityen0_.c1 as c2_1_, identityen0_.c2 as c3_1_, identityen0_.c3 as c4_1_, identityen0_.c4 as c5_1_, identityen0_.c5 as c6_1_, identityen0_.version as version7_1_ from identity identityen0_ [Created on: Mon Sep 21 02:47:55 KST 2020, duration: 2, connection-id: 425, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52)]
[main] MySQL : [QUERY] select nonidentit0_.id as id1_2_, nonidentit0_.c1 as c2_2_, nonidentit0_.c2 as c3_2_, nonidentit0_.c3 as c4_2_, nonidentit0_.c4 as c5_2_, nonidentit0_.c5 as c6_2_, nonidentit0_.version as version7_2_ from non_identity nonidentit0_ [Created on: Mon Sep 21 02:47:55 KST 2020, duration: 2, connection-id: 425, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52)]
[main] MySQL : [QUERY] update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=1 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=2 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=3 and version=0;update identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=4 and version=0 [Created on: Mon Sep 21 02:47:55 KST 2020, duration: 2, connection-id: 425, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=1 and version=0;update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=2 and version=0;update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=3 and version=0;update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=4 and version=0 [Created on: Mon Sep 21 02:47:55 KST 2020, duration: 2, connection-id: 425, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

hibernate.jdbc.batch_versioned_data

JPA 에는 동일한 엔티티에 대해 동시 쓰기를 방지하기위한 수단으로 낙관적 잠금(Optimistic Lock) 이라는 것이 존재합니다.
직접 DB 에 잠금을 걸지 않고 업데이트시 엔티티의 @Version 애노테이션을 선언한 필드의 값을 쿼리의 조건문에 추가하여 반환된 갯수와 변경된 엔티티의 갯수를 비교하여 일치하지 않으면 애플리케이션 단에서 예외를 발생시켜 동시 업데이트를 방지하고 있습니다. @Version 애노테이션을 선언했을때와 그렇지 않았을 때의 쿼리의 차이는 아래와 같습니다.

  • @Version 적용 쿼리 : where 문에 @Version 를 선언한 필드의 이전값을 where 절에 조건으로 함께 넣어준다.
      update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=1 and version=0
    
  • @Version 미적용 쿼리 : where 문에 id 외의 다른 조건이 없다.
      update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=0 where id=1
    

hibernate.jdbc.batch_versioned_data 속성을 true 로 설정한다면 한번에 배치로 update 를 실행후 반환된 갯수와 변경된 엔티티의 갯수를 비교하게 됩니다.
따라서 업데이트 쿼리 또한 아래와 같이 한번만 실행되게 됩니다.

[main] MySQL : [QUERY] update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=1 and version=0;update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=2 and version=0;update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=3 and version=0;update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=4 and version=0;update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=5 and version=0 [Created on: Mon Sep 21 03:11:27 KST 2020, duration: 3, connection-id: 455, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

hibernate.jdbc.batch_versioned_data 속성을 true 로 설정할경우 주의해야 할 점은 일부 드라이버는 update 쿼리 실행 갯수를 잘못된 값을 반환하고 있어 잘못된 값을 반환하는 경우에는 반드시 false 로 선언하여 사용해야 합니다.

hibernate.jdbc.batch_versioned_data 속성을 false 로 설정한다면 update 를 한건씩 실행후 반환된 결과값이 0이 아닌지를 한건씩 비교하게 됩니다. 따라서 업데이트 쿼리가 변경된 엔티티 갯수 만큼 실행되게 됩니다.
hibernate.jdbc.batch_versioned_data 속성은 하이버네이트 5버전 부터는 기본값이 true 이기 때문에 비활성화 시키려면 hibernate.jdbc.batch_versioned_data=false 를 명시해야 합니다.

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_versioned_data: false

아래 실행결과를 보면 엔티티 갯수만큼 업데이트가 실행된 것을 확인할 수 있습니다.

[main] MySQL : [QUERY] update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=1 and version=0 [Created on: Mon Sep 21 03:14:50 KST 2020, duration: 2, connection-id: 465, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)]
[main] MySQL : [QUERY] update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=2 and version=0 [Created on: Mon Sep 21 03:14:50 KST 2020, duration: 2, connection-id: 465, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)]
[main] MySQL : [QUERY] update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=3 and version=0 [Created on: Mon Sep 21 03:14:50 KST 2020, duration: 2, connection-id: 465, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)]
[main] MySQL : [QUERY] update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=4 and version=0 [Created on: Mon Sep 21 03:14:50 KST 2020, duration: 2, connection-id: 465, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)]
[main] MySQL : [QUERY] update non_identity set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=5 and version=0 [Created on: Mon Sep 21 03:14:50 KST 2020, duration: 1, connection-id: 465, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)]

일대다 관계

ParentEntity 엔티티와 ChildEntity 는 1 : N 관계로 매핑되어 있습니다.

public class ParentEntity {
    //...
    @ToString.Exclude
    @OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY)
    private List<ChildEntity> children = new ArrayList<>();
    //...
}

연관관계에 있는 엔티티를 한번에 insert/update 시 어떻게 동작하는지 알아보도록 하겠습니다.

hibernate.order_inserts

hibernate.order_inserts 속성은 기본값이 false 이기 때문에 설정하지 않고서 ParentEntityChildEntity 를 생성하여 저장해 보겠습니다. 아래와 같이 ParentEntity 하나당 ChildEntity 3 개를 추가하며 저장하도록 하겠습니다.

ParentEntityRepositoryTest.saveAll()

    void saveAll() throws Exception {
        long childId = 1;
        for (long i = 1; i <= 5; i++) {
            final ParentEntity parent = ParentEntity.of(i);
            for (long j = 0; j < 3; j++) {
                final ChildEntity child = ChildEntity.of(childId, parent);
                parent.addChild(child);
                childId++;
            }
            parentRepository.save(parent);
        }
        flush();
    }

모든 엔티티에대해 save() 호출 이후 flush() 를 호출하였음에도 실행결과는 아래와 같이 ParentEntity 가 먼저 저장이 되고 연관된 ChildEntity 가 저장이 되고 있습니다.

[main] MySQL : [QUERY] insert into parent (c1, c2, c3, c4, c5, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 1) [Created on: Mon Sep 21 03:32:56 KST 2020, duration: 2, connection-id: 475, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] insert into child (c1, c2, c3, c4, c5, parent_id, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 1, 0, 1),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 1, 0, 2),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 1, 0, 3) [Created on: Mon Sep 21 03:32:56 KST 2020, duration: 2, connection-id: 475, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] insert into parent (c1, c2, c3, c4, c5, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 2) [Created on: Mon Sep 21 03:32:56 KST 2020, duration: 2, connection-id: 475, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] insert into child (c1, c2, c3, c4, c5, parent_id, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 2, 0, 4),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 2, 0, 5),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 2, 0, 6) [Created on: Mon Sep 21 03:32:56 KST 2020, duration: 2, connection-id: 475, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] insert into parent (c1, c2, c3, c4, c5, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 3) [Created on: Mon Sep 21 03:32:56 KST 2020, duration: 1, connection-id: 475, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] insert into child (c1, c2, c3, c4, c5, parent_id, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 3, 0, 7),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 3, 0, 8),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 3, 0, 9) [Created on: Mon Sep 21 03:32:56 KST 2020, duration: 1, connection-id: 475, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] insert into parent (c1, c2, c3, c4, c5, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 4) [Created on: Mon Sep 21 03:32:56 KST 2020, duration: 1, connection-id: 475, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] insert into child (c1, c2, c3, c4, c5, parent_id, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 4, 0, 10),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 4, 0, 11),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 4, 0, 12) [Created on: Mon Sep 21 03:32:56 KST 2020, duration: 2, connection-id: 475, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] insert into parent (c1, c2, c3, c4, c5, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 5) [Created on: Mon Sep 21 03:32:56 KST 2020, duration: 2, connection-id: 475, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] insert into child (c1, c2, c3, c4, c5, parent_id, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 5, 0, 13),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 5, 0, 14),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 5, 0, 15) [Created on: Mon Sep 21 03:32:56 KST 2020, duration: 2, connection-id: 475, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

이번에는 hibernate.order_inserts 속성을 true 로 설정해 보겠습니다.

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          order_inserts: true

이전과 동일하게 ParentEntityRepositoryTest.saveAll() 를 실행해보면 실행 결과가 아래와 같이 엔티티별로 insert 문이 실행되는 것을 확인할 수 있습니다.

[main] MySQL : [QUERY] insert into parent (c1, c2, c3, c4, c5, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 1),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 2),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 3),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 4),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 5) [Created on: Mon Sep 21 03:44:58 KST 2020, duration: 2, connection-id: 485, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] insert into child (c1, c2, c3, c4, c5, parent_id, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 1, 0, 1),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 1, 0, 2),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 1, 0, 3),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 2, 0, 4),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 2, 0, 5),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 2, 0, 6),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 3, 0, 7),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 3, 0, 8),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 3, 0, 9),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 4, 0, 10),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 4, 0, 11),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 4, 0, 12),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 5, 0, 13),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 5, 0, 14),(1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 5, 0, 15) [Created on: Mon Sep 21 03:44:58 KST 2020, duration: 2, connection-id: 485, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

하이버네이트는 insert/update/delete 와 같은 엔티티가 변경되는 작업을 실행할때는 ActionQueue 라는 곳에 변경작업을 추가해두고, flush() 호출시 ActionQueue 에 추가되어 있는 작업들을 순차적으로 실행합니다.
hibernate.order_inserts 속성이 false 일 경우에는 ActionQueue 내부의 작업들을 따로 정렬하지 않기 때문에 ActionQueue 에 추가된 순서대로인 ParentEntityChildEntity 에 대한 배치 작업이 번갈아 가면서 호출됩니다.
하이버네이트는 배치 작업시 BatchKey 를 생성하여 BatchKey 단위로 배치 작업을 실행하는데, BatchKey 는 엔티티의 이름을 입력받아 생성합니다.
만약, 다음번 배치 작업시 다른 BatchKey 를 넘겨주게 된다면 이전 BatchKey 에 대해서는 PreparedStatement 에 추가해둔 쿼리를 실행하고 다시 새로운 PreparedStatement 에 쿼리를 추가해 나갑니다.
따라서 ParentEntityChildEntity 는 엔티티 이름이 다르기 때문에 다른 BatchKey 를 생성하게 되어 ParentEntity 1개와 ChildEntity 3개에 대한 insert 쿼리가 번갈아가면서 실행되게 됩니다.

hibernate.order_inserts 속성을 true 로 설정하게 된다면 ActionQueue 내부에 추가된 insert 작업을 먼저 엔티티별로 정렬하고, 정렬된 순서대로 insert 작업이 실행됩니다.
그렇기 때문에 엔티티별로 insert 쿼리가 한번씩만 실행되게 됩니다.

hibernate.order_updates

hibernate.order_updates 속성도 hibernate.order_inserts 과 동일하게 기본값은 false 입니다.
먼저 hibernate.order_updates 를 설정하지 않고 ParentEntityChildEntity 를 업데이트 해 보겠습니다.
ParentEntity.plus() 는 내부에서 children 를 반복돌면서 ChildEntity.plus() 를 호출하고 있습니다.

    public void plus() {
        //...
        children.forEach(ChildEntity::plus);
    }

아래와 같이 저장된 ParentEntity 를 모두 조회하여 업데이트 해보겠습니다. ParentEntityRepositoryTest.updateAll

    void updateAll() throws Exception {
        final int parentSize = 5;
        insertTestValues(INSERT_PARENT, parentParameters(parentSize));
        insertTestValues(INSERT_CHILD, childParameters(parentSize, 4));

        final List<ParentEntity> parents = parentRepository.findAll();
        parents.forEach(ParentEntity::plus);

        flush();
    }

hibernate.order_updates 속성을 설정하지 않았음에도 ParentEntity 에대한 업데이트가 먼저 실행되고 ChildEntity 에 대한 업데이트가 실행되었습니다.

[main] MySQL : [QUERY] update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=1 and version=0;update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=2 and version=0;update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=3 and version=0;update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=4 and version=0;update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=5 and version=0 [Created on: Mon Sep 21 04:29:44 KST 2020, duration: 2, connection-id: 515, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=1, version=1 where id=1 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=1, version=1 where id=2 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=1, version=1 where id=3 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=1, version=1 where id=4 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=2, version=1 where id=5 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=2, version=1 where id=6 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=2, version=1 where id=7 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=2, version=1 where id=8 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=3, version=1 where id=9 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=3, version=1 where id=10 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=3, version=1 where id=11 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=3, version=1 where id=12 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=4, version=1 where id=13 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=4, version=1 where id=14 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=4, version=1 where id=15 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=4, version=1 where id=16 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=5, version=1 where id=17 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=5, version=1 where id=18 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=5, version=1 where id=19 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=5, version=1 where id=20 and version=0 [Created on: Mon Sep 21 04:29:44 KST 2020, duration: 2, connection-id: 515, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

ParentEntity 내부에 ChildEntityFetchType.LAZY 로 설정되어 있기 때문에 ParentEntity 가 먼저 조회되고 ParentEntity.plus() 호출시 ChildEntity 가 조회되어 엔티티별로 정렬된 것 처럼 보이게 되었습니다.

[main] MySQL : [QUERY] select parententi0_.id as id1_3_, parententi0_.c1 as c2_3_, parententi0_.c2 as c3_3_, parententi0_.c3 as c4_3_, parententi0_.c4 as c5_3_, parententi0_.c5 as c6_3_, parententi0_.version as version7_3_ from parent parententi0_ [Created on: Mon Sep 21 18:17:22 KST 2020, duration: 2, connection-id: 2788, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52)]
[main] MySQL : [QUERY] select children0_.parent_id as parent_i8_0_0_, children0_.id as id1_0_0_, children0_.id as id1_0_1_, children0_.c1 as c2_0_1_, children0_.c2 as c3_0_1_, children0_.c3 as c4_0_1_, children0_.c4 as c5_0_1_, children0_.c5 as c6_0_1_, children0_.parent_id as parent_i8_0_1_, children0_.version as version7_0_1_ from child children0_ where children0_.parent_id=1 [Created on: Mon Sep 21 18:17:22 KST 2020, duration: 3, connection-id: 2788, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52)]
[main] MySQL : [QUERY] select children0_.parent_id as parent_i8_0_0_, children0_.id as id1_0_0_, children0_.id as id1_0_1_, children0_.c1 as c2_0_1_, children0_.c2 as c3_0_1_, children0_.c3 as c4_0_1_, children0_.c4 as c5_0_1_, children0_.c5 as c6_0_1_, children0_.parent_id as parent_i8_0_1_, children0_.version as version7_0_1_ from child children0_ where children0_.parent_id=2 [Created on: Mon Sep 21 18:17:22 KST 2020, duration: 2, connection-id: 2788, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52)]
[main] MySQL : [QUERY] select children0_.parent_id as parent_i8_0_0_, children0_.id as id1_0_0_, children0_.id as id1_0_1_, children0_.c1 as c2_0_1_, children0_.c2 as c3_0_1_, children0_.c3 as c4_0_1_, children0_.c4 as c5_0_1_, children0_.c5 as c6_0_1_, children0_.parent_id as parent_i8_0_1_, children0_.version as version7_0_1_ from child children0_ where children0_.parent_id=3 [Created on: Mon Sep 21 18:17:22 KST 2020, duration: 3, connection-id: 2788, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52)]
[main] MySQL : [QUERY] select children0_.parent_id as parent_i8_0_0_, children0_.id as id1_0_0_, children0_.id as id1_0_1_, children0_.c1 as c2_0_1_, children0_.c2 as c3_0_1_, children0_.c3 as c4_0_1_, children0_.c4 as c5_0_1_, children0_.c5 as c6_0_1_, children0_.parent_id as parent_i8_0_1_, children0_.version as version7_0_1_ from child children0_ where children0_.parent_id=4 [Created on: Mon Sep 21 18:17:22 KST 2020, duration: 3, connection-id: 2788, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52)]
[main] MySQL : [QUERY] select children0_.parent_id as parent_i8_0_0_, children0_.id as id1_0_0_, children0_.id as id1_0_1_, children0_.c1 as c2_0_1_, children0_.c2 as c3_0_1_, children0_.c3 as c4_0_1_, children0_.c4 as c5_0_1_, children0_.c5 as c6_0_1_, children0_.parent_id as parent_i8_0_1_, children0_.version as version7_0_1_ from child children0_ where children0_.parent_id=5 [Created on: Mon Sep 21 18:17:22 KST 2020, duration: 2, connection-id: 2788, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52)]

이번에는 ParentEntity 조회시 ChildEntity 또한 함께 조회될 수 있도록 페치조인을 이용해 조회하여 업데이트 해보도록 하겠습니다. ParentEntityRepository

public interface ParentEntityRepository extends JpaRepository<ParentEntity, Long> {
    @Query("SELECT DISTINCT p FROM ParentEntity p join fetch p.children ORDER BY p.id")
    List<ParentEntity> findAllParentAndChild();
}

ParentEntityRepositoryTest.updateAll2

    void updateAll2() throws Exception {
        final int parentSize = 5;
        insertTestValues(INSERT_PARENT, parentParameters(parentSize));
        insertTestValues(INSERT_CHILD, childParameters(parentSize, 4));

        final List<ParentEntity> parents = parentRepository.findAllParentAndChild();
        parents.forEach(ParentEntity::plus);

        flush();
    }

페치조인을 통해 ParentEntityChildEntity 를 동시에 조회후 업데이트를 하였더니 이번에는 ParentEntityChildEntity 가 번갈아 가면서 업데이트가 실행된 것을 확인 할 수 있습니다.

[main] MySQL : [QUERY] update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=1 and version=0 [Created on: Mon Sep 21 04:41:27 KST 2020, duration: 2, connection-id: 525, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=1, version=1 where id=4 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=1, version=1 where id=3 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=1, version=1 where id=2 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=1, version=1 where id=1 and version=0 [Created on: Mon Sep 21 04:41:27 KST 2020, duration: 2, connection-id: 525, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=2 and version=0 [Created on: Mon Sep 21 04:41:27 KST 2020, duration: 2, connection-id: 525, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=2, version=1 where id=8 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=2, version=1 where id=7 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=2, version=1 where id=6 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=2, version=1 where id=5 and version=0 [Created on: Mon Sep 21 04:41:27 KST 2020, duration: 2, connection-id: 525, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=3 and version=0 [Created on: Mon Sep 21 04:41:27 KST 2020, duration: 1, connection-id: 525, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=3, version=1 where id=12 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=3, version=1 where id=11 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=3, version=1 where id=10 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=3, version=1 where id=9 and version=0 [Created on: Mon Sep 21 04:41:27 KST 2020, duration: 2, connection-id: 525, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=4 and version=0 [Created on: Mon Sep 21 04:41:27 KST 2020, duration: 2, connection-id: 525, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=4, version=1 where id=16 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=4, version=1 where id=15 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=4, version=1 where id=14 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=4, version=1 where id=13 and version=0 [Created on: Mon Sep 21 04:41:27 KST 2020, duration: 2, connection-id: 525, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=5 and version=0 [Created on: Mon Sep 21 04:41:27 KST 2020, duration: 2, connection-id: 525, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=5, version=1 where id=20 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=5, version=1 where id=19 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=5, version=1 where id=18 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=5, version=1 where id=17 and version=0 [Created on: Mon Sep 21 04:41:27 KST 2020, duration: 2, connection-id: 525, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

application.yml 파일의 hibernate.order_updates 속성을 true 로 설정하고 다시한번 ParentEntityRepositoryTest.updateAll2 를 실행해 보겠습니다.

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          order_updates: true

hibernate.order_updates 속성을 true 로 적용후의 결과는 아래와 같이 엔티티별로 업데이트 쿼리가 한번씩 실행된 것을 확인할 수 있습니다.

[main] MySQL : [QUERY] update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=1, version=1 where id=1 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=1, version=1 where id=2 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=1, version=1 where id=3 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=1, version=1 where id=4 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=2, version=1 where id=5 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=2, version=1 where id=6 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=2, version=1 where id=7 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=2, version=1 where id=8 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=3, version=1 where id=9 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=3, version=1 where id=10 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=3, version=1 where id=11 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=3, version=1 where id=12 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=4, version=1 where id=13 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=4, version=1 where id=14 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=4, version=1 where id=15 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=4, version=1 where id=16 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=5, version=1 where id=17 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=5, version=1 where id=18 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=5, version=1 where id=19 and version=0;update child set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', parent_id=5, version=1 where id=20 and version=0 [Created on: Mon Sep 21 04:46:49 KST 2020, duration: 2, connection-id: 535, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=1 and version=0;update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=2 and version=0;update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=3 and version=0;update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=4 and version=0;update parent set c1=2, c2='c2-2', c3='c3-2', c4='c4-2', c5='c5-2', version=1 where id=5 and version=0 [Created on: Mon Sep 21 04:46:49 KST 2020, duration: 3, connection-id: 535, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

하나의 트랜잭션 안에서 IDENTITY 적용된 엔티티와 IDENTITY 적용되지 않은 엔티티 번갈아가며 SAVE

하나의 트랜잭션 안에서 IdentityEntityNonIdentityEntity 를 번갈아 가며 save() 를 호출해 보겠습니다.

CompositeTest.saveIdentityAndNonIdentity()

    void saveIdentityAndNonIdentity() throws Exception {
        final int size = 5;
        final List<IdentityEntity> identityEntities = Stream.generate(IdentityEntity::of)
                .limit(size)
                .collect(Collectors.toList());

        final List<NonIdentityEntity> nonIdentityEntities = LongStream.rangeClosed(1, size)
                .mapToObj(NonIdentityEntity::of)
                .collect(Collectors.toList());

        for (int i = 0; i < size; i++) {
            identityEntityRepository.save(identityEntities.get(i));
            nonIdentityEntityRepository.save(nonIdentityEntities.get(i));
        }
    }

테스트에서 flush() 를 호출하지 않았음에도 결과를 보면 NonIdentityEntity의 insert 쿼리가 실행된 것을 확인할 수 있습니다.

[main] MySQL : [QUERY] insert into identity (c1, c2, c3, c4, c5, version) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0) [Created on: Mon Sep 21 05:01:01 KST 2020, duration: 3, connection-id: 575, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)]
[main] MySQL : [QUERY] insert into non_identity (c1, c2, c3, c4, c5, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 1) [Created on: Mon Sep 21 05:01:01 KST 2020, duration: 3, connection-id: 575, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] insert into identity (c1, c2, c3, c4, c5, version) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0) [Created on: Mon Sep 21 05:01:01 KST 2020, duration: 3, connection-id: 575, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)]
[main] MySQL : [QUERY] insert into non_identity (c1, c2, c3, c4, c5, version, id) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0, 2) [Created on: Mon Sep 21 05:01:01 KST 2020, duration: 1, connection-id: 575, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]
[main] MySQL : [QUERY] insert into identity (c1, c2, c3, c4, c5, version) values (1, 'c2-1', 'c3-1', 'c4-1', 'c5-1', 0) [Created on: Mon Sep 21 05:01:01 KST 2020, duration: 3, connection-id: 575, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)]

IDENTITY 가 적용된 엔티티를 저장할때는 항상 ActionQueue 내부에 이미 저장되어 있는 insert 작업들을 먼저 실행 후 IDENTITY 를 저장하기 때문에 IdentityEntityNonIdentityEntity 가 번갈아가며 insert 문이 실행되었습니다.
그리고 IDENTITY 가 적용된 엔티티를 insert 할 경우에는 예외적으로 ActionQueue 에 insert 작업을 넣지 않고 바로 insert 작업을 실행합니다.

max_allowed_packet

max_allowed_packet 은 DB 서버에서 한번에 받아들일 수 있는 쿼리 크기 라고도 볼 수 있습니다.
먼저 show variables where Variable_name = 'max_allowed_packet' 명령어를 실행해 로컬 MySQL 의 max_allowed_packet 크기를 확인해 보겠습니다.

제 로컬 MySQL 의 max_allowed_packet 의 크기는 현재 4MB 로 설정되어 있습니다.

max_allowed_packet 크기에 따른 배치로 insert / update 할 때의 영향에 대해 알아보기 위해 이번에는 UUIDEntity 라는 엔티티를 만들었습니다.
id 는 UUID 이고, 엔티티 생성시 id 필드를 제외한 모든 필드에 252 글자의 문자열을 입력하도록 만들었습니다.

max_allowed_packet 크기를 넘기기 위해 3,000개의 엔티티를 생성하여 한번에 저장해 보도록 하겠습니다.

UUIDEntityRepositoryTest.saveAll

    void saveAll() throws Exception {
        final List<UUIDEntity> uuidEntities = Stream.generate(UUIDEntity::of)
                .limit(SIZE)
                .collect(Collectors.toList());

        uuidEntityRepository.saveAll(uuidEntities);
        flush();

        final long count = uuidEntityRepository.count();
        assertThat(count).isEqualTo(SIZE);
    }

엄청난 양의 로그를 남기긴 했지만 insert 는 성공하였습니다.

... (1, '52c69227-5547-4266-be5a-0a7baa1cfc2b52c69227-5547-4266-be5a-0a7baa1cfc2b52c69227-5547-4266-be5a-0a7baa1cfc2b52c69227-5547-4266-be5a-0a7baa1cfc2b52c69227-5547-4266-be5a-0a7baa1cfc2b52c69227-5547-4266-be5a-0a7baa1cfc2b52c69227-5547-4266-be5a-0a7baa1cfc2b', '830179b3-6675-48a8-9423-8fd264ceb8fb830179b3-6675-48a8-9423-8fd264ceb8fb830179b3-6675-48a8-9423-8fd264ceb8fb830179b3-6675-48a8-9423-8fd264ceb8fb830179b3-6675-48a8-9423-8fd264ceb8fb830179b3-6675-48a8-9423-8fd264ceb8fb830179b3-6675-48a8-9423-8fd264ceb8fb', '4def7d23-a81d-4813-8b0d-a81ca32e30eb4def7d23-a81d-4813-8b0d-a81ca32e30eb4def7d23-a81d-4813-8b0d-a81ca32e30eb4def7d23-a81d-4813-8b0d-a81ca32e30eb4def7d23-a81d-4813-8b0d-a81ca32e30eb4def7d23-a81d-4813-8b0d-a81ca32e30eb4def7d23-a81d-4813-8b0d-a81ca32e30eb', 'd5ca6c6c-04c7-44d9-900b-ae887be1c2e6d5ca6c6c-04c7-44d9-900b-ae887be1c2e6d5ca6c6c-04c7-44d9-900b-ae887be1c2e6d5ca6c6c-04c7-44d9-900b-ae887be1c2e6d5ca6c6c-04c7-44d9-900b-ae887be1c2e6d5ca6c6c-04c7-44d9-900b-ae887be1c2e6d5ca6c6c-04c7-44d9-900b-ae887be1c2e6', '4e149702-face-40fb-a33c-d9777c9c09494e149702-face-40fb-a33c-d9777c9c09494e149702-face-40fb-a33c-d9777c9c09494e149702-face-40fb-a33c-d9777c9c09494e149702-face-40fb-a33c-d9777c9c09494e149702-face-40fb-a33c-d9777c9c09494e149702-face-40fb-a33c-d9777c9c0949', 'dc5347e0-43d4-439f-acf1-c20b2221d36bdc5347e0-43d4-439f-acf1-c20b2221d36bdc5347e0-43d4-439f-acf1-c20b2221d36bdc5347e0-43d4-439f-acf1-c20b2221d36bdc5347e0-43d4-439f-acf1-c20b2221d36bdc5347e0-43d4-439f-acf1-c20b2221d36bdc5347e0-43d4-439f-acf1-c20b2221d36b', '235c2167-1d56-43a2-a953-6fa12fe882aa235c2167-1d56-43a2-a953-6fa12fe882aa235c2167-1d56-43a2-a953-6fa12fe882aa235c2167-1d56-43a2-a953-6fa12fe882aa235c2167-1d56-43a2-a953-6fa12fe882aa235c2167-1d56-43a2-a953-6fa12fe882aa235c2167-1d56-43a2-a953-6fa12fe882aa', 'd6866e09-2757-4fba-a192-06d960e9f73dd6866e09-2757-4fba-a192-06d960e9f73dd6866e09-2757-4fba-a192-06d960e9f73dd6866e09-2757-4fba-a192-06d960e9f73dd6866e09-2757-4fba-a192-06d960e9f73dd6866e09-2757-4fba-a192-06d960e9f73dd6866e09-2757-4fba-a192-06d960e9f73d ... (truncated) [Created on: Mon Sep 21 05:31:25 KST 2020, duration: 198, connection-id: 586, statement-id: 0, resultset-id: 0,	at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]

이번에는 쿼리를 문자열로 생성하여 한번에 호출해 보겠습니다. NonBatchTest.saveOnce()

    void saveOnce() throws Exception {
        String values = Stream.generate(this::generateValues)
                .limit(SIZE)
                .collect(Collectors.joining(","));

        assertThatThrownBy(() -> namedParameterJdbcTemplate.update(INSERT_ONCE + values, Map.of()))
                .isInstanceOf(TransientDataAccessResourceException.class)
                .hasMessageContaining("max_allowed_packet");
    }

문자열로 연결하여 쿼리를 한번에 실행하니 Packet for query is too large (7,656,069 > 4,194,304). You can change this value on the server by setting the 'max_allowed_packet' variable. 라는 메시지를 남기며 예외가 발생했습니다.

이번에는 동일한 갯수를 JdbcTemplate.batchUpdate() 를 통해 저장해 보도록 하겠습니다. NonBatchTest.saveBatch()

    void saveBatch() throws Exception {
        namedParameterJdbcTemplate.batchUpdate(INSERT_BATCH, parameters(SIZE));

        final Integer count = namedParameterJdbcTemplate.queryForObject(COUNT_QUERY, Map.of(), Integer.class);

        assertThat(count).isEqualTo(SIZE);
    }

JdbcTemplate.batchUpdate() 를 통해 동일한 갯수를 저장하니 이번에는 예외가 발생하지 않고 잘 저장이 되었습니다.

MySQL 드라이버와 MariaDB 드라이버 모두 배치로 insert 나 update 를 실행할 경우 쿼리를 DB 로 전송하기 전에 DB 의 max_allowed_packet 값과 쿼리의 길이를 비교하여 max_allowed_packet 미만으로 분할하여 전송하고 있어 하이버네이트 배치나 JdbcTemplate.batchUpdate() 로 대량의 값을 한번에 저장하여도 예외가 발생하지 않았습니다.

하이버네이트 배치를 적용할 경우 드라이버에서 max_allowed_packet 미만으로 분할해서 쿼리를 전송하고 있어, max_allowed_packet 크기를 고려하지 않고 로직에 알맞은 적절한 batch_size 를 지정할 수 있을것 같습니다.

실행시간 비교

일반적인 배치 애플리케이션에서 작업하는것 처럼 10,000 건 저장후 500 건씩 분할 조회하여 업데이트 하는 시간을 1번씩만 실행하여 비교해 보겠습니다. NonIdentityEntityRepositoryTest.updateLoop()

    void updateLoop() throws Exception {
        final int insertSize = 10_000;
        insertTestValues(INSERT_NON_IDENTITY, nonIdentityParameters(insertSize));

        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        final int pageSize = 500;

        Pageable pageable = PageRequest.of(0, pageSize);
        while (true) {
            final Slice<NonIdentityEntity> slice = nonIdentityEntityRepository.findAllIdentityEntities(pageable);
            final List<NonIdentityEntity> nonIdentityEntities = slice.getContent();
            nonIdentityEntities.forEach(NonIdentityEntity::plus);
            flush();
            if (slice.isLast()) {
                break;
            }
            pageable = slice.nextPageable();
        }
        stopWatch.stop();

        System.out.printf("%n%n%n결과(초) : %f%n%n%n%n", stopWatch.getTotalTimeSeconds());
    }
  • 하이버네이트 배치 적용 : 4.115409851 초
  • 하이버네이트 배치 미적용 : 17.423250337 초

끝!!!

그나마 사용할줄 아는 RDBMS 가 MySQL 밖에 없어 MySQL 위주로만 테스트해보고 글을 적었습니다.
MySQL 이 아닌 다른 RDBMS 의 경우에는 제가 테스트 한 것과 다른 동작을 할 수 있으니 한번씩 테스트 해보고 적용해 보시면 좋겠습니다.

감사합니다.

참고