sign_pick 이벤트 시즌1 하이버네이트 배치 설정
sign_pick 이벤트 시즌1 하이버네이트 배치 설정

sign_pick 이벤트 시즌1 하이버네이트 배치 설정

안녕하세요. cosign에서 back-end 개발을 하고 있는 Ian입니다.
sign pick 이벤트 시즌1을 진행하면서 Hibernate Batch를 통해 대량 update 시 속도를 개선한 점을 적어보고자 합니다.

Hibernate Batch란?

Batch는 이름에서 보이다시피 ‘일괄’이라는 의미를 내포합니다. 때문에 개발자들에게는 Batch라는 작업은 보통 대량의 작업을 한번에 처리하는 경우를 말합니다.

Hibernate는 객체-관계 매핑 해주는 ORM 기술을 사용하여 관계형 데이터 베이스를 다룹니다. ORM 기술은 일반적으로 단일 데이터 조작을 위해 설계되었습니다. 그렇기 때문에 대량의 처리를 수행하려면 객체를 통한 조작 보다는 DB를 통한 SQL문이나 프로시저를 이용한 대용량 처리가 더 효율적일 것입니다.

하지만 Hibernate는 대용량 처리 Batch로 작업할 수 있게 다양한 기능을 제공합니다. Hibernate Batch를 이용하면 데이터베이스 성능을 최적화하고 시스템 부하를 줄일 수 있습니다.

Hibernate Batch의 간단한 순서를 살펴보자면 대량의 데이터를 읽어들인 후 메모리에 로딩하며 로딩된 데이터를 처리하기 적절한 SQL 쿼리를 생성합니다. 생성된 SQL 쿼리를 데이터베이스에 전달하여 실행합니다. 모든 배치 작업이 완료되면, 트랜잭션을 커밋하는 과정을 거칩니다. 이로써 얻을 수 있는 장점은 뭐가 있을까요?

바로, DB 와 통신하는 횟수도 줄어들고, DB 에서도 락을 잡는 횟수가 줄어들어 실행 속도가 향상된다는 것입니다. 여기서 기억해야 할 건, 대량의 데이터를 읽어 메모리에 로딩시킨 후 모든 배치 작업이 완료 되면 트랜잭션을 커밋한다는 점입니다.

Spring에서 Batch 설정하기

Hibernate Batch의 설정은 아주 간단합니다. application.yml 파일에 하이버네이트 배치 설정만 해주면 됩니다. 또한, batch size를 통해 유동적으로 batch size를 늘리고 줄일 수 있습니다.

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

적당한 Batch size

100~1000 사이를 선택하는 것을 권장한다고 합니다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다고 합니다. 결국 DB와 애플리케이션에서 순간적인 부하들을 어느 정도까지 버틸 수 있는지 파악 한 후에 결정하면 됩니다.


Hibernate Batch 특징

Insert & Save

Save 시 가장 큰 특징으로는 GenerationType.IDENTITY 전략을 사용하면 Batch insert가 안됩니다. 이와 비슷하게 Spring Data JPA에서는saveAll()이라는 bulk성 메서드를 지원하여 DB에 한번에 전달해줍니다. 이 기능또한 활용하지 못합니다. Persistence Context 내부에서 엔티티를 식별할때는 엔티티 타입과 엔티티의 id 값으로 엔티티를 식별하지만 IDENTITY전략의 경우 DB에 insert 문을 실행해야만 id 값을 확인 가능하다고 합니다. 따라서 PK 값을 미리 알 수 있는 방식을 사용해야 합니다.

눈으로 봐야 오래 기억한다는 말을 기억하며, 간단한 엔티티로 테스트를 진행해보겠습니다.

public class Bookmark {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bookmarkId;
    
    // ... getter, setter
    
}
  • Batch를 통한 save
    • insert 갯수는 5개 입니다.
    • DB에 값이 insert 되지 않게 @Transactional을 적용하였습니다.
    • createBookmarkEntities()메서드는 엔티티를 만드는 메서드 입니다.
