한 줄 정의

핵심 메시지

마이크로벤치마킹은 작은 단위의 자바 코드 성능을 직접 측정하는 작업입니다. 그런데 자바 가상 머신은 워밍업·JIT·가비지 컬렉션·데드 코드 제거 같은 동적 동작을 측정하는 순간에도 계속 수행합니다. 그래서 작은 코드 조각일수록 런타임의 배경 동작에서 분리하기가 더 어렵고, 무심코 작성한 벤치마크는 거의 항상 거짓말을 합니다.

결론은 두 가지입니다. 첫째, 대부분의 경우 마이크로벤치마킹은 하지 말아야 합니다 — 전체 시스템을 측정하는 탑다운 접근이 더 쉽고 정확합니다. 둘째, 정말 필요하다면 손으로 짜지 말고 JMH(Java Microbenchmark Harness)를 쓰세요. OpenJDK 팀이 알려진 함정들을 우회하려고 만든 사실상의 표준 도구입니다.

쉽게 말하면

직접 짠 정렬 벤치마크를 돌렸더니 “초당 9,838번 정렬 가능”이라는 깔끔한 숫자가 나옵니다. 오차율도 낮고 처리량도 그럴듯합니다. 여기서 “측정 끝, 이 코드가 빠르다”고 결론 내리는 것이 가장 흔한 실수입니다.

문제는 그 숫자가 무엇을 측정한 것인지 아무도 모른다는 점입니다. JIT 컴파일러가 아직 워밍업이 안 된 인터프리터 코드를 측정한 것일 수도, 가비지 컬렉션이 끼어든 시간을 포함한 것일 수도, 결과를 안 쓴다는 이유로 JIT가 정렬 자체를 통째로 제거해 버린 것을 측정한 것일 수도 있습니다.

마이크로벤치마킹은 “이 한 줄이 느린 것 같아”라며 코드를 현미경으로 들여다보고 싶은 욕구에서 출발하는 경우가 많습니다. 그러나 이 수준에서 신뢰할 수 있는 숫자를 뽑는 일은 매우 어렵고 위험한 덫 입니다.

알렉세이 쉬필료프 (JMH 개발자)

벤치마크 수치 자체는 중요하지 않습니다. 중요한 것은 그 수치로부터 어떤 모델을 도출하는가입니다.

왜 중요한가?

벤치마크는 과학 실험이다

좋은 벤치마크를 만들려면 이를 과학 실험 처럼 다뤄야 합니다. 벤치마크를 입력과 출력이 있는 불투명한 상자(opaque box) 로 바라보고, 거기서 데이터를 수집해 동작 모델을 추론하는 과정입니다.

이상적인 목표는 공정한 테스트 입니다. 시스템의 단 하나의 요소만 변경하고, 그 외 모든 외부 요인은 통제된 상태로 유지하는 것입니다. 그러나 현실에서는 그렇게 운이 좋은 경우가 드뭅니다.

반복 가능성이 최소 조건

과학적으로 완벽한 공정 테스트가 실질적으로 불가능하더라도, 벤치마크가 최소한 반복 가능해야 한다는 것은 필수 입니다. 이는 모든 실증적 결과의 근본 기반입니다.

자바 런타임은 분리할 수 없다

자바 플랫폼에서 벤치마크가 어려운 중심 문제는 자바 런타임의 정교함 입니다. 자바 가상 머신은 개발자 코드에 자동 최적화를 끊임없이 적용하므로, 벤치마크를 과학적 테스트로 간주하는 순간 선택지가 크게 제한됩니다.

자바 실행 코드와 JIT 컴파일러, 메모리 관리, 그 밖의 런타임 하위 시스템을 완전히 분리할 수 없습니다. 마찬가지로 테스트 실행 시점의 운영 체제, 하드웨어, CPU 부하 같은 런타임 조건의 영향도 무시할 수 없습니다.

존 던

