코딩공부

Kotlin 런타임 시간 개선하기

integerJI 2025. 2. 2. 16:14

 

이전 글 : Kotlin으로 Chunk 사용하기, Spring와 얼마나 다를까?

 

해당 글에서 이어집니다. 

 

0. 런타임 시간을 줄일 수는 없을까?

문득 공부를 해보고 궁금해졌다. 코틀린 런타임 시간은 줄일 수 없을까? 줄일 수 있다면 어떤 방법이 있는지 알아보고 기존 소스를 개선할 수 있는점은 없을까하여 추가로 수정해보았다.

 

1. Kotlin Reflection (kotlin-reflect) 제거

kotlin-reflect는 주로 리플렉션 기능 (KClass, KProperty, KFunction 등)을 사용할 때 필요하지만, 현재 코드에서는 사용하지 않는다. 지워도 되는 이유는 아래의 이유가 있다.

  • 클래스 정보를 동적으로 조회하지 않음 (::class 사용 없음)
  • 프로퍼티나 함수 호출에 call() 같은 리플렉션 기능을 사용하지 않음
  • 어노테이션을 런타임에 조회하는 코드가 없음
  • Spring Batch와 관련된 모든 컴포넌트가 일반적인 @Bean으로 사용됨

따라서 kotlin-reflect를 제거함으로써 런타임 실행을 줄일 수 있다.

 

But. 이럴 경우에는 필요할 수 있다.

  • 만약 이후에 Kotlin 데이터 클래스의 copy(), toString(), equals(), hashCode() 같은 함수들을 리플렉션으로 처리하는 프레임워크(Jackson, Spring Validation, DI 등) 를 사용하게 되면, 다시 필요할 수 있다.
  • Spring에서 @ConfigurationProperties를 사용할 경우, 내부적으로 kotlin-reflect를 필요로 할 수 있다.

 

2. Kotlin Object 인스턴스 최적화

현재 data class 및 일반 클래스를 많이 사용하면 실행 시 객체가 계속 생성되면서 메모리를 많이 차지할 수 있다.

 

Kotlin에서는 object 키워드를 사용하면 싱글톤 객체를 생성할 수 있어, 불필요한 인스턴스 생성을 방지하고 메모리 사용량을 최적화할 수 있다. 이를 통해 동일한 기능을 수행하는 클래스의 반복적인 인스턴스 생성을 줄이고, 보다 효율적인 메모리 관리가 가능해진다.

 

어떤 부분을 object로 바꿔야 할까?

  • 상수 값들을 담고 있는 객체
  • 한 번만 생성되어야 하는 공통적인 함수/설정 값
  • 불필요하게 매번 생성되는 고정 데이터
  • Mapper 또는 RowMapper 같은 반복 호출되는 객체

 

2-1. SQL Query / 설정값을 object로 관리

AS-IS

@Bean
fun itemReader(): JdbcCursorItemReader<InputType> {
    return JdbcCursorItemReaderBuilder<InputType>()
        .name("jdbcItemReader")
        .dataSource(dataSource)
        .sql("SELECT id, name, value, created_at FROM test_table") // 👈 쿼리가 매번 객체로 생성됨
        .rowMapper { rs, _ ->
            InputType(
                rs.getString("name"),
                rs.getInt("value"),
                rs.getTimestamp("created_at").toLocalDateTime()
            )
        }
        .build()
}

 

기존 코드에서는 SQL 쿼리 문자열이 itemReader() 메서드가 호출될 때마다 새로운 객체로 생성된다. 동일한 SQL 문장이 반복적으로 메모리에 할당되는 상황

 

TO-BE

object QueryConstants {
    const val SELECT_TEST_TABLE = "SELECT id, name, value, created_at FROM test_table"
}

@Bean
fun itemReader(): JdbcCursorItemReader<InputType> {
    return JdbcCursorItemReaderBuilder<InputType>()
        .name("jdbcItemReader")
        .dataSource(dataSource)
        .sql(QueryConstants.SELECT_TEST_TABLE) // 👈 문자열 객체 재사용
        .rowMapper { rs, _ ->
            InputType(
                rs.getString("name"),
                rs.getInt("value"),
                rs.getTimestamp("created_at").toLocalDateTime()
            )
        }
        .build()
}

 

object를 활용하여 SQL 쿼리를 상수로 선언하면, 애플리케이션 실행 중 단일 객체로 유지되며 JVM에서 재사용된다. 이를 통해 문자열 객체의 반복 생성을 방지하고, 메모리를 보다 효율적으로 사용할 수 있다.

 

2-2. RowMapper를 object로 최적화

AS-IS

