훈훈훈

Spring boot :: JdbcTemplate을 사용하여 batch insert 기능 구현 본문

Spring Framework/개념

Spring boot :: JdbcTemplate을 사용하여 batch insert 기능 구현

훈훈훈 2021. 1. 17. 19:49

Springboot와 JPA를 사용하는 서비스에서 Hibernate SQL 쿼리 로그를 확인해보니 insert 쿼리가 단 건씩 발생하는 것을 보았다. 분명 saveAll() 메서드를 사용하고 있는 함수였지만 의도한 대로 동작하지 않았던 것이다.

 

발생한 쿼리는 아래와 같았다.

Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)

 

이 문제는 Jdbc Template에 있는 batch insert 기능을 사용하여 해결하였고 관련 내용을 정리하려고 합니다.

 

사용한 환경은 아래와 같습니다.

-  Spring boot

-  Kotlin

-  MySQL (GenerationType.IDENTITY)

Batch Insert 적용이 안되는 이유

Vladmihalcea 블로그 글에서 내용을 확인 해보면 GenerationType.IDENTITY을 사용할 경우 Hiberbate에서 Batch Insert를 비활성화 처리를 한다는 내용을 확인할 수 있습니다.

이유는 간단한데, 해당 타입 식별자를 사용할 때는 Entity를 persist하기 위해서는 @Id로 지정한 필드의 값이 필요 합니다.

하지만 IDENTITY 타입을 사용할 경우 실제 DB를 Insert해야 그 값을 얻을 수 있기 때문에 Hibernate에서는 어쩔 수 없이 해당 기능을 비활성화 처리를 합니다.

 

그렇다면 이 문제를 해결하기 위해서는 크게 두 가지 방법이 있습니다.

첫 번째는 GenerationType을 변경하는 방법이고, 두 번째는 Spring JDBC, JOOQ, MyBatis 등을 사용하는 것입니다.

 

이 문제를 해결하기 위해서 저는 Spring JDBC를 선택하기로 했습니다.

그 이유는 먼저 첫 번째 방법 같은 경우 기존에 운영하던 테이블의 GenerationType을 변경하는 것은 쉽지 않습니다. 설령 batch insert에 대한 문제를 해결할지는 몰라도 그 외의 서비스에서 어떤 사이드 이팩트가 터질지 모르기 때문에 이 방법은 권장하지 않습니다.

 

두 번째로 Spring JDBC, JOOQ, MyBatis 등 여러 선택지가 있겠지만 Spring JDBC를 선택한 이유는 서비스 하고 있는 서버는 이미 spring-boot-starter-data-jpa 의존성을 가지고 있기 때문에 Spring JDBC에 관한 내용들은 대부분 포함하고 있어 새로운 라이브러리를 추가하지 않아도 된다는 장점이 있다.

JdbcTemplate Batch Insert 구현

 

시작하기 전에 application.yml 혹은 application.properties에 있는 DB-URL에 아래와 같이 파라미터 추가하고 진행하려고 한다. 해당 내용은 우아한형제 기술 블로그 내용을 참고 했습니다.

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/batch_test?&rewriteBatchedStatements=true&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999
    username: root
    password: 1234
    driver-class-name: com.mysql.cj.jdbc.Driver

각각의 파라미터들에 대한 아래와 같습니다.

 

-  rewriteBatchedStatements

batch 형태의 SQL로 재작성 해주는 옵셥이며, MySQL에서는 기본 값이 false 이기 때문에 true로 변경이 필요합니다.

-  profileSQL

Driver에서 전소하는 쿼리를 출력, 해당 값도 true로 변경을 해줍니다.

-  logger

MySQL 드라이버 같은 경우 기본 값은 System.err로 출력하도록 설정되어 있기 때문에 Slf4jLogger로 변경 해줍니다.

-  maxQuerySizeToLog