어떤 사람도 그 자체로 온전한 섬이 아닙니다.

이 영향을 완화하는 더 쉬운 방법은 더 큰 집합(전체 시스템 또는 하위 시스템)을 다루는 것 입니다. 반대로 작은 규모의 마이크로벤치마크일수록 애플리케이션 코드를 런타임의 배경 동작에서 분리하기가 훨씬 더 어렵습니다. 이것이 마이크로벤치마킹이 근본적으로 어려운 이유입니다.

백엔드 관점에서의 함의

실무에서 이 원칙은 탑다운 성능과 곧장 연결됩니다. 스프링부트·쿼커스로 평범한 웹 서비스를 만드는 대부분의 백엔드 개발자에게는 마이크로벤치마킹이 거의 필요 없습니다. “이 메서드가 몇 나노초 걸리는가”보다 “이 API가 목표 처리량에서 지연 SLA를 지키는가”가 비즈니스 질문이기 때문입니다. 후자는 전체 시스템 부하 테스트로 답하는 편이 훨씬 쉽고 정확합니다.

핵심 내용

A.1 직접 짠 벤치마크가 거짓말하는 과정

100,000개(예제 코드에서는 1,000개) 무작위 정수 리스트를 정렬하는, 겉보기에 매우 단순한 벤치마크입니다.

public class ClassicSort {
    private static final int N = 1_000;
    private static final int I = 150_000;
    private static final List<Integer> testData = new ArrayList<>();
 
    public static void main(String[] args) {
        Random randomGenerator = new Random();
        for (int i = 0; i < N; i++) {
            testData.add(randomGenerator.nextInt(Integer.MAX_VALUE));
        }
 
        System.out.println("Testing Sort Algorithm");
        double startTime = System.nanoTime();
 
        for (int i = 0; i < I; i++) {
            List<Integer> copy = new ArrayList<Integer>(testData);
            Collections.sort(copy);
        }
 
        double endTime = System.nanoTime();
        double timePerOperation = ((endTime - startTime) / (1_000_000_000L * I));
        System.out.println("Result: " + (1 / timePerOperation) + " op/s");
    }
}

무작위 정수 배열을 만들고 시작 시간을 기록한 뒤, 템플릿 배열을 복사해 정렬하는 작업을 I번 반복합니다. 그리고 전체 시간을 초 단위로 환산해 반복 횟수로 나눠 한 번의 연산 시간을 계산합니다. 문제가 없어 보이지만, 이 벤치마크에는 최소 네 가지 함정이 숨어 있습니다.

함정 1: 워밍업이 없다

이 벤치마크는 자바 가상 머신의 준비 과정 없이 곧바로 측정에 들어갑니다. 그런데 운영 환경에서 정렬 코드는 몇 시간, 어쩌면 며칠 동안 실행됩니다. JIT 컴파일러는 인터프리터 바이트코드를 고도로 최적화된 기계어로 변환하는데, 이 변환은 해당 메서드가 일정 횟수 이상 실행된 후에만 활성화 됩니다.

따라서 워밍업 없는 측정은 실제 운영 동작을 반영하지 못합니다. JVM이 벤치마크를 측정하는 도중에도 최적화 작업을 계속하기 때문입니다. PrintCompilation 플래그로 이를 직접 확인할 수 있습니다.

java -Xms2048m -Xmx2048m -XX:+PrintCompilation ClassicSort

-Xms/-Xmx는 힙 크기를 2GB로 고정하고, PrintCompilation은 메서드가 컴파일될 때마다 로그를 출력합니다.

Testing Sort Algorithm
73    29    3    java.util.ArrayList::ensureExplicitCapacity (26 bytes)
73    31    3    java.lang.Integer::valueOf (32 bytes)
74    32    3    java.util.concurrent.atomic.AtomicLong::get (5 bytes)
...
75    36    3    java.lang.Integer::compareTo (9 bytes) made not entrant
76    40    3    java.util.ComparableTimSort::binarySort (223 bytes)
77    41    3    java.util.ComparableTimSort::mergeLo (656 bytes)
...

