Post

[Spring/JPA] JPA Batch Insert에 대해서 깊게 알아보자.

Batch Insert

개발을 진행하던 중에 대량의 데이터를 insert 해야하는 일이 있었는데 당시 환경에서 소요 시간이 꽤 걸려서 성능 향상을 위해 batch insert를 도입했던 내용을 정리해보고자 한다.

Batch Insert란?

1
2
3
4
5
6
7
8
9
10
11
12
# 일반적인 개별 insert
insert into member (username, age) values (?, ?)
;

# 배치 insert
insert into member (username, age)
    values
            ('안유진', 21),
            ('이영지', 23),
            ('미미', 30),
            ('이은지', 32)
;

DB에 여러 레코드를 삽입 시에 단건 insert문을 N번 반복하는 것이 아닌 여러 row를 한 번에 연결해서 한 번에 insert하는 방식을 batch insert라고 한다. 이 때 batch insert는 하나의 트랜잭션에 묶이게 된다.

Hibernate에서 Batch Insert의 제약 사항

Hibernate ORM User Guide - 12.2.1 Batch Inserts를 참고하면 아래와 같은 문구가 있다.

Hibernate disables insert batching at the JDBC level transparently if you use an identity identifier generator.

식별자 방식에 IDENTITY 방식은 batch insert에 사용될 수 없다는 내용이다.

식별자 생성 방식은 GenerationType.AUTO, GenerationType.IDENTITY, GenerationType.TABLE, GenerationType.SEQUENCE가 있다.

기본키 생성 전략에 대해 간단하게 얘기하면, 아래와 같이 네 종류가 있다.

  • AUTO : DB dialect에 따른 Hibernate의 식별자 생성 타입 default 설정하는 방식
  • IDENTITY : MySQL의 auto increment와 같이 DB에 식별자 생성을 위임하는 방식 (DB에 레코드를 insert한 후 기본 키 값을 조회할 수 있다.)
  • SEQUENCE : DB SEQUENCE를 사용해 기본 키를 할당하는 방식으로 메모리를 활용해 Application 단에서 sequence 값을 할당할 수 있다. (DB에 레코드를 insert하기 전에 기본키 값을 알 수 있다.)
  • TABLE : 키 생성 전용 테이블을 별도로 사용하는 전략이다. DB SEQUENCE를 지원하지 않을 때에 흉내내는 전략으로 DB에서 기본키에 넣을 값을 별도 테이블에서 조회해서 insert 후 값 증가를 위해 update 쿼리를 한 번 더 사용해야한다.

왜 Batch Insert에서 IDENTITY 키 생성 방식이 지원되지 않는가?

Why does hibernate disable insert batching when using an identity identifier generator를 참고하면, hibernate에서는 Transactional Write behind 방식(쓰기 지연)을 사용하므로 entity를 persist하기 위해서는 @Id 어노테이션의 멤버 변수에 값이 필요한데 IDENTITY방식으로는 insert를 실행하기 전에 기본키에 할당할 값을 알 수 없기 때문에 Batch insert를 지원할 수 없다.

SEQUENCE 방식 도입

MySQL을 사용하던 프로젝트에서 IDENTITY 방식을 사용할 수 없다면 매우 치명적이다. 그래서 키 생성 방식을 변경하지 않고 도입하려면 Spring Data JDBCJdbcTemplate.batchUpdate()메소드를 통해 구현할 수 있다.

하지만 다른 이슈로 인해 프로젝트 DB 스펙이 MySQL에서 MariaDB로 변경되었고, 이를 기점으로 Sequence 방식으로 변경하며 도입했다.

Batch Insert 테스트 스펙

DB는 h2를 이용해서 테스트를 진행하였다.

