티스토리 뷰

우리 팀 프로젝트에서는 JPA 와 QueryDSL 을 조합해서 사용하고 있다.

해당 프로젝트에서 JPA 를 통해 Batch Insert 하는 과정에서 겪었던 문제를 소개하고, 어떻게 해결했으며 그 결과로 어느정도의 성능향상이 이뤄졌는지 소개하려고 한다.

 

Batch Insert 란?


간단히 설명하면 여러 개의 Row 를 한 번의 쿼리로 Insert 하는 방식이다. MySQL 은 Bulk Insert 방식을 통해 Batch Insert 가 가능하다.

INSERT INTO MEMBER(NICKNAME, AGE) VALUE ('wiz', 29);
INSERT INTO MEMBER(NICKNAME, AGE) VALUE ('chan', 27);
INSERT INTO MEMBER(NICKNAME, AGE) VALUE ('navi', 22);
INSERT INTO MEMBER(NICKNAME, AGE) VALUES ('wiz', 29), ('chan', 27), ('navi', 22);

여러 줄의 Insert 쿼리를 실행하는 것보다 Bulk Insert 방식을 이용하는게 성능적으로 빠른 것은 당연하다. 그 이유는 아래 StackOverFlow 를 살펴보면 명확히 이해할 수 있을 것이다.

 

https://stackoverflow.com/questions/1793169/which-is-faster-multiple-single-inserts-or-one-multiple-row-insert

 

Which is faster: multiple single INSERTs or one multiple-row INSERT?

I am trying to optimize one part of my code that inserts data into MySQL. Should I chain INSERTs to make one huge multiple-row INSERT or are multiple separate INSERTs faster?

stackoverflow.com

 

JPA 에서 Batch Insert


사실 나는 JPA 에서 기본적으로 제공해주는 saveAll() 을 사용하고 batch_size 를 조정하면 Batch Insert 방식으로 동작하리라 생각했다. 하지만 실제로 DB 에 날아가는 쿼리를 보니 Row 마다 개별 Insert 쿼리가 발생하고 있었다.

 

나는 MySQL 에서 직접 쌓아주는 로그를 통해 쿼리를 확인했다.

MySQL 로그 확인 방법

더보기

1. 로그 활성화

2. 로그 파일 확인

 

saveAll() 을 했을 때 실제로 날라간 쿼리를 보자.

JPA 에서 Batch Insert 를 하기 위한 세팅(rewriteBatchedStatements, batch_size 등)을 모두 해줬음에도 Row 마다 별도의 Insert 쿼리가 날라가는 것을 확인할 수 있다.

 

왜 이런 결과가 나왔을까?

 

결론부터 말하면 Entity 객체의 @Id 에 @GeneratedType 의 전략을 IDENTITY 로 설정했기 때문이다.

https://stackoverflow.com/questions/27697810/why-does-hibernate-disable-insert-batching-when-using-an-identity-identifier-gen

 

Why does Hibernate disable INSERT batching when using an IDENTITY identifier generator

The Hibernate documentation says: Hibernate disables insert batching at the JDBC level transparently if you use an identity identifier generator. But all my entities have this configuration:...

stackoverflow.com

 

위 사이트에서 이유를 읽어보는게 가장 정확하겠지만 간략하게 요약해보면 다음과 같다.

 

  1. IDENTITY GeneretedType 전략은 MySQL 의 auto_increment 를 사용한다.
  2. 실제로 Insert 쿼리가 MySQL 로 날라가야만 auto_increment 된 값을 얻어올 수 있다.
  3. Hibernate(JPA) 는 Insert 에 대한 Flush 를 마지막 순간까지 지연하고자한다. (Transactional Write-Behind)
  4. 이러한 부분이 충돌되기 때문에 Hibernate(JPA) 는 IDENTITY 전략에서 기본적으로 Batch Insert 를 비활성화한다.

 

그럼 @GeneratedType 의 전략을 IDENTITY 가 아니라 SEQUENCE, TABLE 을 사용하면 괜찮을까?