측정이 진행되는 와중에도 JIT 컴파일러가 호출 계층의 일부를 계속 최적화하고 있습니다. 즉 벤치마크의 성능이 시간 측정 도중에 계속 변하고 있고, 결과적으로 통제하지 않은 변수가 실험에 끼어든 상태입니다. (made not entrant은 이전에 컴파일한 코드를 폐기하고 재컴파일한다는 신호로, 최적화가 한 번에 끝나지 않음을 보여줍니다.)

그래서 워밍업이 필요합니다. 벤치마크 본 측정 전에 일정 횟수 코드를 실행하되 그 실행에서는 시간을 측정하지 않는 방식으로, JVM이 안정 상태로 수렴한 뒤에 측정합니다.

함정 2: 가비지 컬렉션

또 다른 통제되지 않은 변수는 가비지 컬렉션입니다. 이상적으로는 측정 동안 GC가 실행되지 않도록 하고, 설정이 끝난 뒤 GC 상태가 정상화되기를 원합니다. 그러나 GC는 비결정적(nondeterministic) 이라 제어가 매우 어렵습니다. -verbose:gc로 GC 발생을 들여다볼 수 있습니다.

java -Xms2048m -Xmx2048m -verbose:gc ClassicSort
Testing Sort Algorithm
[GC (Allocation Failure)  524800K->632K(2010112K), 0.0009038 secs]
[GC (Allocation Failure)  525432K->672K(2010112K), 0.0008671 secs]
Result: 9838.556465303362 op/s

확실히 개선할 수 있는 부분은 GC가 실행될 가능성이 높은 동안에는 측정을 하지 않는 것 입니다. 시스템에 GC를 요청한 후 잠시 대기하는 방법도 있지만, 시스템이 이를 무시할 수도 있습니다.

함정 3: 데드 코드 제거

테스트에서 생성된 결과를 실제로 사용하지 않는 것 도 흔한 실수입니다. 위 벤치마크에서 copy는 정렬된 뒤 어디에도 쓰이지 않습니다. JIT 컴파일러는 이를 데드 코드 경로(dead code path) 로 인식하고 최적화로 제거해 버릴 수 있습니다. 정렬을 측정하려고 짠 코드가, 측정 시점엔 아예 사라져 있는 것입니다.

함정 4: 단일 실행과 오차 범위

단일 실행 결과만으로 성능을 평가하는 것도 문제입니다. 평균값을 계산하더라도, 벤치마크가 실제로 어떻게 수행되었는지 전체 그림을 보여주지 못합니다. 이상적으로는 오차 범위(margin of error) 를 함께 측정해 수집된 값의 신뢰성을 평가해야 합니다.

오차 범위가 높다면 통제되지 않은 변수가 존재하거나 코드 자체의 성능이 낮을 가능성이 있습니다. 어느 경우든 오차 범위를 측정하지 않으면 문제의 존재 여부조차 파악할 방법이 없습니다.

복잡해지면 더 나빠진다

코드의 복잡성이 커질수록 상황은 훨씬 악화됩니다. 예를 들어 멀티스레드 코드는 벤치마킹이 매우 어려운 영역입니다. 정확한 결과를 얻으려면 벤치마크 시작 시점부터 모든 스레드가 완전히 실행될 때까지 동기화 상태를 유지해야 하며, 그렇지 않으면 오차 범위가 매우 커집니다. 여기에 전력 관리 기능, 같은 시스템 내 다른 프로세스와의 자원 경쟁 같은 하드웨어 요소까지 더해집니다.

두 가지 해결책

이 모든 문제를 해결하는 방법은 두 가지입니다.

