Batch benchmark 다시 세우기: measureTimeMillis를 보내고 kotlinx-benchmark로 간 이유

배치 코드를 만들다 보면 성능을 재고 싶은 순간이 옵니다. measureTimeMillis로 한 번 감싸면 숫자는 바로
나옵니다. 편합니다. 문제는 그다음입니다.
이 숫자를 믿고 코드를 바꿔도 될까?
처음에는 bluetape4k-batch도 테스트 코드 안에서 시간을 재는 방식으로 충분하다고 생각했습니다. 작은 예제에서는
그럴듯했습니다. 실행도 쉽고, 결과도 바로 확인할 수 있습니다. 그런데 JDBC/R2DBC, H2/PostgreSQL/MySQL, pool size,
partition 수까지 비교하기 시작하면 이야기가 달라집니다. 숫자는 나오는데, 그 숫자가 어떤 조건에서 나온 값인지
설명하기 어려워집니다. 성능을 논할 때 이 상태가 제일 위험합니다. 회의실에서는 그럴듯하지만, 운영 판단으로
가져가면 뚜껑이 열릴 수 있습니다.
이번 글에서는 bluetape4k-exposed의 utils/batch benchmark를 kotlinx-benchmark 기반으로 다시 세운 이유와
구조를 살펴보겠습니다. 현재 구현은 bluetape4k-projects에서 분리되어
bluetape4k-exposed/utils/batch에
있습니다.
기존 방식이 아쉬웠던 지점
섹션 제목: “기존 방식이 아쉬웠던 지점”measureTimeMillis 자체가 나쁜 도구는 아닙니다. 간단한 기능 확인이나 대략적인 smoke check에는 충분히 쓸 수
있습니다. 하지만 성능 비교 결과를 README나 블로그에 남기려면 기준이 달라집니다.
| 문제 | 실제로 곤란한 이유 |
|---|---|
| 워밍업이 없다 | JVM/JIT가 준비되기 전의 숫자를 결과처럼 볼 수 있다 |
| 반복과 통계가 약하다 | 한 번 튄 값인지, 반복해도 같은 경향인지 구분하기 어렵다 |
| 테스트와 benchmark가 섞인다 | 회귀 테스트 실패와 성능 측정 실패의 의미가 흐려진다 |
| 결과 문서화가 분리되어 있다 | benchmark를 다시 돌려도 README 숫자가 따라오지 않을 수 있다 |
| 입력 크기가 작다 | 100건 같은 작은 값은 batch 병목을 잘 드러내지 못한다 |
처음에는 “테스트에서 한 번 돌려보고 빠른 쪽을 고르면 되겠지”라고 생각했습니다. 이게 첫 번째 삽질이었습니다. 배치 작업은 데이터 크기, connection pool, partition 수에 따라 병목이 바뀝니다. 한 조건에서 빠른 코드가 다른 조건에서도 빠르다고 말할 수 없습니다.
그래서 측정 구조를 먼저 분리했습니다.
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/테스트는 기능을 검증하고, benchmark는 측정 조건을 고정합니다. 둘을 같은 파일에 넣으면 처음에는 편하지만, 나중에는 “이 숫자는 테스트가 만든 숫자인가, benchmark가 만든 숫자인가”부터 다시 확인해야 합니다.
benchmark source set을 따로 둔다
섹션 제목: “benchmark source set을 따로 둔다”현재 utils/batch/build.gradle.kts는 benchmark source set을 따로 둡니다. kotlinx-benchmark는 JVM에서
JMH 위에서 동작하므로, benchmark 클래스가 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을 빼먹으면 JMH가 benchmark state를 다루는 과정에서 불필요하게 고생합니다. Kotlin class는 기본이
final이고, JMH는 그런 사정을 봐주지 않습니다. “왜 benchmark class를 못 잡지?” 하고 한참 보다가 결국 Gradle
설정으로 돌아오게 됩니다. 이런 삽질은 한 번이면 충분합니다.
프로파일은 DB와 driver 단위로 나눴습니다.
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도 함께
남깁니다. 평균 시간은 낮을수록 좋습니다. benchmark 글에서 측정값의 방향을 빼먹으면 독자는 표를 볼 때마다
멈칫합니다.
kotlinx-benchmark를 쓴다고 해서 시간만 잴 수 있는 것은 아닙니다. JVM target에서는 JMH 위에서 실행되므로,
생성된 benchmark JAR에 JMH profiler를 붙일 수 있습니다. 예를 들어 gc profiler를 붙이면 처리량뿐 아니라
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에 실린 표와 chart는 thrpt와 avg ms 중심입니다. GC profiler 결과까지
같이 문서화하려면 benchmark 실행과 문서 생성기를 한 번 더 보강해야 합니다. 성능 문제가 “느리다”에서 끝나지 않고
“왜 느린가”로 넘어가면 GC와 allocation 지표가 필요합니다. 특히 배치 코드에서는 row mapping이나 chunk 처리에서
객체를 많이 만들면 throughput 숫자보다 GC 지표가 먼저 경고를 줄 때가 있습니다.
참고할 만한 사례는 bluetape4k-image에 있습니다. 대용량 이미지 streaming benchmark는 batch 작업처럼 큰 입력을
읽고, 변환하고, 다시 쓰는 흐름을 다룹니다. 그 문서에서는 kotlinx-benchmark로 평균 시간을 측정한 뒤,
별도 JMH GC profiler 결과로 gc.alloc.rate.norm을 남겼습니다. Scrimage ByteArray 경로가
large-photo에서 약 216 MiB/op를 할당하고, vips Path 경로가 약 0.54 MiB/op에 머문다는 차이는
평균 시간만 봤다면 놓치기 쉬운 신호입니다.
조합 변수는 @Param으로 고정한다
섹션 제목: “조합 변수는 @Param으로 고정한다”배치 benchmark에서 가장 먼저 고정한 것은 비교 축입니다. DB와 driver는 benchmark class/profile이 맡고,
조합 변수는 @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은 thread 개수가 아니라 partition 수입니다. 이 차이를 문서에 적어두지 않으면 benchmark 결과를
읽는 사람이 “worker thread를 8개로 늘렸구나”라고 오해하기 쉽습니다. 실제로는 source key range를 나누고, 각
partition을 별도 batch job으로 처리합니다.
조합 수는 꽤 큽니다.
| 시나리오 | 조합 |
|---|---|
| Seed | DB 3개 x driver 2개 x dataSize 3개 x poolSize 3개 = 54 |
| End-to-End | DB 3개 x driver 2개 x dataSize 3개 x poolSize 3개 x parallelism 3개 = 162 |
이 정도가 되면 “대충 한 번 재봤다”는 말로는 부족합니다. 대충 잰 숫자는 대충 맞을 때도 있지만, 틀렸을 때는 사람을 꽤 오래 속입니다. 숫자가 틀렸다는 사실을 알아차릴 때쯤이면 이미 한참 삽질한 뒤일 가능성이 큽니다.
seed와 end-to-end를 분리한다
섹션 제목: “seed와 end-to-end를 분리한다”seedBenchmark와 endToEndBatchJobBenchmark는 병목이 다릅니다.
| 시나리오 | 측정 대상 | 제외하는 것 |
|---|---|---|
| Seed | source row INSERT 처리량 | Testcontainers 기동, pool 생성, schema 생성 |
| End-to-End | 이미 적재된 source를 읽고 target에 쓰는 batch job 1회 | Testcontainers 기동, pool 생성, schema 생성, seed 적재 |
JDBC 쪽 공용 benchmark 인프라에는 이 경계가 주석으로도 명시되어 있습니다.
/** * ## 측정 경계 * - 측정 외부: Testcontainers 기동, DataSource/커넥션 풀 생성, 스키마 생성 * - 시드 벤치마크 본문: 소스 행 INSERT만 측정 * - 엔드 투 엔드 벤치마크 본문: 소스 데이터 적재 완료 후 배치 잡 1회 실행만 측정 */이 경계를 정하지 않으면 benchmark는 금방 이상해집니다. 첫 실행이 느린 이유가 container 기동 때문인지, schema 생성 때문인지, 진짜 batch job이 느린 건지 구분이 안 됩니다. 운영 코드에서 병목을 찾으려는 사람에게 이런 숫자는 도움이 거의 없습니다.
JDBC end-to-end 경로는 source key range를 partition으로 나누고, 각 partition을 별도 job으로 실행합니다.
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를
사용합니다. repository를 바꾸면 parallelism의 효과를 본 것이 아니라 repository 차이를 같이 본 셈이 됩니다.
benchmark에서는 변수가 적을수록 해석하기 좋습니다. 변수 욕심을 내면 표는 화려해지지만, 결론은 흐려집니다.
문서 생성까지 benchmark의 일부로 둔다
섹션 제목: “문서 생성까지 benchmark의 일부로 둔다”benchmark 결과는 JSON으로 끝나지 않습니다. 사람이 읽을 문서로 이어져야 합니다. 그래서
generateBenchmarkDocs가 JSON report를 읽고 utils/batch/benchmark/*.md를 갱신합니다.

문서화에서 중요한 것은 CSV를 그대로 붙이는 것이 아닙니다. 독자가 판단할 수 있는 축을 먼저 보여주고, 자세한 항목은 그 아래에 둬야 합니다.
| 문서 요소 | 독자가 얻는 것 |
|---|---|
| Profile table | 어떤 Gradle task와 benchmark class를 실행해야 하는지 |
| Comparison dimensions | 어떤 축으로 비교했는지 |
| Result tables | 조합별 ops/sec와 avg ms |
| Charts | 큰 경향을 빠르게 확인 |
| Notes | DB별 해석 주의점 |
이렇게 해두면 benchmark를 다시 돌린 뒤 문서 갱신도 같은 흐름에 들어갑니다. README에 숫자를 손으로 옮기는 순간, 언젠가는 숫자가 코드와 어긋납니다. 그때부터는 성능 문서가 아니라 갱신되지 않은 기록이 됩니다.
대표 결과는 이렇게 읽는다
섹션 제목: “대표 결과는 이렇게 읽는다”현재 utils/batch/benchmark/*.md에 기록된 값은 JSON benchmark report에서 생성된 결과입니다. 여기서는 전체 row를
다 펼치지 않고, PostgreSQL과 MySQL의 큰 end-to-end 조건만 봅니다. ops/sec는 높을수록 좋고, avg ms는 낮을수록
좋습니다.
| 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 end-to-end chart를 따로 보면 dataSize=10000, poolSize=30 조건에서 parallelism별 흐름을
빠르게 확인할 수 있습니다.

자기 코드에 적용할 때도 같은 식으로 읽으면 됩니다. “어느 쪽이 빠르다”보다 먼저 “어떤 workload, 어떤 DB, 어떤 driver, 어떤 pool size, 어떤 partition 수에서 빠른가”를 물어야 합니다.
독자가 가져가면 좋은 구조
섹션 제목: “독자가 가져가면 좋은 구조”이 benchmark 구조에서 그대로 가져갈 만한 것은 네 가지입니다.
| 가져갈 것 | 이유 |
|---|---|
src/benchmark/kotlin 분리 | 테스트와 성능 측정의 목적을 나눈다 |
| DB/driver별 profile 분리 | 실행 단위와 결과 해석 단위가 명확해진다 |
@Param으로 조합 고정 | 같은 축으로 반복 측정할 수 있다 |
| JSON report -> Markdown/chart 생성 | 결과 문서가 코드와 함께 갱신된다 |
반대로 그대로 따라 하면 안 되는 것도 있습니다. benchmark 값 자체를 다른 서비스의 성능 예상치로 가져가면 안 됩니다. DB schema, network, driver version, JVM option, pool setting, transaction 크기가 달라지면 결과는 달라집니다. 이 글에서 가져갈 것은 숫자가 아니라 측정 구조입니다.
실전에서 저는 이런 순서로 시작하는 편이 좋다고 봅니다.
1. 먼저 workload를 둘로 나눈다. - seed/insert 성능인가? - 이미 적재된 데이터를 처리하는 batch job 성능인가?
2. 측정 밖으로 뺄 것을 정한다. - container 기동 - connection pool 생성 - schema 생성 - test data 준비
3. 조합 변수를 @Param으로 고정한다. - dataSize - poolSize - parallelism 또는 partition 수
4. 결과 문서의 갱신 경로를 만든다. - JSON report - Markdown table - summary chart이 정도만 해도 measureTimeMillis 하나로 만든 숫자보다 훨씬 덜 위험합니다. 삽질을 완전히 막지는 못합니다. 그래도
삽질한 위치를 찾을 수는 있습니다. benchmark에서는 이 차이가 꽤 큽니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.