아무튼, 쓰기

(오픈소스 기여까지)Spring JDBC 배치 처리 최적화: 27초에서 1초로 본문

스프링

(오픈소스 기여까지)Spring JDBC 배치 처리 최적화: 27초에서 1초로

순원이 2025. 10. 9. 22:25

이미지입니당 클릭 안됩니다 🥲

1. 개요

이 글에서는 대량의 추천 상품 데이터를 저장하는 과정에서 발생한 성능 문제를 해결하고, 그 과정에서 Spring Framework의 개선점을 발견하여 오픈소스에 기여한 경험을 다룹니다.

초기 JPA saveAll()로 부모 테이블 3,000개, 자식 테이블 15,000개 데이터를 저장하는데 27초가 걸렸던 작업을, SimpleJDBCInsert 배치 처리를 통해 4로 단축하고, JDBC Template 활용하여 최종적으로 3까지 개선한 과정을 상세히 설명합니다.

더 나아가 이 과정에서 Spring JDBC의 TableMetadataProvider 관련 이슈를 발견하고, 이를 Spring 팀에 보고하여 프레임워크 개선에 기여한 경험도 공유합니다.

2. 문제 상황

2.1 초기 구현과 성능 문제

회원별 추천 상품 데이터를 저장하는 기능을 JPA로 구현했습니다:

데이터 구조:

  • 부모 테이블: member_recommended_product (추천 상품 정보)
  • 자식 테이블: member_recommended_product_bucket (추천 상품이 속한 버킷 정보)
  • 관계: 1:N (하나의 추천 상품은 평균 5개의 버킷에 속함)

초기 JPA 구현:

@Transactional
fun saveRecommendations(products: List<MemberRecommendedProduct>) {    
    // JPA saveAll 사용
    productRepository.saveAll(products)  // Cascade로 자식도 자동 저장
}

2.2 성능 측정 결과

데이터 규모:
- 부모 레코드: 3,000개
- 자식 레코드: 15,000개 (부모당 평균 5개)
- 총 INSERT 쿼리: 18,000개

JPA 성능:
- 소요 시간: 27초
- 쿼리 수: 18,000개 (개별 INSERT)

27초는 배치 작업으로는 수용 가능하지만, 실시간에 가까운 추천 시스템 업데이트 요구사항을 고려하면 개선이 필요했습니다.

3. 1차 개선: JDBC 배치 처리 적용

1. JPA saveAll()의 내부 동작과 영속성 컨텍스트

saveAll은 사실 한 번에 save 하는 게 아닌 반복문을 돌면서 save를 호출합니다. 그래도 괜찮습니다!

JPA에서 save() (또는 persist())를 호출하면, SQL이 즉시 데이터베이스로 전송되지 않기때문입니다. 대신, 다음과 같은 일이 벌어집니다.

  1. 엔티티 객체가 영속성 컨텍스트(1차 캐시)에 저장됩니다.
  2. 실제 INSERT SQL은 내부 쿼리 저장소(Action Queue)에 "쓰기 지연(Transactional Write-Behind)" 상태로 쌓입니다.

  1. application.yml에 배치(batch) 설정이 켜져 있다면, 이 작업들은 하나의 네트워크 요청으로 묶여서 한 번에 전송됩니다. 이 쿼리들은 트랜잭션이 커밋(commit)되거나 플러시(flush)가 명시적으로 호출될 때, 모아서 한 번에 데이터베이스로 전송됩니다. 이것이 JPA가 배치 처리를 수행하는 기본 원리입니다.

그럼 된 거 아니야? 배치 설정 키고 saveAll 써도 되는 거 아니야? 라는 물음이 생길텐데 아래와 같은 문제가 있습니다

2. IDENTITY 전략이 이 흐름을 깨는 방식

문제는 GenerationType.IDENTITY를 사용할 때 발생합니다. 이 전략의 문제는 "데이터가 데이터베이스 테이블에 INSERT 된 후에야 기본 키(ID) 값을 알 수 있다"는 점입니다.