접근방식적합한 대상
전체 시스템만 측정저수준 수치는 무시하고, 수많은 개별 효과가 평균적으로 수렴된 대규모 결과만 수집대부분의 상황·대부분의 개발자
공통 프레임워크 사용알려진 부담들을 제거하고, OpenJDK 메인라인을 따라가며 새 최적화·외부 제어 변수를 관리하는 도구 활용마이크로벤치마킹이 정말 필요한 경우 → JMH

A.2.1 피할 수 있다면 피하라

한 일화가 이 원칙을 잘 보여줍니다. 동료 한 명이 며칠째 단일 자바 메서드만 뚫어져라 바라보며 작업하고 있었습니다. 그가 개편 중이던 애플리케이션에는 명백한 성능 문제가 있었고, 새 버전은 최신 라이브러리를 썼는데도 기존보다 성능이 오히려 떨어졌습니다.

그는 코드 일부를 제거하고 작은 벤치마크를 작성하며 원인을 찾고 있었습니다. 하지만 이 방식은 건초 더미에서 바늘을 찾는 것 과 다르지 않았습니다. 접근을 바꿔 실행 프로파일러로 단 10분간 애플리케이션을 프로파일링하자, 진짜 원인이 드러났습니다. 문제는 애플리케이션 코드가 아니라 팀이 새로 도입한 인프라 라이브러리 에 있었습니다.

개발자가 빠지기 쉬운 덫

개발자는 자신의 코드가 문제의 원인일 것이라는 생각에 집착하다가 더 큰 그림을 놓치는 경우가 많습니다. 작은 규모의 코드 구조를 면밀히 살펴보며 문제를 찾고 싶어 하지만, 이 수준에서 벤치마킹을 수행하는 것은 매우 어렵고 위험한 ‘덫’입니다.

A.2.2 그래도 필요한 경우: 판단 휴리스틱

전체가 부분보다 쉽다

전체 자바 애플리케이션의 실제 성능을 분석하는 것이, 작은 자바 코드 조각을 분석하는 것보다 대부분 더 쉽습니다.

저수준 분석이나 마이크로벤치마킹이 필요한 주요 사례는 세 가지뿐입니다.

사례근거
OpenJDK·다른 자바 플랫폼 구현 개발JMH의 핵심 사용자층. JMH 자체가 OpenJDK 팀이 쓰려고 만든 도구
범용 라이브러리 코드 개발구글 구아바·이클립스 컬렉션처럼 어떤 환경에서 쓰일지 알 수 없는 라이브러리는 성능을 일반적인 용량 테스트 기법으로 검증할 수밖에 없음
극도로 낮은 지연이 요구되는 코드초저지연 금융 트레이딩 등. 그 외에는 비교적 드묾

뒤의 두 경우는 JMH 기반 테스트를 CI/CD 파이프라인에 포함 하고, 성능 저하가 발생하면 빌드를 실패시키는 것이 합리적입니다. 자기 코드뿐 아니라 라이브러리 종속성의 변경까지 감지할 수 있기 때문입니다.

마이크로벤치마킹의 가치 기준

다음 기준을 대부분 또는 전부 충족하지 못한다면, 마이크로벤치마킹으로 실질적 이점을 얻기 어렵습니다.

  • 전체 코드 경로 실행 시간이 반드시 1ms 이하, 가능하면 100μs 이하
  • 메모리(객체) 할당률이 1MB/s 미만, 이상적으로는 거의 0
  • 사용 가능한 CPU를 거의 100% 활용하고, 시스템 활용률은 일관되게 10% 이하
  • 실행 프로파일러로 분석했을 때 주로 CPU를 소비하는 메서드가 최대 2~3개

이 조건들을 보면 마이크로벤치마킹은 고급 기법이지만 실제로는 거의 쓰이지 않는 기법임이 분명해집니다. 그럼에도 이 기법이 반영하는 기본 이론을 이해하면, 극단적이지 않은 애플리케이션에서 성능 최적화가 얼마나 어려운지 더 잘 이해할 수 있습니다.

