훈훈훈
Spring boot :: JdbcTemplate을 사용하여 batch insert 기능 구현 본문
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가 압도적인 성능 우위를 보여줍니다.
참고
kapentaz.github.io/jpa/JPA-Batch-Insert-with-MySQL/#
mkyong.com/spring/spring-jdbctemplate-batchupdate-example/
docs.spring.io/spring-framework/docs/current/reference/html/data-access.html#jdbc-advanced-jdbc
vladmihalcea.com/jpa-persist-and-merge/
'Spring Framework > 개념' 카테고리의 다른 글
Spring boot :: Task Execution and Scheduling (0) | 2021.06.12 |
---|---|
Spring boot :: JPA @EntityListeners 정리 (0) | 2021.03.22 |
Spring boot :: QueryDSL을 사용해서 No Offset Paging 구현하기 (0) | 2021.01.10 |
Spring boot :: JPA에서 OneToOne 관계 N+1 문제 정리 (0) | 2020.12.28 |
Spring boot :: Kotlin + Hibernate 사용 시, lazy loading 이슈 정리 (1) | 2020.12.19 |