이 때문에 JPA(Hibernate)는 다음과 같은 딜레마에 빠집니다.

"엔티티를 영속성 컨텍스트에 넣으려면 ID 값이 반드시 필요한데, 그 ID를 알려면 DB에 INSERT를 해야만 하네?"

이 딜레마를 해결하기 위해, Hibernate는 IDENTITY 전략을 사용하는 엔티티에 대해서는 쓰기 지연을 포기합니다. 

결론적으로, 3,000개의 부모 데이터를 저장하기 위해 3,000번의 INSERT 네트워크 왕복이 발생합니다. 이는 배치 처리가 전혀 동작하지 않고, 사실상 'N+1 INSERT' 문제와 동일한 상황을 유발합니다. 이것이 바로 27초라는 긴 시간이 소요된 근본적인 원인입니다.

3.1 해결 방법 검토

위 문제를 해결하기 위해 여러 접근 방법을 검토했습니다:

방법 장점 단점 선택 여부

JPA 배치 설정 튜닝 기존 코드 유지 ID 생성 전략(IDENTITY)으로 인해 배치 불가능
ID 생성 전략 변경 (SEQUENCE) ORM 설정만으로 변경 가능 JPA와 호환이 안됨
ID 생성 전략 변경 (TABLE) ORM 설정만으로 변경 가능 별도 테이블 필요, 속도가 느림 
UUID 사용 배치 처리 가능 인덱스 성능 저하, 기존 시스템과 불일치
JDBC 사용 세밀한 제어 가능, 높은 성능 Boilerplate 코드 증가

JDBC를 선택한 이유:

  1. GenerationType.IDENTITY를 유지하면서 성능 개선 가능
  2. 부모-자식 관계의 ID 매핑을 명확하게 제어 가능
  3. 배치 크기와 실행 방식을 세밀하게 조정 가능

3.2 JDBC 구현 (1차)

@Component
class MemberRecommendedProductJdbcInsert(jdbcTemplate: JdbcTemplate) {
    private val recommendedProductInsert = SimpleJdbcInsert(jdbcTemplate)
        .withTableName("member_recommended_product")
        .usingGeneratedKeyColumns("id")
    
    private val bucketBatchInsert = SimpleJdbcInsert(jdbcTemplate)
        .withTableName("member_recommended_product_bucket")
    
    fun saveWithJdbc(recommendedProducts: List<MemberRecommendedProduct>) {
        val startTime = System.currentTimeMillis()
        val now = LocalDateTime.now()
        val timestampNow = Timestamp.valueOf(now)
        
        val allBucketMaps = mutableListOf<Map<String, Any>>()
        
        // 부모는 단건 저장 (ID를 받아와야 하므로)
        recommendedProducts.forEach { product ->
            val productMap = mapOf(
                "member_id" to product.memberId,
                "product_id" to product.productId,
                "initial_score" to product.initialScore,
                "score" to product.score,
                "source" to product.source,
                "index_in_source" to product.indexInSource,
                "impressions" to product.impressions,
                "created_at" to timestampNow,
                "updated_at" to timestampNow
            )
            
            // 단건 저장하고 생성된 ID 반환
            val generatedId = recommendedProductInsert.executeAndReturnKey(productMap)
            
            // 자식 테이블용 데이터 준비
            product.buckets.forEach { bucket ->
                allBucketMaps.add(
                    mapOf(
                        "member_recommended_product_id" to generatedId.toLong(),
                        "bucket_number" to bucket.bucketNumber,
                        "created_at" to timestampNow,
                        "updated_at" to timestampNow
                    )
                )
            }
        }
        
        // 자식은 1000개씩 배치 저장
        allBucketMaps.chunked(1000).forEach { chunk ->
            bucketBatchInsert.executeBatch(*chunk.toTypedArray())
        }
    }
}

3.3 1차 개선 결과