.rowMapper { rs, _ ->
    InputType(
        rs.getString("name"),
        rs.getInt("value"),
        rs.getTimestamp("created_at").toLocalDateTime()
    )
}

 

rowMapper 람다가 itemReader() 호출 시마다 새롭게 생성된다. 이로 인해 불필요한 람다 인스턴스가 계속 생성되며, 메모리 사용이 증가.

 

TO-BE

object InputTypeRowMapper : RowMapper<InputType> {
    override fun mapRow(rs: ResultSet, rowNum: Int): InputType {
        return InputType(
            rs.getString("name"),
            rs.getInt("value"),
            rs.getTimestamp("created_at").toLocalDateTime()
        )
    }
}

@Bean
fun itemReader(): JdbcCursorItemReader<InputType> {
    return JdbcCursorItemReaderBuilder<InputType>()
        .name("jdbcItemReader")
        .dataSource(dataSource)
        .sql(QueryConstants.SELECT_TEST_TABLE)
        .rowMapper(InputTypeRowMapper) // 👈 같은 객체를 계속 사용
        .build()
}

 

 

RowMapperobject로 정의하여 싱글톤으로 관리하여 동일한 객체 재사용

 

2-3. ItemProcessor를 싱글톤 object로 변경

AS-IS

@Bean
fun itemProcessor(): ItemProcessor<InputType, OutputType> {
    return ItemProcessor { input ->
        OutputType(
            name = input.name.uppercase(),
            value = input.value * 2,
            createdAt = input.createdAt
        )
    }
}

 

기존 코드에서는 itemProcessor()가 호출될 때마다 새로운 ItemProcessor 인스턴스가 생성된다. 이는 불필요한 객체 할당 되는 중

 

 

TO-BE

object BatchItemProcessor : ItemProcessor<InputType, OutputType> {
    override fun process(input: InputType): OutputType {
        return OutputType(
            name = input.name.uppercase(),
            value = input.value * 2,
            createdAt = input.createdAt
        )
    }
}

@Bean
fun itemProcessor(): ItemProcessor<InputType, OutputType> = BatchItemProcessor

 

ItemProcessorobject로 정의하여 싱글톤으로 관리, 동일한 객체를 재사용

 

2-4. PerformanceStepListener를 object로 변경

AS-IS

@Bean
fun step1(
    jobRepository: JobRepository,
    transactionManager: PlatformTransactionManager
): Step {
    return StepBuilder("step1", jobRepository)
        .chunk<InputType, OutputType>(1000, transactionManager)
        .reader(itemReader())
        .processor(itemProcessor())
        .writer(itemWriter())
        .listener(PerformanceStepListener()) // 👈 매번 새로운 인스턴스 생성
        .allowStartIfComplete(false)
        .build()
}

 

PerformanceStepListenerstep1() 호출 시마다 새로운 인스턴스로 생성 중

 

TO-BE

// as-is
class PerformanceStepListener : StepExecutionListenerSupport()

// to-be
object PerformanceStepListener : StepExecutionListenerSupport()

 

class로 되어있는 PerformanceStepListener를 object로 바꾼다.

 

@Bean
fun step1(
    jobRepository: JobRepository,
    transactionManager: PlatformTransactionManager
): Step {
    return StepBuilder("step1", jobRepository)
        .chunk<InputType, OutputType>(1000, transactionManager)
        .reader(itemReader())
        .processor(itemProcessor())
        .writer(itemWriter())
        .listener(PerformanceStepListener) // 👈 같은 객체를 재사용
        .allowStartIfComplete(false)
        .build()
}

 

인스턴스를 재사용

 

3. JdbcPagingItemReader를 사용해서 페이징 처리

@Bean
fun itemReader(): JdbcCursorItemReader<InputType> {
    return JdbcCursorItemReaderBuilder<InputType>()
        .name("jdbcItemReader")
        .dataSource(dataSource)
        .sql(QueryConstants.SELECT_TEST_TABLE)
        .rowMapper(InputTypeRowMapper) // 👈 같은 객체를 계속 사용
        .build()
}

object BatchItemProcessor : ItemProcessor<InputType, OutputType> {
    override fun process(input: InputType): OutputType {
        return OutputType(
            name = input.name.uppercase(),
            value = input.value * 2,
            createdAt = input.createdAt
        )
    }
}

 

기존에는 JdbcCursorItemReader를 사용하여 한 번에 모든 데이터를 읽어오는 방식이다. 이 경우, 대용량 데이터를 처리할 때 메모리 사용량이 급격히 증가하는데 특히, 전체 데이터를 커서 기반으로 한꺼번에 메모리에 로드하기 때문에, 데이터 크기가 커질수록 OutOfMemory(메모리 부족)이 발생한다.

 