A.2.3 JMH는 어떻게 함정을 우회하는가

벤치마크 프레임워크는 컴파일 타임에 벤치마크 내용을 미리 알 수 없으므로 동적으로 동작 해야 합니다. 가장 명확한 선택지는 리플렉션이지만, 리플렉션을 쓰면 또 다른 복잡한 JVM 하위 시스템이 측정 경로에 개입하게 됩니다.

대신 JMH는 주석 처리로 추가 자바 소스 코드를 생성 하는 방식으로 동작합니다.

JUnit과의 차이

JUnit 같은 일반적인 주석 기반 프레임워크는 리플렉션으로 동작하지만, JMH는 소스 코드를 생성하는 프로세서를 사용하기 때문에 일부 개발자에게 다소 낯설 수 있습니다.

또 하나의 핵심 문제는 사용자 코드를 다수 반복 호출할 때 루프 최적화(loop optimization) 가 발생할 수 있다는 점입니다. 이를 피하기 위해 JMH는 벤치마크 코드를 특정 반복 횟수로 감싼 루프 형태로 생성하되, 그 횟수를 최적화가 발생하지 않도록 신중히 설정 합니다.

OpenJDK

JMH는 자바와 자바 가상 머신을 대상으로 하는 기타 언어에서 나노·마이크로·밀리·매크로 벤치마크를 작성하고 실행하며 분석하기 위한 자바 기반 하네스입니다.

A.2.4 벤치마크 실행

메이븐 아키타입으로 새 JMH 프로젝트를 매우 직관적으로 설정할 수 있습니다.

$ mvn archetype:generate \
    -DinteractiveMode=false \
    -DarchetypeGroupId=org.openjdk.jmh \
    -DarchetypeArtifactId=jmh-java-benchmark-archetype \
    -DgroupId=org.sample \
    -DartifactId=test \
    -Dversion=1.0

생성된 스텁에는 @Benchmark 주석이 붙어 있고, 프레임워크가 설정 작업을 마친 뒤 이 메서드를 실행해 벤치마킹합니다.

public class MyBenchmark {
    @Benchmark
    public void testMethod() {
        // 코드용 기본 틀
    }
}

실행 매개변수는 명령줄에서 설정하거나 main() 메서드 안에서 설정할 수 있으며, 명령줄 설정이 main() 설정을 덮어씁니다.

@State: 상태 관리

@State 주석으로 벤치마크 상태를 정의하고, Scope 열거형으로 상태가 어디에서 공유되는지를 지정합니다.

Scope공유 범위
Benchmark모든 스레드가 하나의 상태 공유
Group같은 그룹 내 스레드끼리 공유
Thread각 스레드가 자신만의 상태를 가짐

@State 객체는 벤치마크 수명 주기 동안 계속 접근 가능합니다. 멀티스레드 코드 또한 신중히 다뤄야 하며, 그렇지 않으면 상태가 제대로 관리되지 않아 결과가 왜곡됩니다.

Blackhole: 데드 코드 제거를 막는 장치

부작용이 없고 반환값을 쓰지 않는 메서드는 JVM이 제거 후보로 간주합니다(함정 3). JMH는 이를 막기 위해, 벤치마크 메서드가 반환한 값을 암묵적으로 블랙홀(Blackhole)에 할당 합니다. 블랙홀은 JMH 개발자가 설계한 메커니즘으로 성능 오버헤드가 거의 없습니다.

여러 계산 결과를 결합해 반환하는 것이 비용이 클 때는 명시적 블랙홀 을 씁니다. 벤치마크 메서드의 매개변수로 블랙홀을 받도록 작성하면 실행 시 JMH가 이를 주입합니다.

블랙홀이 제공하는 네 가지 보호 기능입니다.