성능 개선:
- 소요 시간: 27초 → 4초 (85% 개선)
- 부모 INSERT: 3,000개 단건 실행
- 자식 INSERT: 15개 배치 (1000개씩)
- 네트워크 왕복: 18,000회 → 3,015회

9배의 성능 향상을 달성했지만, 부모 테이블이 여전히 단건으로 처리되는 것이 아쉬웠습니다.

4. 디버깅을 통한 Spring Framework 이슈 발견

4.1 SimpleJdbcInsert 내부 동작 분석

부모 테이블을 배치로 처리하면서 생성된 키를 모두 가져올 수 있는 방법이 없을까?

이 의문을 해결하기 위해 SimpleJdbcInsert의 executeAndReturnKey() 메서드를 디버깅했습니다.

디버깅 중 예상치 못한 점을 발견했습니다:

실행 중 TableMetadataProvider 인스턴스:

  • 예상: MySqlTableMetaDataProvider
  • 실제: GenericTableMetaDataProvider ← 왜 Generic?

4.2 JDBC GenericTableMetaDataProvider vs MySQL Connector 차이점 분석

// GenericTableMetaDataProvider.java
public class GenericTableMetaDataProvider implements TableMetaDataProvider {
    .
    .
    .
    @Override
    public boolean isGeneratedKeysColumnNameArraySupported() {
        return true; 
    }
    .
    .
    .
}

4.3 MySQL Connector/J 소스 코드 분석

MySqlTableMetaDataProvider를 생성하지 않고 왜 GenericTableMetaDataProvider으로 퉁치는지? GenericTableMetaDataProvider에 선언된 속성들이 정말 MySQL connector에 구현과  같은지 비교해봤습니다. MySQL Connector의 DataStoreMetadata 클래스와 StatementImpl 클래스를 분석해봤습니다.

증거 1: execute(String sql, String[] generatedKeyNames) - Line 699-700

public boolean execute(String sql, String[] generatedKeyNames) throws SQLException {
    return executeInternal(sql, generatedKeyNames != null && generatedKeyNames.length > 0);
}

컬럼명 배열이 boolean으로 변환되고 배열 자체는 버려집니다.

증거 2: execute(String sql, int[] generatedKeyIndices) - Line 694-696

public boolean execute(String sql, int[] generatedKeyIndices) throws SQLException {
    return executeInternal(sql, generatedKeyIndices != null && generatedKeyIndices.length > 0);
}

컬럼 인덱스 배열도 boolean으로 변환되고 버려집니다.

증거 3: executeLargeUpdate(String sql, String[] columnNames) - Line 2335-2337

public long executeLargeUpdate(String sql, String[] columnNames) throws SQLException {
    return executeUpdateInternal(sql, false, columnNames != null && columnNames.length > 0);
}

동일한 패턴 - 컬럼명이 boolean으로만 사용됩니다.

증거 4: 내부 필드 선언 - Line 158

protected boolean retrieveGeneratedKeys = false;

생성된 키와 관련된 유일한 필드는 boolean이지, 컬럼명이나 인덱스 배열이 아닙니다.

증거 5: boolean 플래그만 저장 - Line 737

this.retrieveGeneratedKeys = returnGeneratedKeys;

executeInternal() 메소드에서는 boolean 플래그만 저장됩니다.

4.4 결론

MySQL Connector/J는 JDBC API 규격을 맞추기 위해 컬럼명/인덱스 배열을 파라미터로 받지만, 실제로는 완전히 무시합니다. getGeneratedKeys()가 호출되면 항상 모든 auto-increment 컬럼을 반환합니다.

따라서 Spring Framework의 MySqlTableMetaDataProvider가 generatedKeysColumnNameArraySupported = false로 설정한 것이 정확합니다.

문제는: Spring Framework가 MySQL Connector 정의와 다르지만 Generic으로 퉁치고 있었습니다.

5. Spring Framework 이슈 제출

