티스토리 뷰

배경

jpa는 insert는 save로, update는 변경감지를 통해 업데이트가 진행됩니다.

문제는 이 작업들이 단 건으로 진행된다는 점이죠.

saveAll이 있으나 내부를 살펴보면 반복문을 통해 save로 저장해주는 식으로 되어 있어서 단 건으로 진행되는 걸 막을 수 없습니다.

이에 대한 문제를 살펴보고 어떤 방법으로 해결했는지 다루려고 합니다.

🐻
테스트 데이터베이스는 MariaDB를 사용하였습니다.

 

테스트 코드의 시나리오는 여러 입고 주문들을 비활성화 처리합니다.

테스트 코드는 다음과 같습니다.

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
@Import({TestJPAQueryFactoryConfig.class})
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace= AutoConfigureTestDatabase.Replace.NONE)
public class InboundRepositoryTest {
    private static final int TEST_COUNT = 6;

    @Autowired
    private InboundRepository inboundRepository;

    List<Long> ids;
    int givenOrderId = 1;

    @BeforeEach
    void init() {
        ids = createInbound(TEST_COUNT);
    }

    @AfterEach
    void flush() {
        inboundRepository.flush();
    }

    @Test
    @DisplayName("bulk update test")
    void inbound_bulk_update_test() {
        // given
        List<Inbound> inbounds = inboundRepository.findAllByIdInAndActivated(ids, BaseEntity.ActiveYN.Y);

        // when
        for(Inbound inbound : inbounds) {
            inbound.delete();
        }

    }

    private List<Long> createInbound(int testCount) {
        List<Inbound> inbounds = new ArrayList<>();
        for (int i = 0; i < testCount; i++) {
            Inbound inbound = Inbound.builder()
                    .centerId(1L)
                    .wikey("aaa")
                    .category(InboundCategory.ETC)
                    .statusCode(InboundStatusCode.COMPLETED)
                    .seq(1)
                    .build();
            inbounds.add(inbound);
        }
        inboundRepository.saveAll(inbounds);
        inboundRepository.flush();
        return inbounds.stream().map(Inbound::getId).collect(Collectors.toList());
    }
}

단건 INSERT, UPDATE 예시

아래 이미지는 위 테스트를 실행한 결과 콘솔 캡처 화면입니다.

TEST_COUNT 6만큼 단 건으로 INSERT와 UPDATE가 된 걸 보실 수 있습니다.

TEST_COUNT가 100, 200이 넘어간다면 DB 접근은 그만큼 많아지므로 성능 문제를 피할 수 없게 됩니다.

단건 UPDATE 해결 - 하이버네이트 배치

단건 UPDATE는 하이버네이트 배치 기능을 사용하여 해결보겠습니다.

하이버네이트 배치기능을 적용하게 되면 설정한 배치 개수에 도달할 때까지 PreparedStatement.addBatch()를 호출 하여 실행할 쿼리를 추가하고, 설정한 배치 갯수에 도달하게 되면 PreparedStatement.executeBatch()를 호출합니다.

다수의 쿼리를 한번에 모아서 처리하기 때문에 단 건씩 쿼리를 수행할 때에 비해 DB 접근도 줄어들고, DB에서도 락을 잡는 횟수가 줄어들어 실행 속도 개선을 할 수 있습니다.

하이버네이트 배치기능을 적용하기 - application.yml 설정

하이버네이트 배치기능을 적용하려면 applicaiton.yml을 변경해주면 됩니다.

spring:
  config:
    activate:
      on-profile: test
  datasource:
    url: ${jdbc_url}?rewriteBatchedStatements=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&profileSQL=true
    username: ${username}
    password: ${password}
    #driver-class-name: com.mysql.cj.jdbc.Driver
    driver-class-name: org.mariadb.jdbc.Driver
  jpa:
    hibernate:
      dialect: org.hibernate.dialect.MariaDBDialect
      #dialect: org.hibernate.dialect.MySQL57Dialect
      ddl-auto: validate
    properties:
      hibernate:
        check_nullability: true
        format_sql: true
        show_sql: false
        jdbc:
          batch_size: 3
          batch_versioned_data: true
          order_inserts: true
          order_updates: true