@DisplayName("Batch Insert 적용")
@Test
@Transactional
void saveForBatch(){
    List<Bookmark> bookmarkEntities = bookmark.createBookmarkEntities();

    for(Bookmark bookmarkEntity: bookmarkEntities){
        bookmarkRepository.save(bookmarkEntity);
    }
}
Hibernate: 
    insert 
    into
        bookmark
        (hidden data, hidden data, hidden data, hidden data) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        bookmark
        (hidden data, hidden data, hidden data, hidden data)
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        bookmark
        (hidden data, hidden data, hidden data, hidden data)
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        bookmark
        (hidden data, hidden data, hidden data, hidden data)
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        bookmark
        (hidden data, hidden data, hidden data, hidden data)
    values
        (?, ?, ?, ?)

  • Spring Data JPA saveAll()
@DisplayName("saveAll() 메서드 적용")
@Test
@Transactional
void saveAll(){
    List<Bookmark> bookmarkEntities = bookmark.createBookmarkEntities();
    bookmarkRepository.saveAll(bookmarkEntities);
}
Hibernate: 
    insert 
    into
        bookmark
        (hidden data, hidden data, hidden data, hidden data)
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        bookmark
        (hidden data, hidden data, hidden data, hidden data)
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        bookmark
        (hidden data, hidden data, hidden data, hidden data)
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        bookmark
        (hidden data, hidden data, hidden data, hidden data)
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        bookmark
        (hidden data, hidden data, hidden data, hidden data)
    values
        (?, ?, ?, ?)

둘 다 단건 쿼리로 호출되는 것을 볼 수 있습니다.

식별자 전략을 잘 설계해야 한다는 것을 다시 한번 체감하게 되었습니다. 대량의 데이터들이 insert가 많이 일어나는 엔티티는 GenerationType.IDENTITY 전략 사용 여부에 대한 단서가 될 수 있습니다.


Update

IDENTITY 전략을 가진 엔티티는 Insert 시 Batch가 되지 않는다는 것을 알게 되었습니다. 그렇다면 대량의 데이터가 update 시 GenerationType.IDENTITY전략을 가져갔을 때도 적용이 안되는지 직접 눈으로 확인 해봐야겠죠?

sign_pick 이벤트에 사용된 엔티티로 간단하게 테스트 해보겠습니다.

public class SignPickEventUserInfo {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long signPickEventUserInfoId;
    