5.1 GitHub Issue 작성

 

이슈 제출 링크 

5.2 개선 결과

Spring JDBC main에 머지 링크

이 이슈는 단순한 클래스 추가가 아니라 잠재적 버그를 개선한 것이라고 생각합니다. 또한, 겉으로는 "에러가 나지 않으니 괜찮다"고 생각할 수 있지만, 부정확한 메타데이터는 개발자의 잘못된 가정을 유발하고, 예상치 못한 동작과 디버깅 시간 낭비하는시간을 줄였다고 생각합니다.

6. JdbcTemplate 배치 처리로 성능 개선

6.1 GeneratedKeyHolder 발견

스프링 기여 이야기에서 빠져나와 본론으로 돌아와 성능개선을 이어가겠습니다. 키를 저장하고 반환하는 메소드를 디버깅하다가, 키를 저장하는 메소드(storeGeneratedKeys())를 사용하는 다른 함수가 있는지 확인하는 과정에서  JdbcTemplate의 batchUpdate() 메서드와 GeneratedKeyHolder를 발견했습니다. 이를 통해 배치 실행 시 모든 생성된 키를 한 번에 가져올 수 있다는 것을 알게 되었습니다. 

val keyHolder = GeneratedKeyHolder()

jdbcTemplate.batchUpdate(
    { it.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS) },
    object : BatchPreparedStatementSetter {
        // ...
    },
    keyHolder  // 여기에 모든 생성된 키가 저장됨!
)

// 배치 실행 후 모든 키를 가져옴
val parentIds = keyHolder.keyList.map { (it.values.first() as Number).toLong() }



+) 생성된 모든 키 가져오는 동작방식: 추가적으로 MySQl connoector 구현체를 살펴보니 MySQL의 LAST_INSERT_ID() 함수를 호출하여 첫 번째 생성된 ID를 가져오고connection.getAutoIncrementIncrement()를 사용해 MySQL의 auto_increment_increment 시스템 변수를 가져오고 증분식으로 다음 ID들을 추정하여 생성된 모든 키를 가져오고 있었습니다.

statementImpl.getGeneratedKeysInternal() 함수 내부

 
 

6.2 최종 구현

@Component
class MemberRecommendedProductInsert(private val jdbcTemplate: JdbcTemplate) {

    fun with(recommendedProducts: List<MemberRecommendedProduct>) {
        val now = LocalDateTime.now()
        val timestampNow = Timestamp.valueOf(now)

        recommendedProducts.chunked(1000).forEach { chunk ->
            // 부모 배치 저장 - 모든 생성된 키 반환!
            val parentIds = batchInsertParents(chunk, timestampNow)

            // 자식 배치 저장
            batchInsertBuckets(chunk, parentIds, timestampNow)
        }
    }

    private fun batchInsertParents(
        products: List<MemberRecommendedProduct>,
        timestampNow: Timestamp
    ): List<Long> {
        val keyHolder = GeneratedKeyHolder()

        jdbcTemplate.batchUpdate(
            { it.prepareStatement(INSERT_PRODUCT_SQL, Statement.RETURN_GENERATED_KEYS) },
            object : BatchPreparedStatementSetter {
                override fun setValues(ps: PreparedStatement, i: Int) {
                    products[i].apply {
                        ps.setLong(1, memberId)
                        ps.setLong(2, productId)
                        ps.setDouble(3, initialScore)
                        ps.setDouble(4, score)
                        ps.setString(5, source)
                        ps.setString(6, indexInSource)
                        ps.setInt(7, impressions)
                        ps.setTimestamp(8, timestampNow)
                        ps.setTimestamp(9, timestampNow)
                    }
                }

                override fun getBatchSize() = products.size
            },
            keyHolder
        )

        // 배치 실행 후 모든 생성된 키를 가져옴!
        return keyHolder.keyList.map { (it.values.first() as Number).toLong() }
    }