여기서 핵심은 다음과 같습니다.

  • spring.datasource.url에 rewriteBatchedStatements 옵션 true로 변경
  • spring.jpa.properties:hibernate.jdbc.batch_size로 배치 개수 설정

batch_size를 3으로 설정하고 6개 데이터를 UPDATE를 진행해보겠습니다.

위 이미지를 보시면 update 쿼리가 이전과 다르게 2번만 실행된 걸 보실 수 있습니다.

아래 코드는 콘솔 내용을 캡쳐해온 내용입니다.

10.148 ms - Query: update `INBOUND` set `activated`=? where `id`=?, parameters [0,2131],[0,2132],[0,2133]
9.851 ms - Query: update `INBOUND` set `activated`=? where `id`=?, parameters [0,2134],[0,2135],[0,2136]

한 쿼리 당 3개(batch_size)의 파라미터를 넘겨 쿼리를 실행한 걸 볼 수 있습니다.

🤔
INSERT도 batch_size로 해결할 수 있지 않을까요?

 

아쉽지만, INSERT는 6건이 실행되었습니다.

하이버네이트 배치 문서를 보시면 테이블의 PK가 @GeneratedValue(strategy = GenerationType.IDENTITY)라면 배치 기능이 동작하지 않는다고 합니다.

단건 INSERT 해결 - @GeneratedValue 변경

위에서 언급했듯이 GeneratedValue가 bulk insert가 실패하는 원인이라면 이 부분을 변경해주면 됩니다.

Inbound 클래스를 다음과 같이 변경해줍니다.

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.GenericGenerator;
import org.hibernate.annotations.Parameter;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.id.enhanced.SequenceStyleGenerator;
import org.springframework.context.annotation.Profile;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.OneToMany;
import javax.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import static javax.persistence.EnumType.STRING;

@Entity
@Table(name = "INBOUND")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Inbound extends BaseEntity {

//    @GeneratedValue(strategy = GenerationType.IDENTITY)
//    @Column(name = "id", updatable = false)
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "test-sequence-generator")
    @GenericGenerator(
            name = "test-sequence-generator",
            strategy = "sequence",
            parameters = {
                    @Parameter(name = SequenceStyleGenerator.SEQUENCE_PARAM, value = SequenceStyleGenerator.DEF_SEQUENCE_NAME),
                    @Parameter(name = SequenceStyleGenerator.INITIAL_PARAM, value = "1"),
                    @Parameter(name = SequenceStyleGenerator.INCREMENT_PARAM, value = "5"),
                    @Parameter(name = AvailableSettings.PREFERRED_POOLED_OPTIMIZER, value = "pooled-lo")
            }
    )
    private Long id;

    // ...
}

위처럼 GeneratedValue를 다음과 같이 변경해줍니다.

각 요소에 대한 설명을 알아보기 전에 테스트 결과부터 확인해봅시다.

아래 코드는 콘솔 내용을 캡쳐해온 내용입니다.

20220805 15:14:55.138 [Test worker] [] [INFO ] [ProtocolLoggingProxy:128] - - - conn=126617(M) - 13.324 ms - Query: insert into `INBOUND` (`activated`, `created_at`,`id`) values (?, ?, ?), parameters [1,1],[1,2],[1,3]
20220805 15:14:55.401 [Test worker] [] [INFO ] [ProtocolLoggingProxy:128] - - - conn=126617(M) - 261.569 ms - Query: insert into `INBOUND` (`activated`, `created_at`,`id`) values (?, ?, ?), parameters [1,4],[1,5],[1,6]

UPDATE와 마찬가지로 batch_size(3)씩 TEST_CODE(6)에 쿼리를 나눠서 실행되었습니다.

drop table hibernate_sequence;
    create table hibernate_sequence (
       next_val bigint
    ) engine=InnoDB;

insert into hibernate_sequence values ( 1 );

위를 실행하려면 hibernate_sequence 테이블을 필요로 합니다.