아쉽게도 MySQL 에는 시퀀스가 존재하지 않고 TABLE 을 위해서는 별도의 테이블을 만들어야 될 뿐만 아니라 성능적으로도 IDENTITY 전략에 비해 떨어진다.

 

Batch Insert 를 해보자!


우선 Spring Data JPA 를 이용하면 Batch Insert 가 안된다는 사실은 이해했다. 그럼 어떻게 해야할까?

내가 이 문제를 해결하는 과정에서 반드시 만족해야한다고 생각한건 아래 두 가지이다.

 

  1. Batch Insert 를 하는 부분을 제외하고는 여전히 Spring Data JPA + QueryDSL 을 사용하고 싶고, 당연하게도 이 일련의 과정은 Spring Boot 에서 제공하는 Transaction 으로 일관성있게 처리되어야한다.
  2. Spring Data JPA 의 save() 와 동일하게 반환 값이 채번된 Entity 였으면 좋겠다.

 

나는 이 문제를 해결하기 위해 Spring Data JPA 와 JDBC 를 결합해 사용하는 방식을 사용했다.

가장 먼저 Spring Data JPA + QueryDSL + JDBC 를 사용했을 때 Transaction 처리가 잘 되는지 살펴보자.

 

기본적으로 Spring Data JPA 를 사용할 때 Transaction 처리를 담당하던 PlatformTransactionManager 는 JpaTransactionManager 이다. JpaTransactionManager 공식 문서를 보자마자 내가 고민하던 부분이 말끔히 해결됐다.

This transaction manager also supports direct DataSource access within a transaction (i.e. plain JDBC code working with the same DataSource). This allows for mixing services which access JPA and services which use plain JDBC (without being aware of JPA)! Application code needs to stick to the same simple Connection lookup pattern as with DataSourceTransactionManager (i.e. DataSourceUtils.getConnection(javax.sql.DataSource) or going through a TransactionAwareDataSourceProxy). Note that this requires a vendor-specific JpaDialect to be configured.

 

요약하면 동일한 DataSource 를 바라보는 경우 JpaTransactionManager 는 JDBC Connection 에 대한 Transaction 도 함께 처리해준다는 것이다. (반대로 JDBC 에서 사용하는 DatasourceTransactionManager 를 사용하게되면 IllegalStateException 이 발생한다!!)

 

 

두 번째 문제는 JDBC 의 jdbcTemplate.batchUpdate() 를 이용해 Insert 를 하게 되면 응답 값이 JPA 와 다르게 채번된 Entity 를 반환하지 않는다는 것이다. 따라서 두 번째 문제를 해결하기 위해서는 채번된 Entity 를 직접 만들어줘야한다.

 

나는 jdbcTemplate.execute("SELECT last_insert_id()") 를 통해 채번된 Entity 를 만들어 사용했다.

 

사실... last_insert_id() 를 사용하는 내내 너무 불안에 떨었다.

나를 불안하게 한 몇 가지 요소들이 있었는데, 첫 번째는 여러 인스턴스로 Scale-out 된 상황에서 동시성 이슈가 발생하지 않을까였고 두 번째는 너무 MySQL 에 의존적인 코드를 작성하는건 아닐까였다.

우선 첫 번째 동시성 이슈에 대한 고민은 MySQL 공식문서를 통해 해소했다.

