"N+1을 배치 조회로 바꿨더니 빨라졌다"는 감이 아니라 수치로 증명해야 한다. JVM 위에서 돌아가는 코드의 실행 시간을 정확하게 측정하려면 System.currentTimeMillis()로는 부족하다. JMH는 JVM의 특성을 고려한 벤치마크 프레임워크로, OpenJDK 팀이 직접 만들었다.
왜 직접 시간을 재면 안 되는가
이런 코드를 많이 본다.
long start = System.nanoTime();
myMethod();
long elapsed = System.nanoTime() - start;
System.out.println("소요 시간: " + elapsed + "ns");
결과가 나오긴 하지만, 이 수치는 재현 불가능하다. JVM은 코드를 실행하면서 계속 최적화를 수행하기 때문이다.
JIT 컴파일
JVM은 처음에 바이트코드를 인터프리터로 실행하다가, 자주 호출되는 메서드를 감지하면 네이티브 코드로 컴파일한다. 이것이 JIT, Just-In-Time 컴파일이다. 첫 번째 실행과 만 번째 실행의 속도가 완전히 다르다.
데드 코드 제거
JIT 컴파일러는 결과가 사용되지 않는 코드를 감지하면 아예 실행하지 않는다. 위 예시에서 myMethod()의 반환값을 어디에도 쓰지 않으면, JVM이 메서드 호출 자체를 없애버릴 수 있다. 측정 결과가 0ns에 가까워지면서 "엄청 빠르다"는 착각을 하게 된다.
GC 간섭
가비지 컬렉션은 예측 불가능한 시점에 발생한다. 측정 구간 안에서 GC가 발생하면 소요 시간이 급등하고, 발생하지 않으면 정상처럼 보인다. 같은 코드를 돌려도 결과가 들쭉날쭉한 이유다.
JMH는 이 세 가지 문제를 모두 해결한다. 워밍업 단계에서 JIT을 안정화시키고, Blackhole로 데드 코드 제거를 방지하며, 충분한 반복과 포크로 GC 영향을 평균화한다.
JMH vs 다른 성능 테스트 도구
성능 테스트 도구가 많은데 왜 JMH인가. 핵심은 측정 대상의 계층이 다르다는 것이다.
| 도구 | 측정 대상 | 계층 | 적합한 상황 |
|---|---|---|---|
| JMH | Java 메서드 단위 | JVM 내부 | 쿼리 전략, 알고리즘, 직렬화 비교 |
| k6 / JMeter / Gatling | HTTP 엔드포인트 | 네트워크 | 동시 접속, 처리량, SLA 검증 |
| EXPLAIN ANALYZE | SQL 쿼리 단위 | DB 내부 | 실행 계획, 인덱스 효과 확인 |
k6이나 JMeter로 API 엔드포인트를 호출하면 네트워크 지연, 웹 서버 큐잉, 직렬화 시간이 모두 포함된다. "N+1 vs 배치 쿼리"의 순수한 차이를 보고 싶은데, 그 사이에 낀 레이어들이 노이즈가 된다. JMH는 JVM 안에서 메서드를 직접 호출하므로, 쿼리 전략 자체의 성능만 격리해서 측정할 수 있다.
반대로, 동시 접속 100명이 몰렸을 때의 응답 시간이나 처리량(throughput)을 보려면 JMH로는 부족하다. 커넥션 풀 경합, HTTP 스레드 풀 포화 같은 문제는 실제 HTTP 요청을 보내야 드러나기 때문이다. 이때는 k6이나 JMeter가 맞다.
정리하면 JMH는 "이 쿼리 전략이 저 전략보다 몇 배 빠른가"를 정밀하게 답하는 도구이고, k6/JMeter는 "이 API가 트래픽을 얼마나 버티는가"를 답하는 도구다.
JMH의 동작 구조
JMH가 벤치마크를 실행하는 과정을 보면 왜 이 프레임워크가 필요한지 명확해진다.
Fork
JMH는 벤치마크를 별도의 JVM 프로세스에서 실행한다. 이전 벤치마크가 JIT 캐시나 힙 상태를 오염시키는 것을 방지하기 위해서다. @Fork(2)면 같은 벤치마크를 두 개의 독립된 JVM에서 실행하고 결과를 합친다.
워밍업
측정 전에 지정된 횟수만큼 코드를 실행한다. 이 단계에서 JIT 컴파일이 일어나고, 클래스 로딩이 완료되고, 각종 캐시가 채워진다. 워밍업 결과는 측정에 포함되지 않는다.
측정 반복
워밍업 이후 안정화된 상태에서 실제 측정이 이루어진다. 각 Iteration은 지정된 시간 동안 메서드를 반복 호출하고, 처리량이나 평균 시간을 기록한다.
핵심 어노테이션
@Benchmark
벤치마크 대상 메서드에 붙인다. JMH가 이 어노테이션이 달린 메서드를 찾아서 실행한다.
@Benchmark
public List<FeedDto> batchQuery(MyState state) {
return feedService.getFeedsByCursor(state.request, state.loginUser);
}
반환값이 있다는 점에 주목한다. 반환값이 없으면 JVM이 메서드 호출을 데드 코드로 판단해서 제거할 수 있다. 반환값을 JMH에 넘기면 JMH가 내부적으로 Blackhole에 소비시켜서 이 문제를 방지한다.
@State
벤치마크에서 사용할 데이터를 담는 클래스에 붙인다. 스코프에 따라 데이터의 공유 범위가 달라진다.
@State(Scope.Benchmark)
public class MyState {
public FeedGetRequest request;
public User loginUser;
@Setup(Level.Trial)
public void setUp() {
// 벤치마크 전체에서 한 번 실행
request = new FeedGetRequest(/* ... */);
loginUser = createTestUser();
}
@TearDown(Level.Trial)
public void tearDown() {
// 벤치마크 종료 후 정리
}
}
Scope.Benchmark는 모든 스레드가 같은 State 인스턴스를 공유한다. Scope.Thread는 스레드마다 별도 인스턴스를 만든다. DB 커넥션처럼 스레드 안전하지 않은 리소스는 Scope.Thread를 써야 한다.
Setup과 TearDown의 Level
Level.Trial— 전체 벤치마크에서 한 번. DB 시딩, 컨테이너 시작 등 무거운 초기화에 적합하다.Level.Iteration— 각 Iteration 시작/종료 시. 캐시 초기화 등에 사용한다.Level.Invocation— 매 호출마다. 오버헤드가 크기 때문에 측정 결과를 왜곡할 수 있다. 특별한 이유가 없으면 쓰지 않는다.
@BenchmarkMode
무엇을 측정할지 결정한다.
Mode.Throughput— 초당 처리 횟수 (ops/s). "1초에 몇 번 처리하는가"Mode.AverageTime— 평균 실행 시간. "한 번 처리하는 데 평균 얼마나 걸리는가"Mode.SampleTime— 실행 시간 분포. 백분위수(p50, p90, p99)를 보여준다.Mode.SingleShotTime— 워밍업 없이 단 한 번 실행. 콜드 스타트 성능 측정용이다.
어떤 모드를 선택할지는 무엇을 판단하고 싶은가에 달려 있다.
- "A 방식이 B보다 빠른가?" →
AverageTime. 평균으로 비교하면 직관적이다. - "최악의 경우 얼마나 느린가?" →
SampleTime. p99, p99.9 같은 백분위수를 보여준다. SLA 기준이 "p99 < 100ms"인 API라면 이 모드가 필요하다. - "초당 몇 건을 처리하는가?" →
Throughput. 대량 처리 파이프라인의 처리율을 비교할 때 적합하다. - "콜드 스타트 성능은?" →
SingleShotTime. 캐시 워밍 없이 한 번만 실행. 서버 재시작 직후 첫 요청 성능을 측정한다.
쿼리 전략 비교(N+1 vs 배치)에서는 AverageTime으로 시작한다. 평균 차이가 확인되면 SampleTime을 추가로 돌려서 분포의 안정성까지 확인한다.
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@OutputTimeUnit은 결과를 표시할 시간 단위다. 쿼리 벤치마크는 밀리초가 적당하다.
워밍업과 측정 설정
@Warmup(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
@Fork(2)
이 설정의 의미를 풀어보면 이렇다.
- 각 Fork에서 5초짜리 워밍업을 3번 돌린다 (총 15초, 결과 버림)
- 이후 10초짜리 측정을 5번 돌린다 (총 50초, 결과 수집)
- 이 과정을 2개의 독립 JVM에서 반복한다
총 측정 시간은 (15 + 50) × 2 = 130초다. 벤치마크 메서드가 여러 개면 이 시간이 메서드 수만큼 곱해진다.
처음에는 워밍업 3회, 측정 5회, Fork 2로 시작하는 것이 적당하다. 결과의 오차(±)가 평균의 10% 이상이면 측정 횟수를 늘린다. DB를 사용하는 벤치마크는 네트워크 지연 변동이 있으므로 측정 시간을 10초 이상으로 잡는 것이 좋다.
Gradle 프로젝트에 JMH 추가
플러그인 방식
jmh-gradle-plugin을 사용하면 별도 모듈 없이 src/jmh 디렉토리에서 벤치마크를 실행할 수 있다.
plugins {
id 'me.champeau.jmh' version '0.7.3'
}
jmh {
fork = 2
warmupIterations = 3
iterations = 5
benchmarkMode = ['avgt']
timeUnit = 'ms'
resultFormat = 'JSON'
resultsFile = project.file("${project.buildDir}/reports/jmh/result.json")
}
벤치마크 소스는 src/jmh/java/ 아래에 위치한다. src/main과 src/test의 클래스를 모두 참조할 수 있다.
디렉토리 구조
project/
├── src/
│ ├── main/java/ ← 프로덕션 코드
│ ├── test/java/ ← 단위/통합 테스트
│ └── jmh/java/ ← 벤치마크 코드
│ └── com/example/
│ └── FeedBenchmark.java
실행
./gradlew jmh
특정 벤치마크만 실행하려면 정규식 필터를 건다.
./gradlew jmh -Pjmh.include="FeedBenchmark"
최소 벤치마크 예제
DB 없이 순수 Java 로직만 비교하는 가장 단순한 형태다.
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
@State(Scope.Benchmark)
public class CollectionBenchmark {
private List<Integer> data;
@Setup(Level.Trial)
public void setUp() {
data = IntStream.rangeClosed(1, 100_000)
.boxed()
.collect(Collectors.toList());
}
@Benchmark
public long streamSum() {
return data.stream()
.mapToLong(Integer::longValue)
.sum();
}
@Benchmark
public long forLoopSum() {
long sum = 0;
for (int v : data) {
sum += v;
}
return sum;
}
}
두 메서드 모두 결과를 반환한다. streamSum()의 반환값을 void로 바꾸면 JIT이 전체 연산을 제거해버려서 측정이 무의미해진다.
실행하면 이런 결과가 출력된다.
Benchmark Mode Cnt Score Error Units
CollectionBenchmark.forLoopSum avgt 10 45.231 ± 1.024 us/op
CollectionBenchmark.streamSum avgt 10 62.847 ± 2.156 us/op
Score— 평균 실행 시간Error— 95% 신뢰구간의 반폭.45.231 ± 1.024면 진짜 평균이 44.207~46.255 사이에 있을 확률이 95%라는 뜻이다.Cnt— 측정 횟수. Fork 2 × Iteration 5 = 10회다.
자주 하는 실수
@Benchmark 메서드가 void를 반환하면 JIT이 메서드 내부 연산을 통째로 제거할 수 있다. 반드시 결과를 반환하거나, 파라미터로 Blackhole을 받아서 bh.consume(result)로 소비시켜야 한다.
[!WARNING] Setup에서 측정 대상 로직 실행
@Setup에서 DB 조회나 캐시 워밍을 하면서 이것이 측정에 포함된다고 착각하는 경우가 있다. @Setup은 측정 구간 밖에서 실행된다. 측정하고 싶은 코드는 반드시 @Benchmark 메서드 안에 넣어야 한다.
[!WARNING] Fork 없이 실행
@Fork(0)으로 설정하면 현재 JVM에서 바로 실행되어 빠르지만, 이전 벤치마크의 JIT 상태가 남아 있어서 결과가 오염된다. 디버깅용으로만 쓰고, 실제 측정은 반드시 Fork 1 이상으로 실행한다.
[!WARNING] 너무 짧은 측정 시간
@Measurement(time = 100, timeUnit = TimeUnit.MILLISECONDS)처럼 측정 시간을 극단적으로 줄이면, 한 Iteration 안에서 메서드가 몇 번 실행되지 않아 통계적 의미가 없다. DB 쿼리처럼 한 번 실행에 수십 ms가 걸리는 작업은 최소 5초 이상 잡는다.