해당 옵션은 출력할 쿼리의 길이를 지정할 수 있습니다. MySQL 드라이버 같은 경우 기본 값은 0 입니다.

 

사전에 필요한 설정 옵션을 모두 살펴보았으니, 이제 코드로 구현하는 과정을 살펴봅시다.

 

예제 Entity

@Entity
data class User(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
    val name: String
)

예제 Entity Class는 간단하게 구성하였습니다. GenerationType은 IDENTITY로 설정하였습니다.

 

 

아래 스프링 공식 문서를 읽어보면 JdbcTemplate을 사용하여 batch insert를 구현하는 예제를 소개하고 있습니다.

예제와 같이 jdbcTemplate에서 제공하는 batchUpdate 메소드를 사용해서 구현 하였습니다.

 

 

구현한 코드는 아래와 같습니다.

@Repository
class UserJdbcBatchRepository {
    @Autowired
    lateinit var jdbcTemplate: JdbcTemplate

    fun batchInsert1(users: List<User>): IntArray {
        return jdbcTemplate.batchUpdate(
            "insert into user (name) values (?)",
            object: BatchPreparedStatementSetter {
                override fun setValues(ps: PreparedStatement, i: Int) {
                    ps.setString(1, users[i].name)
                }

                override fun getBatchSize() = users.size
            })
    }
 }

한 번 실행 시켜보면 아래와 같이 최 하단에 정상적으로 로그가 찍히는 것을 볼 수 있습니다.

main] MySQL: QUERY created: Sun Jan 17 19:13:28 KST 2021 duration: 1 connection: 1176 statement: 0 resultset: 0 message: insert into user (name) values ('1'),('2'),('3'),('4'),('5'),('6'),('7'),('8'),('9'),('10')

 

하지만 출력된 로그 중간 지점쯤을 살펴보면 아래와 같이 insert 쿼리가 단 건으로 여러개 출력된 것을 보실 수 있습니다.

Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)
Hibernate: insert into user (name) values (?)

그 이유는 Hibernate는 단지 PreparedStatemenet.addBatch()를 호출하기만 할 뿐 실제로는 쿼리가 합쳐지는지 아닌지는 모르는 상태이기 때문입니다. 실제로 발생한 쿼리를 모와서 Batch Insert를 시키는 주체는 Hibernate가 아니라 MySQL 드라이버에서 진행하는 것을 알 수 있습니다.

 

이제 모든 구현이 끝났지만, 추가적으로 batchUpdate() 메소드 없이 구현하는 방법도 살펴보려고 합니다.

이 부분은 넘어가셔도 상관 없습니다. 코드는 아래와 같습니다.

@Repository
class UserJdbcBatchRepository {
    @Autowired
    lateinit var jdbcTemplate: JdbcTemplate
    
    fun batchInsert2(users: List<User>) {
        val sql = " insert into user (name, tag) values (?)".trimIndent()
        
        val ds = jdbcTemplate.dataSource
        val connection: Connection = ds!!.connection
        connection.autoCommit = false
        
        val ps = connection.prepareStatement(sql)

        val batchSize: Int = 1000
        var count: Int = 0

        users.forEach { user ->
            ps.setString(1, user.name)
         // ps.addBatch() <- 코드 하이라이터 적용 문제때문에 주석처리, 코드실행을 위해서 주석 풀어야함
            count += 1
            
            if (count % batchSize == 0 || count == users.size) {
                ps.executeBatch()
                ps.clearBatch()
            }
        }
        connection.commit()
        connection.close()
        ps.close()
    }
}

로직은 간단하게 batchSize 만큼 count를 체크해서 배치를 실행시키는 로직 입니다.

실행 결과는 batchUpdate( )를 사용한 코드와 동일 합니다.

성능 테스트

@ExtendWith(value = [SpringExtension::class])
@SpringBootTest
class BatchTest {

    @Autowired
    private lateinit var userRepository: UserRepository