이 부분을 수정해보자

@Bean
fun itemReader(): JdbcPagingItemReader<InputType> {
    val pagingQueryProvider = SqlPagingQueryProviderFactoryBean().apply {
        setDataSource(dataSource)
        setSelectClause("id, name, value, created_at")
        setFromClause("from test_table")
        setSortKey("id") // 정렬 필수
    }.getObject()!!

    return JdbcPagingItemReaderBuilder<InputType>()
        .name("jdbcPagingItemReader")
        .dataSource(dataSource)
        .fetchSize(1000) // ✅ 한 번에 가져오는 개수 줄이기
        .rowMapper(InputTypeRowMapper)
        .queryProvider(pagingQueryProvider)
        .build()
}

object BatchItemProcessor : ItemProcessor<InputType, OutputType> {
    override fun process(input: InputType): OutputType = input.run {
        OutputType(
            name.uppercase(), // ✅ 기존 객체를 사용하여 최소한의 데이터 변경
            value * 2,
            createdAt
        )
    }
}

 

JdbcPagingItemReader를 사용하여 페이징 단위로 데이터를 가져오도록 변경하면, 한 번에 로드되는 데이터 개수를 제한할 수 있어 메모리 사용량을 효과적으로 줄일 수 있다.

 

실제로 효율적인지 확인해보자.

 

4. 어플리케이션 실행

페이징을 적용하기 전 JdbcCursorItemReader를 사용한 기존 방식에서는 실행 시간이 빠르지만, 메모리 사용량이 높고 CPU 활용도가 낮은 특징이 있었다.

chunk 크기 실행 시간(ms) CPU 사용률 (%) 메모리 사용량 (MB)
1000 74689 0.09 143.88
5000 52788 0.01 117.95

 

과연 페이징 처리를 하면 어떻게 변할까?

 

4-1. chunk 1000, fetch size 1000

=== Batch Step Performance Metrics ===
Step Name: step1
Execution Time: 238215 ms
CPU Usage: 0.02 %
Memory Usage: 62.34 MB
Items Read: 1500000
Items Written: 1500000
======================================

 

오 ... chunk 1000일 경우 cpu와 memory 사용률이 눈에 뛰게 줄어들었다.

 

근데 .. 시간이 너무 오래걸린다. 아무래도 페이징 처리를 하다보니 이렇게 된 것 같다.

 

fetchSize를 올리고 스레드를 사용하여 병렬 처리를 해보기로 하였다.

 

4-2. chunk 1000, fetch size 5000

=== Batch Step Performance Metrics ===
Step Name: step1
Execution Time: 187629 ms
CPU Usage: 0.02 %
Memory Usage: 38.21 MB
Items Read: 1500000
Items Written: 1500000
======================================

 

chunk를 키워보자

 

fetchSize를 5000으로 증가하면서 실행 시간이 줄어들었고, 메모리 사용량도 감소하였다. 데이터 읽기 속도가 향상되었지만 여전히 시간이 오래 걸린다.

 

4-3. 변경 데이터 확인

chunk 크기 fetch size 실행 시간(ms) CPU 사용률 (%) 메모리 사용량 (MB)
1000 1000 238215 (3분) 0.02 62.34
1000 5000 187629 0.02 38.21
5000 1000 161150 0.02 43.36
5000 5000 162954 0.13 69.71

 

사실 이쯤 되면 시간이 너무 오래 걸려서 이렇게 굳이 페이징 처리를 해야하나? 

 

페이징 처리는 메모리 최적화에는 유리하지만, 실행 시간이 길어질 수 있다.사용 목적에 맞게 적절한 chunkSize와 fetchSize를 조절해야 하며, 무조건 페이징이 최적의 방법은 아니라고 생각이 든다. 

 

5. 후기

사실 아직까지 코틀린을 써야 하는 이유가 명확하지 않다. 코틀린의 장점과 단점을 함께 비교해 보면 메모리 최적화나 객체 재사용 측면에서는 유리하지만, 실행 시간이 길어지는 문제가 있다. 이러한 점을 고려할 때, 특정 상황에서는 자바가 더 적합할 수도 있겠다고 생각한다.

저번에 공부한 것을 기반으로 메모리 점유률, cpu 사용률을 계산할 수 있어서 이것저것 해보고있었는데 흠,, 오늘의 결과는 조금 아쉽긴 하다. 물론 내가 올바른 방법으로 테스트를 한게 아닐 수도 있다. 그래도 기존과는 다른 접근 방식을 시도하면서 새로운 시각을 얻을 수 있어서 뿌듯하긴 하다. 다음에는 코틀린 말고 Java에 더 집중해서 공부를 해볼까 한다.