Member Entity class

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
29
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Member {
    @Id
    @SequenceGenerator(
          name = "MEMBER_SEQ_GENERATOR",
          sequenceName = "MEMBER_SEQ",  // 데이터베이스에 등록되어있는 시퀀스 이름: DB에는 해당 이름으로 매핑된다.
          initialValue = 1,  // DDL 생성시에만 사용되며 시퀀스 DDL을 생성할 때 처음 시작하는 수를 지정
          allocationSize = 50  // 시퀀스 한 번 호출에 증가하는 수
    )
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MEMBER_SEQ_GENERATOR")

//  @GeneratedValue(strategy = GenerationType.IDENTITY) //IDENTITY와 SEQUENCE 테스트용
    @Column(name = "member_id")
    private Long id;
    private String username;
    private int age;

    public Member(String username) {
        this.username = username;
    }

    public Member(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

SequenceGenerator 어노테이션을 통해 시퀀스 생성을 해주었다.

applicaton.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        generate_statistics: true
        show_sql: true
        format_sql: true
        jdbc:
          batch_size: 1000
        use_sql_comments: true
            

DB의 캐시 초기화를 위해 ddl-auto 값을 create로 설정했고, 쿼리의 소요 시간을 조회하기 위한 generate_statistics값을 true로 주었다.

saveAll test code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void saveAllMember100Test() throws IOException {
    BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
    long start = System.currentTimeMillis();
    List<Member> memberList = new ArrayList<>();

    for(int i=0;i<100000;i++){
        memberList.add(new Member("memberName" + i));
    }
    memberRepository.saveAll(memberList);

    long end = System.currentTimeMillis();

    bw.write("total time : " + (end - start) + "\n");
    bw.flush();
    bw.close();
}

10만 건을 대상으로 테스트 진행을 하였다.

테스트 진행

키 생성 IDENTITY 방식 소요 시간 측정

identitySaveAll100000 identitySaveAll100000H2

saveAll의 소요 시간이 약 110,000ms이고, batch insert는 적용되지 않은 모습을 볼 수 있다.

키 생성 SEQUENCE 방식 소요 시간 측정

SequenceSaveAll100000 SequenceSaveAll100000H2

Batch insert가 적용되어 JDBC batches 부분이 100,000 / 1,000(batch_size) = 100번으로 확인되었고, saveAll 소요시간은 약 2,700ms로 확인되어 현저히 줄어들었다. call next value for member_seq로 인해 SEQUENCE방식으로 기본키를 채번하는 것을 알 수 있다.

하지만 statistics의 flush nanosecond 소요 시간을 보면, SEQUENCE 방식 보다 IDENTITY 방식의 소요 시간이 적다는 것을 알 수 있다. 여기에서 주의해야 할 부분이 있다.

주의해야 할 부분

batch_size가 높으면 높을 수록 좋을까?

아니다. batch_size가 커지면 쓰기 지연 저장소에 batch_size만큼의 데이터가 영속화되어 일시적으로 저장되기 때문에 메모리에 부하가 생기고, 트랜잭션의 크기가 커져서 하나의 레코드에 이상이 있다면 batch_size 만큼 모든 레코드의 롤백이 일어날 수 있다. 메모리와 DB 성능에 따라 batch_size를 조절해야한다.

그렇다면 SEQUENCE 방식이 IDENTITY 방식보다 성능이 좋을까?

SEQUENCE 방식으로 테스트를 진행함으로서 batch insert가 적용된 것을 알 수 있었다.

하지만 flush 과정에서 14배 차이가 날 정도로 SEQUENCE 방식이 오래걸리는 것을 알 수 있었다. 이 부분은 SEQUENCE 방식은 application 단계에서 기본키를 채번하는데 이 과정에서 flush 해야할 내용이 많아진 듯 보인다. 하지만 모든 소요 시간을 합했을 때 SEQUENCE 방식이 적었다.

설정

  • order_insert
  • order_update

위와 같은 설정들을 application.yml에 설정할 수 있다. 이 설정들은 SQL 쿼리의 실행 순서를 정렬하는 옵션이다.

예를 들어 아래와 같이 한 트랜잭션에 부모의 save, 자식의 save 번갈아서 하는 로직이 있을 때 정렬하지 않는다면 쿼리도 번갈아서 나갈 것이다.

1
2
3
4
insert into 부모 values a1;
insert into 자식 values b1;
insert into 부모 values a2;
insert into 자식 values b2;

하지만 order 설정을 해준다면, 아래와 같이 쿼리를 묶어서 수행할 수 있다.

1
2
insert into 부모 values (a1, a2);
insert into 자식 values (b1, b2);

결론

  • 대량의 데이터를 insert 해야할 때에는 Spring data JDBC의 batchUpdate()메소드를 이용한 batch insert를 고려해야겠다.

  • Spring Data JPA를 이용해서 batch insert를 해야한다면 기본키를 batch로 채번하고 sequence 방식을 이용한다.

  • insert 소요 시간이 전부가 아니고 총 소요시간이 중요하다는 것을 알 수 있었다.

출처

This post is licensed under CC BY 4.0 by the author.