코딩공부

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

integerJI 2025. 1. 19. 20:29

https://integer-ji.tistory.com/440

 

Spring Boot 3.x에서 Chunk 사용해서 데이터 read, write 사용률 알아보기

0. Chunk를 사용하는 이유는 뭘까?메모리 관리에 용이전체 데이터를 한 번에 로드하지 않고 설정된 Chunk 크기만큼 메모리에 유지시킨다. 대용량 데이터 처리 시에도 OutOfMemoryError를 방지할 수 있다

integer-ji.tistory.com

 

0. Kotlin과 차이가 있을까?

저번주에는 Spring Boot에서 Java 17을 이용해 Chunk의 메모리 사용량을 알아보았다.

 

여기서 궁금한 점.

 

Kotlin에서 Java 소스를 그대로 사용하게 된다면 과연 메모리 사용율은 어떻게 변할까?

큰 차이가 있을지 궁금하여 시도해 보았다.

 

1. 프로젝트 설정

1-1. 사용된 언어와 라이브러리 버전

kotlin 1.9.25
spring boot 3.3.6

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-batch:3.3.6")
    implementation("org.springframework.boot:spring-boot-starter-jdbc:3.3.6")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    compileOnly("org.projectlombok:lombok:1.18.36")
    runtimeOnly("com.microsoft.sqlserver:mssql-jdbc:12.6.4.jre11")
    annotationProcessor("org.projectlombok:lombok:1.18.36")
    testImplementation("org.springframework.boot:spring-boot-starter-test:3.3.6")
    testImplementation("org.springframework.batch:spring-batch-test:5.1.2")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.5")
}

 

이전 글과 동의.

1-2. 테이블 생성 및 데이터 Insert (mssql)

이전 글과 동의.

 

2. 배치 생성

Chunk를 사용하여 데이터를 Read, Write 하는 함수를 만듭니다.

package com.kin.batch.config

import com.kin.batch.listener.PerformanceStepListener
import com.kin.batch.model.InputType
import com.kin.batch.model.OutputType
import org.springframework.batch.core.Job
import org.springframework.batch.core.Step
import org.springframework.batch.core.job.builder.JobBuilder
import org.springframework.batch.core.repository.JobRepository
import org.springframework.batch.core.step.builder.StepBuilder
import org.springframework.batch.item.ItemProcessor
import org.springframework.batch.item.database.JdbcBatchItemWriter
import org.springframework.batch.item.database.JdbcCursorItemReader
import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder
import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.transaction.PlatformTransactionManager
import javax.sql.DataSource

@Configuration
class BatchConfig(private val dataSource: DataSource) {

    @Bean
    fun batchJob(
        jobRepository: JobRepository,
        transactionManager: PlatformTransactionManager
    ): Job {
        return JobBuilder("batchJob", jobRepository)
            .start(step1(jobRepository, transactionManager))
            .build()
    }

    @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()
    }

    @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()
    }

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

    @Bean
    fun itemWriter(): JdbcBatchItemWriter<OutputType> {
        return JdbcBatchItemWriterBuilder<OutputType>()
            .dataSource(dataSource)
            .sql("INSERT INTO processed_table (name, value, created_at) VALUES (:name, :value, :createdAt)")
            .beanMapped()
            .build()
    }
}

 

나머지는 이전 글과 동일하다.

 

3. 메모리 체크 함수

package com.kin.batch.listener

import org.springframework.batch.core.ExitStatus
import org.springframework.batch.core.StepExecution
import org.springframework.batch.core.listener.StepExecutionListenerSupport
import java.lang.management.ManagementFactory
import com.sun.management.OperatingSystemMXBean
import java.time.Duration
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId

class PerformanceStepListener : StepExecutionListenerSupport() {

    private val runtime: Runtime = Runtime.getRuntime()

    override fun afterStep(stepExecution: StepExecution): ExitStatus {
        // 시작 시간과 종료 시간 가져오기
        val startTime: LocalDateTime = stepExecution.startTime ?: LocalDateTime.now()
        val endTime: LocalDateTime = stepExecution.endTime ?: LocalDateTime.now()

        // Instant 변환
        val startInstant: Instant = localDateTimeToInstant(startTime)
        val endInstant: Instant = localDateTimeToInstant(endTime)

        // 실행 시간 계산 (밀리초)
        val executionTimeMs: Long = Duration.between(startInstant, endInstant).toMillis()

        val cpuUsage: Float = getCpuUsage()
        val memoryUsage: Double = getMemoryUsage()

        printPerformanceMetrics(stepExecution, executionTimeMs, cpuUsage, memoryUsage)
        return stepExecution.exitStatus
    }

    private fun localDateTimeToInstant(localDateTime: LocalDateTime): Instant {
        return localDateTime.atZone(ZoneId.systemDefault()).toInstant()
    }

