배치 벤치마크 재설계: measureTimeMillis에서 kotlinx-benchmark로

배치 코드를 만들다 보면 성능을 재고 싶은 순간이 옵니다. measureTimeMillis로 한 번 감싸면 숫자는 바로
나옵니다. 편합니다. 문제는 그다음입니다.
이 숫자를 믿고 코드를 바꿔도 될까?
처음에는 bluetape4k-batch도 테스트 코드 안에서 시간을 재는 방식으로 충분하다고 생각했습니다. 작은 예제에서는
그럴듯했습니다. 실행도 쉽고 결과도 바로 확인할 수 있습니다. 그러나 JDBC/R2DBC, H2/PostgreSQL/MySQL, 연결 풀
크기와 파티션 수까지 비교하면 단발성 측정만으로 결과의 조건을 설명하기 어렵습니다. 측정 조건을 설명할 수 없는
수치는 운영 설계의 근거로 사용할 수 없습니다.
이번 글에서는 bluetape4k-exposed의 utils/batch 벤치마크를 kotlinx-benchmark 기반으로 다시 구성한 이유와
구조를 살펴보겠습니다. 현재 구현은 bluetape4k-projects에서 분리되어
bluetape4k-exposed/utils/batch에
있습니다.
단발성 시간 측정의 한계
섹션 제목: “단발성 시간 측정의 한계”measureTimeMillis 자체가 나쁜 도구는 아닙니다. 간단한 기능 확인이나 대략적인 스모크 테스트에는 충분히 사용할 수
있습니다. 하지만 성능 비교 결과를 README나 블로그에 남기려면 기준이 달라집니다.
| 문제 | 실제로 곤란한 이유 |
|---|---|
| 워밍업이 없다 | JVM/JIT가 준비되기 전의 숫자를 결과처럼 볼 수 있다 |
| 반복과 통계가 약하다 | 한 번 튄 값인지, 반복해도 같은 경향인지 구분하기 어렵다 |
| 테스트와 벤치마크가 섞인다 | 회귀 테스트 실패와 성능 측정 실패의 의미가 흐려진다 |
| 결과 문서화가 분리되어 있다 | 벤치마크를 다시 실행해도 README 수치가 갱신되지 않을 수 있다 |
| 입력 크기가 작다 | 100건 같은 작은 값은 배치 병목을 충분히 드러내지 못한다 |
단발성 테스트 실행만으로 구현을 선택할 수 있다고 가정했지만, 배치 작업은 데이터 크기, 연결 풀과 파티션 수에 따라 병목이 바뀝니다. 한 조건에서 빠른 코드가 다른 조건에서도 빠르다고 말할 수 없습니다.
그래서 측정 구조를 먼저 분리했습니다.
utils/batch/├── build.gradle.kts├── benchmark/│ ├── README.ko.md│ ├── h2.md│ ├── postgresql.md│ └── mysql.md└── src/ ├── main/kotlin/ ├── test/kotlin/ └── benchmark/kotlin/io/bluetape4k/batch/benchmark/ ├── jdbc/ ├── r2dbc/ └── support/테스트는 기능을 검증하고, 벤치마크는 측정 조건을 고정합니다. 둘을 같은 파일에 넣으면 처음에는 편하지만, 이후에는 해당 수치가 기능 테스트와 벤치마크 중 어느 경로에서 생성됐는지부터 다시 확인해야 합니다.
벤치마크 소스 집합을 분리한다
섹션 제목: “벤치마크 소스 집합을 분리한다”현재 utils/batch/build.gradle.kts는 benchmark 소스 집합을 별도로 둡니다. kotlinx-benchmark는 JVM에서
JMH 위에서 동작하므로 벤치마크 클래스가 open이어야 하는 제약도 함께 처리합니다.
plugins { kotlin("plugin.allopen") alias(libs.plugins.kotlinx.benchmark)}
allOpen { annotation("org.openjdk.jmh.annotations.State")}
sourceSets { create("benchmark")}
kotlin { target { compilations.getByName("benchmark") .associateWith(compilations.getByName("main")) }}allOpen을 누락하면 기본적으로 final인 Kotlin 클래스를 JMH가 벤치마크 상태 객체로 확장할 수 없습니다.
이 오류는 벤치마크 구현이 아니라 Gradle 플러그인 설정에서 해결해야 합니다.
실행 프로필은 데이터베이스와 드라이버 단위로 나눴습니다.
benchmark { targets { register("benchmark") { this as kotlinx.benchmark.gradle.JvmBenchmarkTarget jmhVersion = libs.versions.jmh.get() } } configurations { register("postgresJdbc") { include("io.bluetape4k.batch.benchmark.jdbc.PostgreSqlJdbcBatchBenchmark") warmups = 2 iterations = 5 iterationTime = 1 iterationTimeUnit = "s" mode = "thrpt" outputTimeUnit = "s" reportFormat = "json" } register("postgresR2dbc") { include("io.bluetape4k.batch.benchmark.r2dbc.PostgreSqlR2dbcBatchBenchmark") warmups = 2 iterations = 5 iterationTime = 1 iterationTimeUnit = "s" mode = "thrpt" outputTimeUnit = "s" reportFormat = "json" } }}실행은 이런 식입니다.
./gradlew :bluetape4k-exposed-batch:postgresJdbcBenchmark./gradlew :bluetape4k-exposed-batch:postgresR2dbcBenchmark./gradlew :bluetape4k-exposed-batch:generateBenchmarkDocs여기서 사용하는 측정 모드는 thrpt, 단위는 ops/sec입니다. 높을수록 처리량이 좋습니다. 문서에는 avg ms도 함께
기록하며 평균 시간은 낮을수록 좋습니다. 벤치마크 글에서 각 측정값의 해석 방향을 밝히지 않으면 독자가
표의 의미를 매번 다시 판단해야 합니다.
kotlinx-benchmark를 사용한다고 해서 시간만 측정할 수 있는 것은 아닙니다. JVM 대상에서는 JMH 위에서 실행되므로,
생성된 벤치마크 JAR에 JMH 프로파일러를 적용할 수 있습니다. 예를 들어 gc 프로파일러를 사용하면 처리량뿐 아니라
gc.alloc.rate, gc.alloc.rate.norm, gc.count, 조건에 따라 gc.time 같은 항목도 같이 볼 수 있습니다.
java -jar utils/batch/build/benchmarks/benchmark/jars/<benchmark-jar>.jar \ '.*PostgreSqlJdbcBatchBenchmark.*' \ -prof gc다만 현재 utils/batch/benchmark/*.md에 실린 표와 차트는 thrpt와 avg ms 중심입니다. GC 프로파일러 결과까지
함께 문서화하려면 벤치마크 실행과 문서 생성기를 한 번 더 보강해야 합니다. 성능 저하의 원인을 분석하려면
GC와 할당 지표가 필요합니다. 특히 배치 코드의 행 매핑이나 청크 처리에서 객체를 많이 생성하면
처리량보다 GC 지표가 먼저 문제를 드러낼 수 있습니다.
참고할 만한 사례는 bluetape4k-image에 있습니다. 대용량 이미지 스트리밍 벤치마크는 배치 작업처럼 큰 입력을
읽고, 변환하고, 다시 쓰는 흐름을 다룹니다. 해당 문서는 kotlinx-benchmark로 평균 시간을 측정한 뒤,
별도 JMH GC 프로파일러 결과로 gc.alloc.rate.norm을 기록했습니다. Scrimage ByteArray 경로가
large-photo에서 약 216 MiB/op를 할당하고, vips Path 경로가 약 0.54 MiB/op에 머문다는 차이는
평균 시간만 봤다면 놓치기 쉬운 신호입니다.
조합 변수는 @Param으로 고정한다
섹션 제목: “조합 변수는 @Param으로 고정한다”배치 벤치마크에서 가장 먼저 고정한 것은 비교 축입니다. 데이터베이스와 드라이버는 벤치마크 클래스와 실행 프로필이 맡고,
조합 변수는 @Param으로 선언합니다.
@State(Scope.Benchmark)open class SeedScenarioParams { @Param("1000", "10000", "100000") var dataSize: Int = 1000
@Param("10", "30", "60") var poolSize: Int = 10}
@State(Scope.Benchmark)open class JobScenarioParams { @Param("1000", "10000", "100000") var dataSize: Int = 1000
@Param("10", "30", "60") var poolSize: Int = 10
@Param("1", "4", "8") var parallelism: Int = 1}여기서 parallelism은 스레드 수가 아니라 파티션 수입니다. 이 차이를 문서에 밝히지 않으면 벤치마크 결과를
읽는 사람이 작업자 스레드를 8개로 늘렸다고 오해할 수 있습니다. 실제로는 소스 키 범위를 나누고 각
파티션을 별도 배치 작업으로 처리합니다.
조합 수는 꽤 큽니다.
| 시나리오 | 조합 |
|---|---|
| 시드 적재 | 데이터베이스 3개 x 드라이버 2개 x dataSize 3개 x poolSize 3개 = 54 |
| 전체 작업 | 데이터베이스 3개 x 드라이버 2개 x dataSize 3개 x poolSize 3개 x parallelism 3개 = 162 |
이 정도 조합에서는 단발성 측정만으로 결론을 내릴 수 없습니다. 부정확한 수치는 오랫동안 잘못된 판단을 유도할 수 있으며, 오류를 확인했을 때는 이미 불필요한 최적화에 비용을 지출했을 수 있습니다.
시드 적재와 전체 배치 작업을 분리한다
섹션 제목: “시드 적재와 전체 배치 작업을 분리한다”seedBenchmark와 endToEndBatchJobBenchmark는 병목이 다릅니다.
| 시나리오 | 측정 대상 | 제외하는 것 |
|---|---|---|
| 시드 적재 | 소스 행 INSERT 처리량 | Testcontainers 기동, 연결 풀 생성, 스키마 생성 |
| 전체 작업 | 이미 적재된 소스를 읽고 대상에 쓰는 배치 작업 1회 | Testcontainers 기동, 연결 풀 생성, 스키마 생성, 시드 적재 |
JDBC 공용 벤치마크 기반 코드에는 이 경계가 주석으로도 명시되어 있습니다.
/** * ## 측정 경계 * - 측정 외부: Testcontainers 기동, DataSource/커넥션 풀 생성, 스키마 생성 * - 시드 벤치마크 본문: 소스 행 INSERT만 측정 * - 엔드 투 엔드 벤치마크 본문: 소스 데이터 적재 완료 후 배치 잡 1회 실행만 측정 */이 경계를 정하지 않으면 벤치마크 결과를 해석하기 어려워집니다. 첫 실행이 느린 이유가 컨테이너 기동인지, 스키마 생성인지, 실제 배치 작업인지 구분할 수 없기 때문입니다. 이런 수치는 운영 코드의 병목을 찾는 근거로 사용하기 어렵습니다.
JDBC 전체 작업 경로는 소스 키 범위를 파티션으로 나누고 각 파티션을 별도 작업으로 실행합니다.
internal fun partitionRanges(db: Database, parallelism: Int): List<KeyRange> { parallelism.requirePositiveNumber("parallelism") val (minKey, maxKey) = minMaxKey(db) val totalKeys = maxKey - minKey + 1 val partitionCount = minOf(parallelism.toLong(), totalKeys).toInt() // ...}
internal fun runParallelJobs(db: Database, parallelism: Int): Int { val ranges = partitionRanges(db, parallelism) var totalWriteCount = 0 runSuspendIO { coroutineScope { ranges.mapIndexed { index, range -> async { runJobPartition( db, "jdbcBenchmarkJob-partition-$index", range.minKeyExclusive, range.maxKeyInclusive, ) } }.awaitAll().also { counts -> totalWriteCount = counts.sum() } } } return totalWriteCount}여기서 순차 경로와 병렬 경로 모두 같은 ExposedJdbcBatchJobRepository를 사용합니다. 저장소 구현을 바꾸면
parallelism의 효과와 저장소 구현 차이가 함께 측정됩니다. 벤치마크는 통제하지 않은 변수가 적을수록
결과를 명확하게 해석할 수 있습니다.
문서 생성까지 벤치마크에 포함한다
섹션 제목: “문서 생성까지 벤치마크에 포함한다”벤치마크 결과는 JSON에서 끝나지 않고 사람이 읽을 수 있는 문서로 이어져야 합니다. 그래서
generateBenchmarkDocs가 JSON 보고서를 읽고 utils/batch/benchmark/*.md를 갱신합니다.

문서화에서 중요한 것은 CSV를 그대로 붙이는 것이 아닙니다. 독자가 판단할 수 있는 축을 먼저 보여주고, 자세한 항목은 그 아래에 둬야 합니다.
| 문서 요소 | 독자가 얻는 것 |
|---|---|
| 실행 프로필 표 | 어떤 Gradle 작업과 벤치마크 클래스를 실행해야 하는지 |
| 비교 축 | 어떤 조건을 비교했는지 |
| 결과 표 | 조합별 ops/sec와 avg ms |
| 차트 | 전체 경향을 빠르게 확인 |
| 해석 참고 사항 | 데이터베이스별 해석 주의점 |
이렇게 구성하면 벤치마크를 다시 실행한 뒤 문서도 같은 흐름에서 갱신할 수 있습니다. README에 수치를 수동으로 옮기면 언젠가는 코드와 문서가 어긋납니다. 그때부터 해당 문서는 최신 성능 근거로 사용할 수 없습니다.
대표 결과는 이렇게 읽는다
섹션 제목: “대표 결과는 이렇게 읽는다”현재 utils/batch/benchmark/*.md에 기록된 값은 JSON 벤치마크 보고서에서 생성된 결과입니다. 여기서는 전체 행을
모두 나열하지 않고 PostgreSQL과 MySQL의 큰 전체 작업 조건만 봅니다. 이 표의 ops/sec는 행 처리량이 아니라
dataSize만큼의 행을 처리하는 배치 작업 전체를 초당 몇 회 완료했는지를 뜻합니다. ops/sec는 높을수록 좋고,
avg ms는 배치 작업 1회의 평균 시간이므로 낮을수록 좋습니다.
| DB | Driver | dataSize | poolSize | parallelism | ops/sec | avg ms |
|---|---|---|---|---|---|---|
| PostgreSQL | JDBC | 100000 | 30 | 8 | 0.990 | 1010.557 |
| PostgreSQL | R2DBC | 100000 | 30 | 8 | 0.192 | 5201.966 |
| MySQL | JDBC | 100000 | 30 | 8 | 1.561 | 640.695 |
| MySQL | R2DBC | 100000 | 30 | 8 | 0.182 | 5505.989 |
이 표만 보면 JDBC가 꽤 크게 앞섭니다. 하지만 이 글의 핵심은 “JDBC가 항상 이긴다”가 아닙니다. 더 중요한 것은 이 결과가 어떤 조건에서 나온 값인지 설명할 수 있다는 점입니다.

PostgreSQL 전체 작업 차트를 따로 보면 dataSize=10000, poolSize=30 조건에서 parallelism별 흐름을
빠르게 확인할 수 있습니다.

자체 코드에 적용할 때도 같은 방식으로 읽어야 합니다. 어느 쪽이 빠른지보다 먼저 어떤 작업 부하, 데이터베이스, 드라이버, 연결 풀 크기, 파티션 수에서 빠른지를 확인해야 합니다.
독자가 가져가면 좋은 구조
섹션 제목: “독자가 가져가면 좋은 구조”이 벤치마크 구조에서 재사용할 만한 요소는 네 가지입니다.
| 가져갈 것 | 이유 |
|---|---|
src/benchmark/kotlin 분리 | 테스트와 성능 측정의 목적을 나눈다 |
| 데이터베이스·드라이버별 실행 프로필 분리 | 실행 단위와 결과 해석 단위가 명확해진다 |
@Param으로 조합 고정 | 같은 축으로 반복 측정할 수 있다 |
| JSON 보고서에서 Markdown·차트 생성 | 결과 문서가 코드와 함께 갱신된다 |
반대로 벤치마크 값 자체를 다른 서비스의 성능 예상치로 사용해서는 안 됩니다. 데이터베이스 스키마, 네트워크, 드라이버 버전, JVM 옵션, 연결 풀 설정, 트랜잭션 크기가 달라지면 결과도 달라집니다. 이 글에서 가져갈 것은 숫자가 아니라 측정 구조입니다.
실전에서 저는 이런 순서로 시작하는 편이 좋다고 봅니다.
1. 먼저 작업 부하를 둘로 나눈다. - 시드 적재와 INSERT 성능인가? - 이미 적재된 데이터를 처리하는 배치 작업 성능인가?
2. 측정 밖으로 뺄 것을 정한다. - 컨테이너 기동 - 연결 풀 생성 - 스키마 생성 - 테스트 데이터 준비
3. 조합 변수를 @Param으로 고정한다. - dataSize - poolSize - parallelism 또는 파티션 수
4. 결과 문서의 갱신 경로를 만든다. - JSON 보고서 - Markdown 표 - 요약 차트이 정도만 적용해도 measureTimeMillis 하나로 얻은 수치보다 근거와 재현성이 높아집니다. 모든 측정 오류를 예방할
수는 없지만, 오류가 발생한 단계와 조건을 추적할 수 있습니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.