    // ... 아래 컬럼 생략
    
}
@DisplayName("Batch update 적용")
@Test
@Transactional
void updateBatch(){
    List<SignPickEventUserInfo> signPickEventUserInfoList = signPickEventUserInfoRepository.findAll();
    int temp = 0;
    for(SignPickEventUserInfo info : signPickEventUserInfoLis){
      info.updateEntity(temp); // update문
      temp++;
}
Hibernate: 
    update
        sign_pick_event_user_info 
    set
        hidden data=?,
        hidden data=?,
        hidden data=?,
        hidden data=?,
        hidden data=?,
        hidden data=?
    where
        id=?
Hibernate: 
    update
        sign_pick_event_user_info 
    set
        hidden data=?,
        hidden data=?,
        hidden data=?,
        hidden data=?,
        hidden data=?,
        hidden data=?
    where
        id=?
Hibernate: 
    update
        sign_pick_event_user_info 
    set
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?
    where
        id=?
Hibernate: 
    update
        sign_pick_event_user_info 
    set
        hidden data=?,
        hidden data=?,
        hidden data=?,
        hidden data=?,
        hidden data=?,
        hidden data=?
    where
        id=?

저희는 분명 Hibernate Batch를 적용하였고 콘솔을 보면 기대와 달리 update 문이 단건으로 나가는 것을 볼 수 있다. ‘batch가 적용이 안되었나?’ 라고 생각 하게 되었고, 구글을 통해 하나의 블로그를 발견했습니다.

https://kwonnam.pe.kr/wiki/java/hibernate/batch 블로그의 글을 보면 Batch가 왜 적용이 안됐는지 알 수 있습니다.

batch 옵션은 Hibernate가 직접 Insert 문을 insert into xxx (…) values(…), (….), …. 형태로 합쳐주는 것이 아닌 단지 addBatch를 할 뿐이라고 합니다. 때문에 MySQL DB를 사용한다면 rewriteBatchedStatements=true을 사용하면 됩니다. 실제로 쿼리를 하나로 합치는 것은 각 DB Driver 에서 이뤄지는 것을 알 수 있습니다.

저희 회사는 MySQL이 아닌 MariaDB를 사용합니다. MariaDB는 useBatchMultiSend 속성을 가지고 있으며 기본값이 true 입니다. 그래서 내부적으로 rewriteBatchedStatements 속성을 가장 먼저 확인하고, rewriteBatchedStatements 가 false 로 설정되어 있다면 useBatchMultiSend 여부를 판단하여 쿼리를 배치로 실행하고 있습니다.

관련 자료: https://mariadb.com/kb/en/option-batchmultisend-description

그치만 저희는 눈으로 확인해봐야겠죠? profileSQL=true 옵션을 통해 Driver에서 전송하는 쿼리 및 해당 실행시간을 출력하여 확인 해볼 수 있습니다.

spring:
  datasource:
    hikari:
      jdbc-url: jdbc:mariadb://localhost:20002/dev?profileSQL=true&maxQuerySizeToLog=0 // &rewriteBatchedStatements=true
  • profileSQL=true : Driver 에서 전송하는 쿼리를 출력합니다.
  • maxQuerySizeToLog=0 MariaDB Driver는 기본값이 1024로 지정되어 있습니다. MySQL Driver와는 달리 0으로 지정시 쿼리의 글자 제한이 무제한으로 설정됩니다.

다시 한번 update문을 통해 Batch가 잘 적용되었는지 확인 해보겠습니다.

Hibernate: 
    update
        sign_pick_event_user_info 
    set
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?
    where
        id=?
Hibernate: 
    update
        sign_pick_event_user_info 
    set
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?
    where
        id=?
Hibernate: 
    update
        sign_pick_event_user_info 
    set
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?
    where
        id=?
Hibernate: 
    update
        sign_pick_event_user_info 
    set
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?,
       hidden data=?
    where
        id=?
[2023-03-29 05:36:30:90932][http-nio-20002-exec-2] INFO  o.m.j.i.logging.ProtocolLoggingProxy - conn=4727496(M) - 5.456 ms - Query: update sign_pick_event_user_info set hidden data=?, hidden data=?, hidden data=?, hidden data=?, hidden data=?, hidden data=? where id=?, parameters ['2023-03-29 05:36:30.2394401',hidden data,hidden data,hidden data,hidden data,hidden data,hidden data],['2023-03-29 05:36:30.2514396',hidden data,hidden data,hidden data,hidden data,hidden data,hidden data],['2023-03-29 05:36:30.2514396,hidden data,hidden data,hidden data,hidden data,hidden data,hidden data]
[2023-03-29 05:36:30:90940][http-nio-20002-exec-2] INFO  o.m.j.i.logging.ProtocolLoggingProxy - conn=4727496(M) - 5.827 ms - Query: COMMIT
[2023-03-29 05:36:30:90946][http-nio-20002-exec-2] INFO  o.m.j.i.logging.ProtocolLoggingProxy - conn=4727496(M) - 5.862 ms - Query: set autocommit=1

마지막 부분의 로그를 확인 해보면 Batch로 update가 된 것을 확인 할 수 있었습니다.

즉, 성공적으로 batch가 적용된 것을 눈으로 확인한 것입니다.

이제 저희는 Batch를 통해 많은 양의 데이터들을 가지고 insert와 update가 가능하다는 것을 알게 되었습니다.

끝으로…

sign_pick 이벤트 시즌1은 정해진 시간마다 참여자들의 랭킹 업데이트와 수익률 합산 처리를 해야 했습니다. 만약 Batch를 사용하지 않았다면 수 많은 update문이 DB에 호출될 것입니다. 그로 인해 DB 와 통신하는 횟수가 늘어나 예상치 못한 장애가 발생할 수도 있었을 것입니다.

물론 더 대용량의 update가 필요하다면 Batch를 사용하는 것이 아닌 다른 방법들을 모색 해야 하겠지만 엄청난 대용량의 update는 이루어지지 않을꺼라는 추측과 함께 Batch라는 방법을 사용해봤습니다. 그로 인해 몰랐던 부분도 새로 알게 되면서 성장할 수 있는 좋은 기회였다고 생각합니다.

그럼…더욱 완성된 sign_pick이벤트 시즌 2로 다시 찾아 뵙겠습니다~

참고 자료

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다