    private fun batchInsertBuckets(
        products: List<MemberRecommendedProduct>,
        parentIds: List<Long>,
        timestampNow: Timestamp
    ) {
        val allBuckets = products.flatMapIndexed { index, product ->
            product.buckets.map { bucket ->
                BucketData(parentIds[index], bucket.bucketNumber)
            }
        }

        if (allBuckets.isEmpty()) return

        allBuckets.chunked(1000).forEach { chunk ->
            jdbcTemplate.batchUpdate(
                INSERT_BUCKET_SQL,
                object : BatchPreparedStatementSetter {
                    override fun setValues(ps: PreparedStatement, i: Int) {
                        chunk[i].apply {
                            ps.setLong(1, parentId)
                            ps.setInt(2, bucketNumber)
                            ps.setTimestamp(3, timestampNow)
                            ps.setTimestamp(4, timestampNow)
                        }
                    }

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

    data class BucketData(val parentId: Long, val bucketNumber: Int)

    companion object {
        private const val INSERT_PRODUCT_SQL = """
            INSERT INTO member_recommended_product (
                member_id, product_id, initial_score, score, 
                source, index_in_source, impressions, created_at, updated_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
        """

        private const val INSERT_BUCKET_SQL = """
            INSERT INTO member_recommended_product_bucket (
                member_recommended_product_id, bucket_number, created_at, updated_at
            ) VALUES (?, ?, ?, ?)
        """
    }
}

7.최종 성능 결과

최종 성능 비교:

최종 성능 비교:
┌─────────────────────┬──────────┬─────────────┐
│       방법           │ 소요시간  │   왕복 횟수  │
├─────────────────────┼──────────┼─────────────┤
│ JPA saveAll         │   27초   │  18,000회   │ ← 부모+자식 각각 단건
│ SimpleJdbcInsert    │  ~4초    │   3,015회   │ ← 부모 단건 + 자식 배치
│ JdbcTemplate 배치   │  ~3초    │     18회    │ ← 부모+자식 모두 배치
└─────────────────────┴──────────┴─────────────┘

개선율: 89% (27초 → 3초)

 

 

8. 습관의 힘 & 배운 점

8.1 주장에는 명확한 근거가 필요하다

Spring Framework에 이슈를 제기하고 받아들이기 위해서는 "혼란을 없애기 위해 추가 클래스가 필요하다"는 가정만으로는 부족했습니다. MySQL Connector/J의 StatementImpl.java 소스 코드를 분석하여, 컬럼명 배열이 boolean으로만 변환되고 실제 배열은 버려진다는 구체적인 증거를 확보했습니다.

평소 팀 내에서 기술적 의견을 제시할 때도 "이렇게 하는 게 좋을 것 같아요"가 아니라 "이 방식이 좋은 이유는 X, Y, Z입니다"라고 근거를 들어 설명하려고 노력해왔습니다. 이러한 습관이 오픈소스 이슈를 작성할 때도 자연스럽게 적용될 수 있었습니다. MySQL Connector/J와 Spring JDBC 차이점을 코드레벨에서 분석하였고, 단순한 제안이 아닌 명확한 증거를 바탕으로 개선 제안을 할 수 있었습니다. 결과적으로 Spring Framework라는 기여라는 꿈에 그리던 결과를 얻을 수 있었다고 생각합니다.

8.2 추측보다 확인

JDBC API 스펙은 prepareStatement(sql, String[] columnNames)를 정의하고 있고, MySQL도 이 시그니처를 제공합니다. 문서만 보면 당연히 지원하는 것처럼 보입니다. 하지만 실제 구현을 들여다보니 columnNames 변수는 완전히 무시되고 있었습니다.

이 경험을 통해 실제 구현을 확인하는 습관이 생겼습니다. 이는 서드파티 라이브러리를 사용할 때 발생하는 예기치 않은 동작을 빠르게 파악하고 대응할 수 있는 능력으로 이어진다고 생각합니다.