보호방지하는 최적화
데드 코드 제거 방지결과 미사용으로 인한 코드 삭제
상수 접힘(folding into constants) 방지반복 계산이 상수로 치환되는 것
거짓 공유(false sharing) 방지같은 캐시 라인을 읽고 쓸 때의 간섭
쓰기 장벽(write wall) 방어쓰기 버퍼 포화로 인한 캐시 오염

여기서 ‘장벽(wall)‘은 시스템 리소스가 포화되어 애플리케이션이 병목에 도달하는 지점을 뜻합니다. 쓰기 장벽에 도달하면 캐시에 영향을 미치고 쓰기 버퍼를 오염시켜, 벤치마크 결과에 큰 영향을 줄 수 있습니다.

consume() 들여다보기: 컴파일러를 속이는 기술

블랙홀이 이런 보호를 제공하려면 JIT 컴파일러에 대한 깊은 이해가 필요합니다. 기본형용 consume()은 다음과 같습니다.

public volatile int i1 = 1, i2 = 2;
 
public final void consume(int i) {
    if (i == i1 & i == i2) {
        // 절대 발생해서는 안됨
        nullBait.i1 = i; // 암시적 널 포인터 예외
    }
}

i1, i2volatile로 선언되어 런타임에서 반드시 재평가됩니다. if 문은 절대 true가 될 수 없지만(어떤 i가 1과 2에 동시에 같을 수는 없으므로), 컴파일러는 코드가 실행되도록 허용합니다. 또 if 내부에서 일반 &&가 아니라 비트 연산자 & 를 쓴 것에 주목해야 합니다. 이는 추가적인 분기(branch) 로직을 없애 더 균일한 성능을 유지하기 위한 조치입니다.

객체용 consume()은 한 단계 더 영리합니다.

public int tlr = (int) System.nanoTime();
 
public final void consume(Object obj) {
    int tlr = (this.tlr = (this.tlr * 1664525 + 1013904223));
    if ((tlr & tlrMask) == 0) {
        // 측정 중에는 거의 발생해서는 안되는 상황임
        this.obj1 = obj;
        this.tlrMask = (this.tlrMask << 1) + 1;
    }
}

객체에도 기본형과 같은 논리를 쓸 것 같지만, 컴파일러는 더 똑똑하게 처리합니다. 이스케이프 분석(escape analysis) 으로 객체가 다른 어떤 것과도 같을 수 없다고 판단하면, 비교 연산 자체가 최적화되어 항상 false를 반환할 수 있기 때문입니다.

그래서 객체는 특정 조건하에서만 소비되도록 처리합니다. tlr은 선형 합동 생성기로 매번 새 난수를 만들고 tlrMask와 비트 연산으로 비교되어, 0이 될 가능성을 줄이되 완전히 배제하지는 않습니다. 이렇게 하면 객체를 명시적으로 할당할 필요 없이도 소비 할 수 있어, 이스케이프 분석에 의한 제거를 피합니다.

@CompilerControl: 컴파일러 제어

@CompilerControl 주석을 쓰면 컴파일러가 특정 메서드를 인라인하지 않도록 하거나, 명시적으로 인라인하도록 요청하거나, 아예 컴파일 대상에서 제외할 수 있습니다. JVM의 인라이닝이나 컴파일 과정이 특정 성능 문제를 유발한다고 의심될 때 매우 유용합니다.

정렬 벤치마크 다시 쓰기

앞의 ClassicSort를 JMH로 다시 작성한 전체 예제입니다. 워밍업·측정 반복·포크·결과 반환(데드 코드 제거 방지)·프로파일러가 모두 선언적으로 반영되어 있습니다.

@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(1)
public class SortBenchmark {
 
    private static final int N = 1_000;
    private static final List<Integer> testData = new ArrayList<>();
 
    @Setup
    public static final void setup() {
        Random randomGenerator = new Random();
        for (int i = 0; i < N; i++) {
            testData.add(randomGenerator.nextInt(Integer.MAX_VALUE));
        }
        System.out.println("Setup Complete");
    }
 
