콘텐츠로 이동

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

H2, PostgreSQL, MySQL 블록과 JDBC, R2DBC 벤치마크 장비를 로봇 작업자들이 정리하는 3D 작업대 일러스트
배치 벤치마크에서는 수치보다 먼저 측정 경계와 재현 방법을 정해야 합니다.

배치 코드를 만들다 보면 성능을 재고 싶은 순간이 옵니다. measureTimeMillis로 한 번 감싸면 숫자는 바로 나옵니다. 편합니다. 문제는 그다음입니다.

이 숫자를 믿고 코드를 바꿔도 될까?

처음에는 bluetape4k-batch도 테스트 코드 안에서 시간을 재는 방식으로 충분하다고 생각했습니다. 작은 예제에서는 그럴듯했습니다. 실행도 쉽고 결과도 바로 확인할 수 있습니다. 그러나 JDBC/R2DBC, H2/PostgreSQL/MySQL, 연결 풀 크기와 파티션 수까지 비교하면 단발성 측정만으로 결과의 조건을 설명하기 어렵습니다. 측정 조건을 설명할 수 없는 수치는 운영 설계의 근거로 사용할 수 없습니다.

이번 글에서는 bluetape4k-exposedutils/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.ktsbenchmark 소스 집합을 별도로 둡니다. 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"
}
}
}

실행은 이런 식입니다.

Terminal window
./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 같은 항목도 같이 볼 수 있습니다.

Terminal window
java -jar utils/batch/build/benchmarks/benchmark/jars/<benchmark-jar>.jar \
'.*PostgreSqlJdbcBatchBenchmark.*' \
-prof gc

다만 현재 utils/batch/benchmark/*.md에 실린 표와 차트는 thrptavg 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

이 정도 조합에서는 단발성 측정만으로 결론을 내릴 수 없습니다. 부정확한 수치는 오랫동안 잘못된 판단을 유도할 수 있으며, 오류를 확인했을 때는 이미 불필요한 최적화에 비용을 지출했을 수 있습니다.

시드 적재와 전체 배치 작업을 분리한다

섹션 제목: “시드 적재와 전체 배치 작업을 분리한다”

seedBenchmarkendToEndBatchJobBenchmark는 병목이 다릅니다.

시나리오측정 대상제외하는 것
시드 적재소스 행 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를 갱신합니다.

Gradle 실행 프로필에서 데이터베이스와 드라이버별 벤치마크, JSON 보고서, Markdown 문서와 차트로 이어지는 생성 구조
벤치마크 실행, JSON 보고서, Markdown 문서와 차트를 하나의 갱신 경로로 묶습니다.

문서화에서 중요한 것은 CSV를 그대로 붙이는 것이 아닙니다. 독자가 판단할 수 있는 축을 먼저 보여주고, 자세한 항목은 그 아래에 둬야 합니다.

문서 요소독자가 얻는 것
실행 프로필 표어떤 Gradle 작업과 벤치마크 클래스를 실행해야 하는지
비교 축어떤 조건을 비교했는지
결과 표조합별 ops/secavg ms
차트전체 경향을 빠르게 확인
해석 참고 사항데이터베이스별 해석 주의점

이렇게 구성하면 벤치마크를 다시 실행한 뒤 문서도 같은 흐름에서 갱신할 수 있습니다. README에 수치를 수동으로 옮기면 언젠가는 코드와 문서가 어긋납니다. 그때부터 해당 문서는 최신 성능 근거로 사용할 수 없습니다.

현재 utils/batch/benchmark/*.md에 기록된 값은 JSON 벤치마크 보고서에서 생성된 결과입니다. 여기서는 전체 행을 모두 나열하지 않고 PostgreSQL과 MySQL의 큰 전체 작업 조건만 봅니다. 이 표의 ops/sec는 행 처리량이 아니라 dataSize만큼의 행을 처리하는 배치 작업 전체를 초당 몇 회 완료했는지를 뜻합니다. ops/sec는 높을수록 좋고, avg ms는 배치 작업 1회의 평균 시간이므로 낮을수록 좋습니다.

DBDriverdataSizepoolSizeparallelismops/secavg ms
PostgreSQLJDBC1000003080.9901010.557
PostgreSQLR2DBC1000003080.1925201.966
MySQLJDBC1000003081.561640.695
MySQLR2DBC1000003080.1825505.989

이 표만 보면 JDBC가 꽤 크게 앞섭니다. 하지만 이 글의 핵심은 “JDBC가 항상 이긴다”가 아닙니다. 더 중요한 것은 이 결과가 어떤 조건에서 나온 값인지 설명할 수 있다는 점입니다.

H2, PostgreSQL, MySQL의 시드 적재 벤치마크에서 JDBC와 R2DBC의 배치 작업 처리량을 비교한 로그 눈금 차트
요약 차트는 큰 경향을 보여주고, 상세 Markdown 표는 조합별 값을 확인하게 합니다.

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

PostgreSQL 전체 배치 작업 벤치마크에서 파티션 수에 따른 JDBC와 R2DBC의 작업 완료 처리량을 비교한 차트
같은 데이터 크기와 연결 풀 크기에서 파티션 수에 따른 배치 작업 완료 처리량의 변화를 보여줍니다.

자체 코드에 적용할 때도 같은 방식으로 읽어야 합니다. 어느 쪽이 빠른지보다 먼저 어떤 작업 부하, 데이터베이스, 드라이버, 연결 풀 크기, 파티션 수에서 빠른지를 확인해야 합니다.

이 벤치마크 구조에서 재사용할 만한 요소는 네 가지입니다.

가져갈 것이유
src/benchmark/kotlin 분리테스트와 성능 측정의 목적을 나눈다
데이터베이스·드라이버별 실행 프로필 분리실행 단위와 결과 해석 단위가 명확해진다
@Param으로 조합 고정같은 축으로 반복 측정할 수 있다
JSON 보고서에서 Markdown·차트 생성결과 문서가 코드와 함께 갱신된다

반대로 벤치마크 값 자체를 다른 서비스의 성능 예상치로 사용해서는 안 됩니다. 데이터베이스 스키마, 네트워크, 드라이버 버전, JVM 옵션, 연결 풀 설정, 트랜잭션 크기가 달라지면 결과도 달라집니다. 이 글에서 가져갈 것은 숫자가 아니라 측정 구조입니다.

실전에서 저는 이런 순서로 시작하는 편이 좋다고 봅니다.

1. 먼저 작업 부하를 둘로 나눈다.
- 시드 적재와 INSERT 성능인가?
- 이미 적재된 데이터를 처리하는 배치 작업 성능인가?
2. 측정 밖으로 뺄 것을 정한다.
- 컨테이너 기동
- 연결 풀 생성
- 스키마 생성
- 테스트 데이터 준비
3. 조합 변수를 @Param으로 고정한다.
- dataSize
- poolSize
- parallelism 또는 파티션 수
4. 결과 문서의 갱신 경로를 만든다.
- JSON 보고서
- Markdown 표
- 요약 차트

이 정도만 적용해도 measureTimeMillis 하나로 얻은 수치보다 근거와 재현성이 높아집니다. 모든 측정 오류를 예방할 수는 없지만, 오류가 발생한 단계와 조건을 추적할 수 있습니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.