여기서 hibernate_sequence 테이블이 필요한 이유는 다음 코드에서 설정을 해줬기 때문입니다.

@Parameter(name = SequenceStyleGenerator.SEQUENCE_PARAM, value = SequenceStyleGenerator.DEF_SEQUENCE_NAME),

hibernate_sequence 테이블에 insert를 해주는 이유는 다음 코드 때문입니다.

@Parameter(name = SequenceStyleGenerator.INITIAL_PARAM, value = "1"),

초기값을 1로 설정해줬기 때문에 hibernate_sequence 테이블에 1을 추가해줬습니다.

컬럼명 그대로 다음 PK로 들어갈 id 값이 1이라는 뜻입니다.

@Parameter(name = SequenceStyleGenerator.INCREMENT_PARAM, value = "5"),

다음은 INCREMENT_PARAM에 대해서 살펴봅시다.

INCREMENT_PARAM 값이 5, TEST_COUNT를 5로 해보고 테스트를 진행해봅시다.

데이터베이스 접근은 다음과 같이 동작합니다.

  1. hibernate_sequence 테이블 조회하여 next_val를 가져온다.
  2. INCREMENT_PARAM 값(5)을 next_val에 더한다.
  3. insert를 해줄 때 해당 1번에서 가져온 next_val부터 차례대로 증가해주면서 넣어준다.

결과적으로, next_val 값은 6이 된 걸 알 수 있습니다.

🤔
INCREMENT_PARAM 값이 TEST_COUNT 값보다 작다면 어떻게 될까요?

 

바로 테스트를 해봅시다.

INCREMENT_PARAM 값이 5, TEST_COUNT를 9로 해보고 테스트를 진행했습니다.

데이터베이스 접근은 다음과 같이 동작합니다.

  1. hibernate_sequence 테이블 조회하여 next_val를 가져온다.
  2. INCREMENT_PARAM 값(5)을 next_val에 더한다.
  3. 더한 값(5)이 TEST_COUNT보다 작으므로 한 번 더 INCREMENT_PARAM 값(5)을 next_val에 더해준다.
  4. insert를 해줄 때 해당 1번에서 가져온 next_val부터 차례대로 증가해주면서 넣어준다.

결과적으로, next_val 값은 16이 된 걸 알 수 있습니다.

TEST_COUNT가 9이므로 insertId는 6부터 14까지 들어갔습니다.

🤔
현재 next_val는 16이니 15는 어떻게 되는걸까요?

 

이게 바로 @GeneratedValue를 변경했을 때의 단점이라 할 수 있습니다.

15의 사례처럼 사용하지 않고 넘어가는 id 값이 생길 수 있는 허점이 있죠.

따라서, INCREMENT_PARAM 값을 적절히 설정하는 것이 중요할 것 같습니다.

마무리

bulk insert와 bulk update에 대해 알아봤습니다.

제 개인적인 생각은 bulk insert나 bulk update가 빈번하게 이루어지는 환경이라면 JPA보다는 raw query와 더 친화적인 mybatis를 사용하는 것이 더 좋을 것 같습니다.

mybatis의 bulk 관련 포스팅은 다음 블로그를 참고해주세요.

 

Bulk Insert, 성능 테스트

데이터베이스 쿼리의 성능을 높이는 방법으로 Bulk Insert를 사용하곤 하는데요. 오늘은 데이터베이스의 Bulk Insert의 성능을 비교해보고자 합니다. 직접 실행해본 쿼리를 통해 어느정도 성능이 좋

blog.kyeongsun.com

 

Bulk Update, Temporary Table 성능 테스트 - Spring

Spring - Mabatis에서 temporary 테이블을 이용한 대량의 데이터를 업데이트하는 것이 본 포스팅의 목표입니다. 안녕하세요. 대량의 데이터를 업데이트할 때 효과적인 방법을 소개하고자 합니다. MySQL

blog.kyeongsun.com

참고 링크

MySQL 환경의 스프링부트에 하이버네이트 배치 설정 해보기

bulk insert 관련

JPA의 트레이드 오프

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/02   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28
글 보관함