본문 바로가기
개발 이야기

데이터 대량 등록/수정 성능 개선 with JDBC Batch Update

by 신재권 2024. 8. 17.

우리 애플리케이션에는 마트 관계자를 위한 관리 페이지가 존재한다.

해당 페이지에서 상품 관리, 주문 관리 등 관리 작업이 가능하다.

상품을 단건으로도 저장/수정이 가능하고, 엑셀 파일을 통해 대량으로 저장/수정도 가능하다.

 

엑셀 파일을 통해 대량으로 저장/수정하는 기능을 우리 앱에서는 '상품 통합 관리'라고 부른다.

상품 통합 관리 기능은 아래 프로세스로 이루어져 있다.

1. 엑셀 업로드

2. 엑셀 파싱 후 클라이언트 반환

3. 클라이언트는 파싱된 정보를 보고 상품 분류 요청

4. 상품 분류에서는 해당 상품이 신규 상품인지, 기존 상품인지 분류하여 클라이언트에게 반환

5. 기존 상품은 수정

6. 신규 상품은 저장

정리하면 통합 상품 관리 기능은 엑셀 파싱 -> 상분 분류 -> 상품 수정 -> 상품 저장 순으로 진행한다.

 

레거시 시스템은 이들을 모두 JPA를 활용하여 처리했다. 즉, 대량 데이터 처리 시 속도가 엄청나게 느려진다.

모든 플로우를 해결하는데 10분 가까이 소요될 때도 있었다.

사용자는 10분 간 같은 페이지에서 움직이지 못하고, 프로세스가 종료될 때까지 기다려야 했다.

이러한 문제점을 파악 후 성능 개선을 하기로 결정하였다.

 

우선 데이터 대량 삽입/수정을 효율적으로 하기 위해 여러 방법을 검토하였다.

1. ID 전략 변경 검토

우리는 PostgreSQL + Identity 전략을 활용 중이다. PostgreSQL은 디폴트가 시퀀스지만, Identity 전략 활용 시 내부적으로 1씩 증가하는 시퀀스를 사용하여 Auto-Increament 기술을 흉내낸다.

auto-increament 전략 활용 시 JPA saveAll()을 실행하면 for문 안에 save()가 반복되는 형태로 실행된다. 즉, 배치 삽입으로 동작하지 않는다.

시퀀스 활용 시 JPA saveAll() 메서드를 통해 배치 삽입이 가능하다.

 

이를 통해 대량 데이터 등록이 필요한 엔티티들 6개의 ID 전략을 변경해봤다. 예상했던 대로 기존 saveAll()보다 매우 빠른 속도로 저장되었다.

하지만 id 전략 변경에 따라 auto increament 전략의 장점인 id 순서가 생성 순서를 만족하지 못하기 때문에, 이를 활용하는 곳을 모두 찾아 수정해야 했다. -> 너무 큰 작업이라 판단되어 해당 방법은 보류하였다.

 

2. Kotlin Exposed

코틀린 ORM인데, 레퍼런스가 적고 버전이 낮아 선택지에서 제외하였다.

 

3. MyBatis

MyBatis는 SQL 길이 제한이 있어, 대량 데이터 처리 시 여러 번 나누어 요청해야 했다.

 

4. JDBC Batch Update

딱히 제한이 없었고, 적용하기도 쉬워 해당 방식을 선택하였다.

 

저장/수정 코드는 아래와 같다.

 

신규 저장 시 총 6개의 엔티티를 저장해야한다.

- 각 엔티티 별로 연관관계를 가지고 있어, 저장한 id를 return 받아 사용하였다.

object BatchInsertRepository {
    fun <T> batchInsert(
        sql: String,
        items: List<T>,
        jdbcTemplate: JdbcTemplate,
        mapper: (PreparedStatement, T) -> Unit,
    ): List<Long> {
        val keyHolder = GeneratedKeyHolder()

        jdbcTemplate.execute { connection: Connection ->
            connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS).use { ps ->
                items.forEach { item ->
                    mapper(ps, item)
                    ps.addBatch()
                }
                ps.executeBatch()
                ps.generatedKeys.use { rs ->
                    while (rs.next()) {
                        keyHolder.keyList.add(mapOf("GENERATED_KEY" to rs.getLong(1)))
                    }
                }
            }
        }

        return keyHolder.keyList.map { it["GENERATED_KEY"] as Long }
    }
}

 

상품 수정 시에는 JPA를 같이 활용하였다.

수정이 필요한 부분은 jdbc를 활용하고, 삭제가 필요한 부분은 JPA.deleteAllByIdInBatch()를 활용하였다.

object BatchUpdateRepository {
    fun <T> batchUpdate(
        sql: String,
        items: List<T>,
        jdbcTemplate: JdbcTemplate,
        mapper: (PreparedStatement, T) -> Unit,
    ) {
        jdbcTemplate.execute { connection: Connection ->
            connection.prepareStatement(sql).use { ps ->
                items.forEach { item ->
                    mapper(ps, item)
                    ps.addBatch()
                }
                ps.executeBatch()
            }
        }
    }
}

 

또한, 레거시 시스템은 하나의 메서드에서 모든 기능을 수행하였는데, 이를 개선하여 각 책임에 따라 분할하였다.(정보 로드, 검증, DTO 생성, 응답 제작, 벌크 연산)

외부 의존성이 필요없는 곳은 POJO 기반 로직을 구성하였다. ->  빠른 속도의 단위 테스트 사용 가능

외부 의존성이 필요한 부분은 Slice 테스트를 활용하여 테스트 속도를 향상시켰다.

통합 테스트 시에는 Fake 객체를 활용하여 DB 연결 없이도 통합 테스트가 가능하도록 구현하였다.

 

기존 기능(수정 + 삭제) 테스트들은 Spring Boot 통합 테스트들로 이루어진 20개 테스트로 구성되어 있었다.

이를 책임에 따라 분할하여 많은 단위 테스트가 생길 수 있었고(80개), Spring Boot를 띄우지 않기 때문에 기존 테스트보다 훨씬 빠른 속도를 가지고 있었다.

많은 테스트 추가를 통해 코드의 신뢰성을 크게 향상시켰다고 생각한다.

 

로컬 기준 개선 수치는 다음과 같다. 로컬 기준이여서 실제 걸린 시간은 무의미하지만, 성능 개선 관점에서는 상승한 퍼센테이지는 유의미하다 생각한다.

대량 저장(20,000건 기준): 28초 - 4.5초(약 85%)

대량 수정(20,000건 기준): 66초 -> 6초(90%)

 

이러한 성능 개선을 통해, 더욱 빠르게 상품 등록/수정이 가능해졌다.