    @Benchmark
    public List<Integer> classicSort() {
        List<Integer> copy = new ArrayList<Integer>(testData);
        Collections.sort(copy);
        return copy;
    }
 
    @Benchmark
    public List<Integer> standardSort() {
        return testData.stream().sorted().collect(Collectors.toList());
    }
 
    @Benchmark
    public List<Integer> parallelSort() {
        return testData.parallelStream().sorted().collect(Collectors.toList());
    }
 
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(SortBenchmark.class.getSimpleName())
                .warmupIterations(100)
                .measurementIterations(5).forks(1)
                .jvmArgs("-server", "-Xms2048m", "-Xmx2048m")
                .addProfiler(GCProfiler.class)
                .addProfiler(StackProfiler.class)
                .build();
        new Runner(opt).run();
    }
}

ClassicSort와 결정적으로 다른 점은 각 벤치마크 메서드가 결과를 return 한다는 것입니다. 이 반환값이 블랙홀에 흘러들어가 데드 코드 제거를 막습니다. 실행 결과는 다음과 같습니다.

Benchmark                            Mode   Cnt   Score        Error      Units
optjava.SortBenchmark.classicSort    thrpt  200   14373.039 ±  111.586    ops/s
optjava.SortBenchmark.parallelSort   thrpt  200    7917.702 ±   87.757    ops/s
optjava.SortBenchmark.standardSort   thrpt  200   12656.107 ±   84.849    ops/s

”낮은 오차율 = 신뢰할 수 있는 결과”라는 착각

이 결과만 보면 고전적 정렬(classicSort)이 스트림보다 효율적이라는 결론에 쉽게 도달합니다. 세 코드 모두 배열 복사 한 번과 정렬 한 번을 수행하므로 비교에 문제가 없어 보이고, 낮은 오차율과 높은 처리량 까지 보면 더욱 그렇게 믿게 됩니다.

그러나 핵심 질문은 “이 테스트는 제대로 통제된 실험인가?” 입니다. classicSort의 반복별 GC 스냅샷을 보면 단서가 나옵니다.

Iteration   1:
[GC (Allocation Failure)  65496K->1480K(239104K), 0.0012473 secs]
[GC (Allocation Failure)  63944K->1496K(237056K), 0.0013170 secs]
10830.105 ops/s
Iteration   2:
[GC (Allocation Failure)  62936K->1680K(236032K), 0.0004776 secs]
10951.704 ops/s

반복당 약 한 번의 GC 사이클이 실행되고 있습니다. 이를 parallelSort와 비교하면 흥미로운 차이가 보입니다.

Iteration   1:
[GC (Allocation Failure)  52952K->1848K(225792K), 0.0005354 secs]
[GC (Allocation Failure)  52024K->1848K(226816K), 0.0005341 secs]
[GC (Allocation Failure)  51000K->1784K(223744K), 0.0005509 secs]
[GC (Allocation Failure)  49912K->1784K(225280K), 0.0003952 secs]
9526.212 ops/s
Iteration   2:
[GC (Allocation Failure)  49400K->1912K(222720K), 0.0005589 secs]
...

병렬 정렬은 반복당 GC가 여러 번 발생합니다. 즉 두 벤치마크의 처리량 차이는 정렬 알고리즘 자체의 우열이 아니라, 벤치마크 내 다른 요소(이번 경우 가비지 컬렉션)가 만들어낸 노이즈 였습니다. JMH라는 정교한 하네스를 썼는데도 통제되지 않은 변수가 끼어든 것입니다.

비교 / 트레이드오프