    @Autowired
    private lateinit var userJdbcBatchRepository: UserJdbcBatchRepository
    
    @Test
    @DisplayName("save 테스트")
    fun noBatchInsertTest1() {
        val users: MutableList<User> = mutableListOf();
        for (i in 1..10000) {
            userRepository.save(User(name = i.toString()))
        }
    }

    @Test
    @DisplayName("savaAll 테스트")
    fun noBatchInsertTest2() {
        val users: MutableList<User> = mutableListOf();
        for (i in 1..10000) {
            users.add(User(name = i.toString()))
        }
        userRepository.saveAll(users)
    }


    @Test
    @DisplayName("batchUpdate()를 사용한 batchInsert 테스트")
    fun batchInsertTest1() {
        val users: MutableList<User> = mutableListOf();
        for (i in 1..10000) {
            users.add(User(name = i.toString()))
        }
        userJdbcBatchRepository.batchInsert1(users)
    }

    @Test
    @DisplayName("batchUpdate()를 사용하지 않은 batchInsert 테스트")
    fun batchInsertTest2() {
        val users: MutableList<User> = mutableListOf();
        for (i in 1..10000) {
            users.add(User(name = i.toString()))
        }
        userJdbcBatchRepository.batchInsert2(users)
    }
}

마지막으로 성능 테스트를 진행하려고 합니다. 

데이터를 10,000개 넣었을 때 속도를 체크하였으며 진행한 케이스는 총 4가지 입니다.

 

결과는 아래 그림과 같으며 확실히 단 건으로 처리하는 로직 보다 batch insert가 압도적인 성능 우위를 보여줍니다.

 

참고

woowabros.github.io/experience/2020/09/23/hibernate-batch.html?fbclid=IwAR0mKmOVyJuLYF8N3uRkelNSdFxkK8Mw0DGg4HFC64T2p0XtvqZ-2y1Tusw

 

MySQL 환경의 스프링부트에 하이버네이트 배치 설정 해보기 - 우아한형제들 기술 블로그

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

woowabros.github.io

kapentaz.github.io/jpa/JPA-Batch-Insert-with-MySQL/#

 

JPA Batch Insert with MySQL

JPA에서 Batch Insert가 되지 않아서 그 이유를 확인한 과정을 공유합니다. Spring, Kotlin, MySQL 환경기준으로 작성했습니다.

kapentaz.github.io

mkyong.com/spring/spring-jdbctemplate-batchupdate-example/

 

Spring JdbcTemplate batchUpdate() Example - Mkyong.com

- Spring JdbcTemplate batchUpdate() Example

mkyong.com

docs.spring.io/spring-framework/docs/current/reference/html/data-access.html#jdbc-advanced-jdbc

 

Data Access

The Data Access Object (DAO) support in Spring is aimed at making it easy to work with data access technologies (such as JDBC, Hibernate, or JPA) in a consistent way. This lets you switch between the aforementioned persistence technologies fairly easily, a

docs.spring.io

vladmihalcea.com/jpa-persist-and-merge/

 

How do persist and merge work in JPA - Vlad Mihalcea

Learn how the persist and merge entity operations work when using JPA and Hibernate, as well as the Persistence Context flush.

vladmihalcea.com

jaehun2841.github.io/2020/11/22/2020-11-22-spring-data-jpa-batch-insert/#pooled-lo-optimizer%EB%A5%BC-%EC%A0%81%EC%9A%A9%ED%95%9C-%EC%BD%94%EB%93%9C

 

Spring JPA Batch Insert 과연 생각대로 동작할까? | Carrey`s 기술블로그

들어가며 Spring JPA를 사용하며 대량으로 insert 시, 1건씩 insert 되기에 성능이 너무 안나온다고 생각을 하고 있었습니다. 그래서 초반에는 bulk insert와 같은 키워드로 검색을 해보니 Hibernate Batch Inser

jaehun2841.github.io

 

Comments