    private fun getCpuUsage(): Float {
        return try {
            val osBean: OperatingSystemMXBean =
                ManagementFactory.getPlatformMXBean(OperatingSystemMXBean::class.java)

            // 첫 번째 샘플링
            val startTime: Long = System.nanoTime()
            val startCpuTime: Double = osBean.processCpuTime.toDouble()

            // 100ms 대기
            Thread.sleep(100)

            // 두 번째 샘플링
            val endTime: Long = System.nanoTime()
            val endCpuTime: Double = osBean.processCpuTime.toDouble()

            // CPU 사용률 계산
            val elapsedTime: Long = endTime - startTime // 나노초
            val cpuUsage: Double = (endCpuTime - startCpuTime) / (elapsedTime * osBean.availableProcessors)

            // 퍼센트 단위로 변환
            (cpuUsage * 100).toFloat()
        } catch (e: Exception) {
            e.printStackTrace()
            0.0f // 오류 시 기본값
        }
    }

    private fun getMemoryUsage(): Double {
        // JVM 메모리 사용량 측정 (MB)
        val usedMemory: Long = runtime.totalMemory() - runtime.freeMemory()
        return usedMemory / (1024.0 * 1024.0) // MB 단위로 변환
    }

    private fun printPerformanceMetrics(
        stepExecution: StepExecution,
        executionTimeMs: Long,
        cpuUsage: Float,
        memoryUsage: Double
    ) {
        System.out.printf(
            """
                === Batch Step Performance Metrics ===
                Step Name: %s
                Execution Time: %d ms
                CPU Usage: %.2f %%
                Memory Usage: %.2f MB
                Items Read: %d
                Items Written: %d
                ======================================
                """.trimIndent(),
            stepExecution.stepName,
            executionTimeMs,
            cpuUsage,
            memoryUsage,
            stepExecution.readCount,
            stepExecution.writeCount
        )
    }
}

 

이전 글과 동의.

 

 

4. 배치 수행

테스트는 Java와 마찬가지로 1,500,000건으로 테스트를 진행해 보았다.

 

mssql을 사용했고 서버는 docker container로 올렸다. 

 

이번에는 배치 수행 전 평시 상태를 체크해 보았다.

평온하다.

 

평온한 도커에 돌을 던져보자

 

4-1. 애플리케이션 수행

=== Batch Step Performance Metrics ===
Step Name: step1
Execution Time: 74689 ms
CPU Usage: 0.09 %
Memory Usage: 143.88 MB
Items Read: 1500000
Items Written: 1500000
======================================

 

앗 Java 보다 자원을 많이 쓴 거 같은데?

 

 

도커 DB CPU는 74% 정도 사용했다.

(java는 80% 넘게 사용됐다.)

 

청크를 높여보자

 

chunk 5000

=== Batch Step Performance Metrics ===
Step Name: step1
Execution Time: 52788 ms
CPU Usage: 0.01 %
Memory Usage: 117.95 MB
Items Read: 1500000
Items Written: 1500000
======================================

 

 

마찬가지로 메모리 사용량이 되게 높다.

 

 

CPU는 77.68%

 

애플리케이션의 메모리를 정리해 본다면

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

 

흠 정리를 해보자.

 

5. 마무리

5-1. 비교 정리

실행 시간

* Java (chunk 1000): 80,352ms

* Kotlin (chunk 1000): 74,689ms


Kotlin이 약 7% 더 빠른 실행 속도를 보임

 

애플리케이션 메모리 사용

* Java (chunk 1000): 48.31MB

* Kotlin (chunk 1000): 143.88MB

 

Kotlin이 약 3배 더 많은 메모리를 사용

 

DB CPU 사용률

Java: 최대 80% 이상

Kotlin: 최대 74%

Kotlin이 DB CPU를 조금 더 효율적으로 사용

 

5-2. 정리

DB 서버의 리소스 사용과 전반적인 실행 속도에서는 kotiln이 더 우세한 성능을 보였다. 하지만 어플리케이션 메모리 사용량에서는 Java가 더 효율적이었다. 이 이유는 kotiln이 런타임 라이브러리가 크기때문에 많이 사용되는 거라고 추정된다.

 

그리고 kotiln을 사용하는데 정말 편했다. 나는 Python과 Java를 사용하고 있는데 딱 두개의 장점을 섞어놓은 듯한 느낌을 받았다. 특히 java에서 만든 소스 그대로 kotiln에서 사용하는게 너무 편했다

 

5-3. 짧은 회고

kotiln을 연구하는 이유는 많은 회사에서 kotiln을 도입한 이유를 알고 싶었고 이미 고착화된 java를 버리고 kotiln으로 넘어가야하나?라는 의문점에 답변을 하고싶었다.

 

그렇게 Java와 Kotiln을 비교해가며 사용해보니 각각의 장점을 알 수 있었다. 또한 이러한 연구방식은 처음 시도해 보는데 CPU, 메모리의 사용량을 알기위해 라이브러리도 처음 써보고 Docker 컨테이너를 사용해서 상태분석도 해봤다. 물론 이게 옳지 않을 수도 있다. 하지만 이러한 방향성을 잡은 지금으로써는 더 다듬어가며 앞으로의 연구에 도움이 될 것 같아서 많이 뿌듯하다.