ClassicSort(직접 작성) vs SortBenchmark(JMH)
항목직접 작성JMH
워밍업없음 (인터프리터 코드 측정)@Warmup으로 선언
데드 코드 제거copy 미사용 → 제거 위험결과 return → 블랙홀이 방어
측정 반복단일 측정, 오차 미산출@Measurement·포크로 분포·오차 산출
GC 영향측정값에 섞여 보이지 않음GCProfiler로 분리 관찰
루프 최적화무방비반복 횟수를 조정해 회피

JMH가 자동으로 “올바른 답”을 주는 것은 아닙니다. JMH는 알려진 함정들을 제거 할 뿐이고, GC처럼 남아 있는 통제되지 않은 변수는 여전히 사람이 GCProfiler 같은 도구로 발견하고 해석해야 합니다.

마이크로벤치마킹 vs 프로파일링

마이크로벤치마킹은 “이 코드 조각이 얼마나 빠른가”를 묻고, 프로파일링은 “전체 실행에서 어디가 느린가”를 묻습니다. A.2.1의 일화가 보여주듯, 성능 문제의 원인을 찾는 출발점으로는 거의 항상 프로파일링이 먼저입니다. 마이크로벤치마킹은 원인이 특정 코드로 좁혀진 뒤, 그것도 위의 가치 기준을 충족할 때만 동원하는 마지막 수단에 가깝습니다.

내 생각

”측정했으니 맞다”는 가장 흔한 함정

백엔드 개발을 하다 보면 “직접 재봤더니 A가 B보다 빠르더라”는 주장이 의외로 자주 등장합니다. 이 부록의 핵심은 그 주장 대부분이 무엇을 측정했는지 모르는 숫자 라는 점입니다. classicSortstandardSort보다 빠르다는 깔끔한 결과조차 알고 보면 GC 노이즈였습니다. 측정 도구가 정교해질수록 결과를 더 신뢰하게 되는데, 정작 통제되지 않은 변수는 그 신뢰 뒤에 더 잘 숨습니다.

대부분의 백엔드 작업에서는 안 쓰는 게 맞다

가치 기준(1ms 이하, 할당률 1MB/s 미만, 핫 메서드 2~3개)을 보면, 일반적인 웹 API·배치·메시지 컨슈머는 단 하나도 충족하지 못합니다. JPA·네트워크·DB I/O가 지배하는 코드에서 나노초 단위 메서드 벤치마킹은 의미가 없습니다. 그 시간에 부하 테스트와 프로파일링으로 전체 시스템을 보는 편이 답에 훨씬 빨리 도달합니다.

CI에 거는 건 라이브러리 개발일 때

다만 라이브러리를 만들거나 공통 모듈을 여러 서비스에 배포한다면 이야기가 다릅니다. JMH 벤치마크를 CI/CD에 걸어 성능 회귀를 빌드 실패로 잡는 패턴은, 내 코드뿐 아니라 의존성 업그레이드가 몰래 끌고 들어오는 성능 저하까지 막아줍니다. 이건 성능 저하 테스트를 메서드 수준으로 내린 버전이라고 볼 수 있습니다.

그래도 한 번은 해볼 가치

부록이 마지막에 짚듯, 마이크로벤치마킹의 진짜 효용은 숫자보다 JVM의 동적 동작을 몸으로 겪는 경험 에 있습니다. 워밍업 전후로 처리량이 달라지고, 데드 코드가 사라지고, GC가 끼어드는 것을 직접 보고 나면 자바 가상 머신에 대한 정신적 모델이 훨씬 정교해집니다. 실무에서 안 쓰더라도 학습 목적으로는 한 번 제대로 해볼 가치가 있습니다.

신중한 엔지니어의 태도
  • 꼭 필요한 경우가 아니라면 수행하지 않는다.
  • 필요하다면 직접 짜지 말고 JMH를 쓴다.
  • 가능한 한 결과를 동료와 공개적으로 논의한다.
  • 자신의 분석이 틀릴 가능성을 항상 염두에 둔다.

관련 개념

출처

『자바 최적화 2판』 부록 A 「마이크로벤치마킹」