The ID that was generated is maintained in the server on a per-connection basis. This means that the value returned by the function to a given client is the first AUTO_INCREMENT value generated for most recent statement affecting an AUTO_INCREMENT column by that client. This value cannot be affected by other clients, even if they generate AUTO_INCREMENT values of their own. This behavior ensures that each client can retrieve its own ID without concern for the activity of other clients, and without the need for locks or transactions.
(https://dev.mysql.com/doc/refman/8.0/en/information-functions.html#function_last-insert-id)

요약하면 MySQL Connection 별로 ID 가 관리된다는 것이다. 동시에 여러 Client 가 실행되더라도 각각 자신이 연결한 Connection 에서 발생하는 Insert → auto_increment 에 의해서 생성된 ID 값을 리턴받게 된다는 것이다.

이 부분은 실제로 CyclicBarrier, ExecutorService 를 이용해 동시성 테스트를 했을 때도 문제가 없는 것으로 확인했다.
(테스트에 사용한 코드는 포스팅 가장 밑에 첨부되어 있다)

두 번째 고민은 다른 Database 로 확장할 수 없는 코드를 작성한다는 것에 대한 불안감이었다.
실제로 테스트 코드에서 사용하고 있는 H2 Database 에서는 last_insert_id() 가 MySQL 과 다른 방식으로 동작하고 있었고 테스트하는데 어려움을 겪었다.

그래서 실제 프로젝트 코드에서는 Batch Insert 의 리턴 값을 넘겨주지 않는 형태로 수정했다.
만약 반환 값으로 채번된 Entity 를 반환해야 한다면 이러한 부분과 변경에 대해 충분히 인지한 상태로 사용하는 것이 좋다고 생각했다.

 

최종적으로 내가 작성한 코드를 소개한다. (실제 프로젝트에 사용된 코드가 아니라 샘플코드이다)

interface PushRepository: Repository<Push, Long>, PushJdbcRepository {
    fun findByAlarmId(alarmId: Long): List<Push>
    
    @Modifying
    fun deleteAllByAlarmId(alarmId: Long)
}

interface PushJdbcRepository {
    fun saveAll(pushList: List<Push>): List<Push>
}

class PushJdbcRepositoryImpl(
    private val datasource: DataSource
): PushJdbcRepository {
    override fun saveAll(pushList: List<Push>): List<Push> {
        val jdbcTemplate = NamedParameterJdbcTemplate(datasource)
        val records = pushList.map { it.toRecord() }

        jdbcTemplate.batchUpdate(
            this.createBulkInsertQuery(records[0].keys.toSet()),
            SqlParameterSourceUtils.createBatch(records.toTypedArray())
        )
        
        return jdbcTemplate.queryForObject("SELECT last_insert_id()", emptyMap<String, Any>(), Long::class.java)?.let { id ->
            pushList.onEachIndexed { idx, push -> push.id = id + idx }
        } ?: emptyList()
    }

    private fun createBulkInsertQuery(columnNames: Set<String>) =
        "INSERT INTO push (${columnNames.joinToString(", ")}) VALUES " +
                "(${columnNames.joinToString(", ") { ":$it" }})"
}

 

해당 코드로 saveAll() 을 호출한 결과를 MySQL 로그를 통해서 확인해보자!

정상적으로 Batch Insert 방식으로 동작한 것을 확인할 수 있다 :)

 

위 코드에 대해 CyclicBarrier + ExecutorService 를 이용해 테스트한 코드를 함께 첨부한다. 물론 이 테스트는 단지 Thread 수를 늘려 테스트 한 것이기 때문에 여러 인스턴스로 Scale-out 한 환경을 완벽히 재현하지는 못한다.

@Test  
fun `save - 동시성 테스트`() {  
    repeat(THREAD_CNT) { index ->  
        executor.execute {  
            println("[ $index ] Wait!")  
            cyclicBarrier.await()  
            println("[ $index ] Start!")  
            exchangeBalanceService.save(9999L, "COINONE", balances).also {  
                println("[ $index ] Result")  
                println(it)  
            }  
        }    
	}    
    executor.shutdown()  
    executor.awaitTermination(5, TimeUnit.SECONDS)  
}

 

그래서 적용한 결과는?


실제 프로젝트에 적용한 결과로 약 10~20% 의 성능향상이 있었다.

 

참고로 이 방식을 이용하면 JPA Auditing 을 사용하지 못하는 문제가 있다. Spring Data JPA 가 아니라 JDBC 를 사용한 것이기 때문에 당연하지만.. 이 부분을 해결하기 위해 방법은 추가적으로 찾아봐야 할 것 같다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/09   »
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 30
글 보관함