Bldev's Blog

[자바/테스트] JMH와 Testcontainers를 활용한 인프라 통합 벤치마킹

2026. 10. 5.

JVM의 특성

자바나 코틀린 같은 JVM 기반 언어에서 특정 코드의 실행 속도를 측정하는 일은 C나 Go 같은 정적 컴파일 언어보다 훨씬 까다롭다. 보통 애플리케이션의 성능 측정을 위해 흔히 떠올리는 방법은 측정하려는 메서드를 반복문으로 100만 번 돌리기 전후에 System.currentTimeMillis()나 System.nanoTime()을 로그에 출력하여 그 차이를 구하는 것이다. 하지만 이렇게 작성한 코드는 십중팔구 완전히 잘못된 결과를 보여준다. 자바 가상 머신(JVM)이 백그라운드에서 코드를 계속 관찰하면서 사람이 예상치 못한 수준으로 코드를 변형하고 최적화하는 특성 때문이다.

가장 대표적인 현상은 데드 코드 제거(dead code elimination)이다. 개발자가 100만 번의 복잡한 연산을 수행하도록 코드를 작성했더라도 그 결과를 화면에 출력하거나 어딘가에 저장하지 않는다면 JIT 컴파일러는 이 연산이 프로그램 전체에 아무런 영향을 미치지 않는 무의미한 작업이라고 판단하고 해당 루프 전체를 어셈블리 수준에서 완전히 삭제하여 100만 번 도는 데 걸린 시간이 0밀리초로 측정되는 상황이 발생하게 된다.

이 현상은 간단한 코드로 직접 확인할 수 있다. 다음은 해시 계산 같은 순수 연산을 100만 번 반복하되 결과를 버리는 루프와 sum에 누적해서 사용하는 루프를 같은 JVM에서 8라운드 반복 측정한 코드이다.

public class Dce {
    static int mix(int x) {                  // 부수효과가 없는 순수 연산
        int h = x * 0x9E3779B1;
        h ^= h >>> 15;
        h *= 0x85EBCA6B;
        h ^= h >>> 13;
        return h;
    }

    static final int N = 1_000_000;

    static void discard() {                  // 결과를 버린다
        for (int i = 0; i < N; i++) mix(i);
    }

    static int keep() {                      // 결과를 누적해서 반환한다
        int s = 0;
        for (int i = 0; i < N; i++) s += mix(i);
        return s;
    }

    static long sink;

    public static void main(String[] args) {
        for (int round = 1; round <= 8; round++) {
            long t0 = System.nanoTime();
            discard();
            long t1 = System.nanoTime();
            sink += keep();                  // 반환값을 필드에 저장해 keep() 자체의 삭제를 막는다
            long t2 = System.nanoTime();
            System.out.printf("round %d | 결과 버림: %,d ns | 결과 사용: %,d ns%n",
                    round, t1 - t0, t2 - t1);
        }
    }
}

OpenJDK 17.0.18에서 기본 옵션(C2 활성)으로 실행한 결과는 다음과 같다.

라운드 결과 버림 결과 사용
1 2,065,417 ns 3,285,708 ns
2 591,375 ns 1,755,375 ns
3 208 ns 544,500 ns
4 167 ns 477,292 ns
5 125 ns 426,958 ns
6 42 ns 460,583 ns
7 83 ns 444,125 ns
8 41 ns 430,459 ns

라운드 1~2에서는 결과를 버려도 0.5~2 ms가 걸린다. 아직 JIT 컴파일이 끝나지 않아 루프가 실제로 실행되기 때문이다. 라운드 3부터 C2 컴파일러가 개입하면서 결과를 버리는 쪽은 수십~수백 ns로 떨어진다. 100만 번 호출을 이 시간에 끝내려면 호출당 0.001 ns 미만이어야 하므로 하드웨어 한계를 넘는다. 루프가 통째로 사라졌다는 뜻이다. System.currentTimeMillis()로 재면 0 ms가 찍힌다. 결과를 사용하는 쪽은 라운드 5 이후 약 0.43~0.46 ms로 일정한 것을 확인할 수 있다.

같은 코드를 -Xint(JIT 끔)로 실행하면 두 경우 모두 약 30 ms로 같다. 차이를 만든 것은 코드가 아니라 JIT 컴파일러이다.

JVM을 새로 띄워 같은 루프를 1회만 재는 경우에는 반복 횟수에 따라 결과가 달라진다. 위 코드에서 mix와 루프를 그대로 두고 라운드 반복만 없앤 변형을 JVM마다 3회씩 실행한 범위이다.

반복 횟수 결과 버림 결과 사용
100만 1.8~2.0 ms 2.7~3.0 ms
1억 1.8~2.1 ms 43.7~50.0 ms

100만 번 1회 측정에서는 인터프리터 실행과 컴파일 시간이 지배적이라 두 경우 모두 수 ms로 같은 자릿수이다. 0 ms에 가까운 값은 JIT 컴파일이 끝난 뒤에 실행하거나 반복 횟수가 큰 경우에 나온다. 어느 쪽이든 측정값이 루프의 실제 비용과 다르다는 점은 같다.

또다른 JVM의 특성은 코드가 실행되는 도중에 최적화 전략을 변경하는 것이다. 처음 실행될 때는 인터프리터 방식으로 한 줄씩 느리게 읽다가 호출 횟수가 임계치를 넘으면 C1 컴파일러가 개입한다. 더 자주 호출될 경우 C2 컴파일러가 기계어로 한층 고도화한다. 메서드 호출 자체를 없애고 내용을 펼쳐버리는 인라이닝(inlining)은 C1부터 적용된다. 여기에 가비지 컬렉터(GC)가 중간에 힙 메모리를 정리하기 위해 스레드를 멈추는 일까지 발생하게 된다면 측정 시점마다 숫자가 들쭉날쭉하게 튀게 되는 현상이 발생하는 것이다.

이 세 가지도 코드를 통해 직접 확인할 수 있다. 먼저 컴파일 단계에 따라 속도가 어떻게 바뀌는지 확인해보자. 다음은 work()를 2만 번씩 14배치 호출하면서 배치마다 호출당 시간을 출력하는 코드이다.

public class Tier {
    static int add(int a, int b) { return a + (b ^ (a >>> 3)); }

    static int work(int x) {                 // add()를 16번 호출한다
        int h = x;
        for (int i = 0; i < 16; i++) h = add(h, i * 31);
        return h;
    }

    static int sink;

    public static void main(String[] args) {
        int batches = 14, per = 20_000;
        for (int b = 1; b <= batches; b++) {
            long t0 = System.nanoTime();
            int s = 0;
            for (int i = 0; i < per; i++) s += work(i);
            long t1 = System.nanoTime();
            sink += s;                       // 결과를 사용해 DCE를 막는다
            System.out.printf("batch %2d: %.1f ns/call%n", b, (t1 - t0) / (double) per);
        }
    }
}

JIT 설정만 바꿔 각각 1회 실행한 결과는 다음과 같다. 단위는 ns/call이다.

배치 -Xint (인터프리터만) C1만 (-XX:TieredStopAtLevel=1) 기본 옵션
1 434.2 27.9 56.1
2 444.8 22.7 17.6
4 431.0 19.5 17.3
5 431.3 8.4 6.3
6 429.2 8.3 6.0
14 424.0 8.0 5.1

-Xint는 14배치 내내 약 430 ns로 변하지 않는다. 기본 옵션은 첫 배치에 44~56 ns에서 시작해 17 ns 안팎을 거쳐 7~8배치부터 5 ns대로 내려가고 마지막 배치 기준으로 인터프리터와 약 80배 차이가 나는 것을 확인할 수 있다. 같은 코드를 세 번 실행해도 이 단계 패턴은 같았지만 단계가 바뀌는 배치는 실행마다 조금씩 달랐다. -XX:+PrintCompilation으로 보면 add()와 work()가 먼저 레벨 3(C1)으로, 이어서 레벨 4(C2)로 컴파일되고 레벨 3 버전은 made not entrant로 폐기된다.

18    5       3       Tier::add (8 bytes)
18    6       3       Tier::work (27 bytes)
18    7       4       Tier::add (8 bytes)
18    5       3       Tier::add (8 bytes)   made not entrant
19    8       4       Tier::work (27 bytes)
19    6       3       Tier::work (27 bytes)   made not entrant

같은 코드로 인라이닝의 효과도 측정할 수 있다. -XX:-Inline으로 인라이닝을 모두 끄거나 -XX:CompileCommand=dontinline,Tier::add로 add()만 막고 마지막 배치를 9회씩 측정한 범위이다.

조건 마지막 배치
기본 5.1~6.3
-XX:-Inline 26.6~28.3
add() 인라이닝 금지 37.9~43.5
C1만 8.0~8.3
C1만 + add() 인라이닝 금지 24.5~25.5

add() 하나만 막아도 5배 넘게 느려지게 된다. C1만 켠 상태에서도 인라이닝을 막으면 8 ns에서 25 ns로 느려지므로 인라이닝은 C2만의 기능이 아니다. 다만 add()만 막은 쪽이 -XX:-Inline보다 더 느린 이유는 확인하지 못했다.

마지막으로 GC 정지가 측정값에 미치는 영향이다. 호출 하나하나의 시간을 재서 분포를 보는 코드이며 JIT의 영향을 빼려고 10만 회 워밍업을 먼저 수행하였다.

import java.lang.management.*;
import java.util.*;

public class Gc {
    static int[][] ring = new int[2000][];
    static int[] fixed = new int[1000];
    static long sink;

    static int op(boolean alloc, int i) {
        int[] a = alloc ? new int[1000] : fixed;   // 할당 O / X
        for (int k = 0; k < a.length; k++) a[k] = k ^ i;
        if (alloc) ring[i % ring.length] = a;      // 일부를 살려 둔다 (GC가 복사할 대상)
        return a[i % a.length];
    }

    static long gcCount() {
        long c = 0;
        for (GarbageCollectorMXBean g : ManagementFactory.getGarbageCollectorMXBeans()) c += g.getCollectionCount();
        return c;
    }

    public static void main(String[] args) {
        boolean alloc = args[0].equals("alloc");
        int n = 200_000;
        for (int i = 0; i < 100_000; i++) sink += op(alloc, i);   // 워밍업: JIT 영향을 먼저 뺀다

        long gc0 = gcCount();
        long[] lat = new long[n];
        for (int i = 0; i < n; i++) {
            long t = System.nanoTime();
            sink += op(alloc, i);
            lat[i] = System.nanoTime() - t;
        }
        long gcs = gcCount() - gc0;

        int spikes = 0;
        for (long l : lat) if (l > 300_000) spikes++;              // 300 µs를 넘긴 호출
        long[] s = lat.clone();
        Arrays.sort(s);
        System.out.printf("p50=%,d ns  max=%,d ns  300µs 초과 %d건  GC %d회%n", s[n / 2], s[n - 1], spikes, gcs);
    }
}

alloc은 호출마다 4 KB 배열을 만들고, noalloc은 미리 만든 배열을 재사용한다. -XX:+UseSerialGC -Xms64m -Xmx64m로 GC가 잘 드러나게 힙을 작게 잡은 경우와, -Xms6g -Xmx6g -Xmn5g -XX:+AlwaysPreTouch로 측정 중 GC가 일어나지 않게 한 경우를 비교했다.

조건 p50 GC 횟수 300 µs 초과 호출 max
alloc, 힙 64 MB 292 ns 53회 46건 1.9~4.2 ms
alloc, young 5 GB (GC 없음) 292~333 ns 0회 0~8건 0.2~1.7 ms
noalloc 208 ns 0회 0~4건 17 µs~1.2 ms

최댓값만 보면 GC를 원인으로 단정하기 어렵다. GC가 없어도 0.2~1.7 ms가 나오고 실행마다 크게 달라지기 때문이다. 할당이 없는 실행에서도 1 ms대가 한 번 나왔다. GC의 흔적은 오래 걸린 호출의 건수와 주기에 있다. 힙이 작으면 300 µs를 넘긴 호출이 46건으로, 3회 실행 모두 같은 호출 번호(4825, 9231, 13634 ...)에서 약 4,400회 간격으로 나왔다. 4,400회 × 4 KB는 약 17 MB로 GC 로그의 Pause Young (Allocation Failure) 17M->8M과 맞고, 이때 정지는 처음 4회 기준 0.8~1.5 ms였다. 46건은 20만 호출의 0.023%라서 p50은 물론 p99.9에도 드러나지 않는다. 평균이나 백분위수만 보면 놓치고 지나가는 지연이다. 이 수치는 Serial GC와 작은 힙으로 GC를 일부러 드러낸 조건이며, 기본 GC(G1)에서는 달라질 수 있다.

JMH

바로 이 JVM을 정밀하게 통제하기 위한 도구가 있다. 바로 오라클과 OpenJDK 팀이 만든 공식 도구인 JMH(Java Microbenchmark Harness)이다. 이름에 붙은 하네스(harness)라는 단어는 원래 마차를 끌 때 말을 묶어두는 마구(고삐와 안장)를 뜻한다. 즉, JVM이라는 야생마가 멋대로 코드를 지우거나 측정 환경을 오염시키지 못하도록 고삐를 쥐고 정확한 실험 환경을 조성해 주는 도구라는 뜻이다. 자바를 비롯한 JVM 언어로 작성된 애플리케이션의 성능을 정량적으로 측정할 때 가장 널리 사용되는 이 도구는 JIT 컴파일러의 최적화, CPU 캐시 라인, 가비지 컬렉션(GC) 등 JVM 내부의 런타임 특성을 세밀하게 통제하며 통계적으로 신뢰할 수 있는 수치를 산출한다.

JMH가 제공하는 해결책은 매우 정교하다. 첫째로 블랙홀(Blackhole)이라는 특수 객체를 제공하여, 연산 결과를 여기에 넘겨주면 CPU 레지스터와 메모리에서 강제로 값을 소비시킴으로써 컴파일러가 코드를 지우지 못하게 막는다.

둘째로 포크(fork) 기능을 통해 완전히 새로운 깨끗한 JVM 프로세스를 실행한다. 이전 테스트에서 컴파일러가 축적한 프로파일링 정보나 메모리 파편화가 다음 측정에 영향을 주지 못하도록 완벽하게 격리하는 것이다.

셋째로 웜업(warmup) 단계를 강제하여 컴파일러가 기계어 최적화를 끝내고 안정적인 상태에 도달할 때까지 충분히 예열한 뒤에야 비로소 실제 측정을 시작한다. 그 결과 단순 평균값 하나만 전달하는 것이 아니라 수십 번의 반복 측정을 통해 표준편차와 99.9 퍼센트 신뢰구간 같은 통계적으로 믿을 수 있는 성능 지표를 산출해 준다.

결국 JMH의 존재 이유는 아주 짧은 나노초 단위의 알고리즘부터 수 밀리초가 걸리는 인프라 로직까지, 컴퓨터 내부의 착시 현상에 속지 않고 코드 본연의 순수한 실행 비용을 측정할 수 있도록 돕는 데 있다.

그러나 백엔드 시스템의 실질적인 성능 병목은 순수 CPU 연산보다는 데이터베이스 조회, 캐시 접근, 외부 메시지 큐 발행과 같은 I/O 지점에서 주로 발생한다. 개발 과정에서 이러한 I/O 성능을 측정하려 할 때 H2나 내장 Redis 같은 인메모리 에뮬레이터를 사용하면 다음과 같은 심각한 왜곡이 발생한다.

구분 인메모리 Mock/H2 실제 프로덕션 DB (PostgreSQL, MySQL 등)
옵티마이저 단순 룰 기반 또는 제한적 쿼리 플래너 통계 기반 비용 최적화(CBO), 복잡한 인덱스 스캔
동시성 제어 단순 테이블/메모리 락 MVCC(다중 버전 동시성 제어), 행 단위 격리 수준
I/O 및 저장소 힙 메모리 직접 참조 (I/O 비용 0) WAL(Write-Ahead Logging), 버퍼 풀(Buffer Pool), fsync
네트워크 스택 프로세스 내 직접 호출 TCP/IP 소켓 통신, 와이어 프로토콜 직렬화/역직렬화

이러한 간극을 없애기 위해 Testcontainers를 결합할 수 있다. Testcontainers를 사용하면 도커(Docker) 기반의 실제 데이터베이스 엔진을 프로그래밍 방식으로 제어하여, 로컬 환경에서도 프로덕션과 동일한 아키텍처 조건에서 JMH 벤치마크를 수행할 수 있다.

JMH 핵심 동작 원리

JMH는 JVM의 최적화 기법으로 인해 벤치마크 결과가 왜곡되는 현상을 차단하도록 설계되었다.

단계 및 대상 JVM JIT 최적화 위험 JMH 대응 메커니즘 세부 역할 및 동작 원리
결과값 소비 Dead Code Elimination (DCE) Blackhole.consume(result) 연산 결과를 휘발성 메모리 또는 레지스터에 강제로 전달하여 컴파일러가 코드를 삭제하지 못하도록 부수 효과 보장
계층 컴파일 안정화 Constant Folding 및 Inlining @Warmup 및 @Measurement C1 및 C2 컴파일러 계층 최적화가 끝날 때까지 웜업을 선행하고 기계어 최적화가 안착된 정상 상태에서 측정 반복 수행
런타임 환경 격리 Loop Unrolling 및 OSR, GC 오염 @Fork(value = n) 완전히 독립된 별도 JVM 프로세스를 구동하여 직전 벤치마크의 JIT 프로파일링 통계 피드백 및 힙 오염 전이 방지

1. 주요 벤치마크 모드 (@BenchmarkMode)

벤치마크의 목적에 따라 성능을 측정하는 지표의 기준 축이 달라진다. 기본 설정인 Mode.Throughput은 초당 작업 처리 횟수(ops/time)를 측정하며 대용량 트래픽 처리 능력을 검증할 때 사용한다. 반면 단일 쿼리나 트랜잭션의 지연시간을 평가할 때는 1회 수행에 소요되는 평균 시간(time/op)을 측정하는 Mode.AverageTime이 적합하다.

평균값만으로는 드러나지 않는 지연시간의 꼬리(Tail Latency)를 파악하려면 Mode.SampleTime을 채택하여 각 호출 시간을 샘플링하고 p50, p90, p99, p99.9 등의 백분위수를 도출해야 한다. 마지막으로 JIT 컴파일러의 웜업 과정을 의도적으로 배제하고 시스템의 콜드 스타트(Cold-Start) 비용이나 최초 1회성 초기화 오버헤드를 확인할 때는 Mode.SingleShotTime을 활용한다.

2. 상태 스코프와 생명주기 (@State, @Setup, @TearDown)

벤치마크 메서드가 사용하는 데이터와 자원은 @State 어노테이션을 통해 관리된다. 여러 작업 스레드가 동일한 인스턴스를 공유해야 하는 데이터베이스 커넥션 풀이나 공유 캐시는 Scope.Benchmark로 지정하며, 스레드 간 격리가 필요한 자원은 Scope.Thread를 통해 각 스레드 전용 인스턴스를 유지하도록 설정한다.

JMH 공식 샘플(JMHSample_03_States)에서 권장하는 가장 관용적인 설계 방식은 벤치마크 실행 클래스와 상태 홀더 클래스를 분리하는 것이다. 자원의 수명주기를 전담하는 정적 중첩 클래스(예: public static class DbState)를 정의하고 이를 벤치마크 메서드의 인자로 주입받으면, 벤치마크 로직과 인프라 설정의 책임이 깔끔하게 나뉜다.

상태의 초기화와 정리는 실행 주기에 따라 세분화된다. 무거운 도커 컨테이너 구동이나 커넥션 풀 생성처럼 전체 벤치마크 실행 전후에 한 번만 수행해야 하는 작업은 Level.Trial에 배치한다. 각 측정 반복(Iteration) 사이에 테이블 데이터를 비우거나 캐시를 비워야 하는 경우에는 Level.Iteration을 사용한다. 반면 벤치마크 메서드가 호출될 때마다 실행되는 Level.Invocation은 그 자체로 나노초 단위의 측정 오버헤드를 루프에 유입시키므로, OpenJDK 공식 가이드에서도 특수한 경우가 아니라면 사용을 엄격히 피하도록 경고하고 있다.


Testcontainers 아키텍처와 라이프사이클

Testcontainers는 Docker API를 감싸 자바 코드 내에서 컨테이너 수명주기를 제어하는 라이브러리이다.

파이프라인 단계 구성 요소 통신 및 제어 경로 세부 역할 및 동작 원리
1. 데몬 소켓 연동 Docker Engine API /var/run/docker.sock IPC 통신 JMH 실행 프로세스에서 도커 소켓을 직접 제어하여 컨테이너 생성 및 생명주기 관리
2. 고아 자원 방어 Ryuk Resource Reaper 사이드카 컨테이너 TCP 통신 호스트 JVM 종료 신호나 OOM/SIGKILL 발생 시 즉각 감지하여 잔여 컨테이너와 볼륨을 강제 회수
3. 프로덕션 환경 격리 Target Container 격리 컨테이너 샌드박스 실제 PostgreSQL 엔진을 구동하여 MVCC, 버퍼 풀, WAL fsync 등 실제 RDBMS 엔진 동작을 모사
4. 포트 충돌 방지 Dynamic Ephemeral Port 호스트 무작위 포트 바인딩 호스트 머신의 기존 5432 포트 충돌을 회피하고 getMappedPort()를 통해 동적 접속 엔드포인트 제공
5. 준비 상태 검증 WaitStrategy 내부 상태 폴링 및 로그 검사 단순 컨테이너 기동을 넘어 실제 DB 엔진이 접속을 수용할 준비가 완료되었는지 감지 후 벤치마크 개시

컨테이너 환경에서 안정적인 벤치마크를 수행하기 위해서는 리소스 누수 방지와 네트워크 매핑 메커니즘을 정확히 이해해야 한다. Testcontainers는 구동 시 testcontainers/ryuk이라는 전용 사이드카 컨테이너를 함께 띄운다. 만약 벤치마크를 수행하던 JVM 프로세스가 메모리 부족(OOM)이나 강제 종료 신호(SIGKILL)로 비정상 종료되더라도, 도커 소켓 연결이 끊어지는 순간 Ryuk 프로세스가 남겨진 컨테이너와 네트워크 브리지, 임시 볼륨을 즉시 강제 회수하여 로컬 호스트의 자원 고갈을 방지한다.

호스트 머신과의 네트워크 연결에서는 동적 포트 매핑(Dynamic Port Allocation) 전략이 사용된다. 로컬 환경에 이미 구동 중인 다른 데이터베이스 프로세스와의 포트 충돌을 방지하기 위해, 컨테이너 내부의 표준 포트(PostgreSQL의 경우 5432)는 호스트 머신의 임의 에페머럴 포트에 무작위로 바인딩된다. 따라서 벤치마크 코드는 고정 포트를 가정하지 않고 container.getJdbcUrl()이나 container.getMappedPort(5432) 메서드를 통해 동적으로 할당된 주소를 주입받아야 한다. 아울러 컨테이너 프로세스가 시작되었다고 해서 곧바로 쿼리를 처리할 수 있는 것은 아니므로, Testcontainers의 대기 전략(WaitStrategy)이 내부 프로세스의 정상 기동 로그("ready to accept connections") 및 포트 리스닝 상태를 감지하여 완전히 준비될 때까지 대기한 후 벤치마크 단계로 진입한다.


JMH와 Testcontainers 결합 시 발생하는 기술적 충돌 및 해결책

두 도구를 결합할 때 발생하는 가장 큰 문제는 생명주기 충돌과 I/O 노이즈이다.

1. JVM Fork와 컨테이너 재시작 오버헤드 충돌

JMH의 핵심 원칙 중 하나는 @Fork를 통해 별도의 JVM을 띄워 이전 실행의 프로파일링 정보(JIT Type Feedback)나 GC 힙 오염을 방지하는 것이다. 그러나 단순한 구조에서는 포크가 발생할 때마다 @Setup(Level.Trial)이 다시 실행되어 도커 컨테이너가 내려갔다 다시 뜨는 데 매번 5~15초 이상의 지연이 발생한다.

이를 방지하기 위해 Testcontainers의 Reusable Containers 기능을 적용한다. 먼저 로컬 개발 머신의 ~/.testcontainers.properties 파일에 testcontainers.reuse.enable=true 속성을 선언하여 재사용 동작을 전역적으로 활성화한다. 그런 다음 자바 코드 상에서 컨테이너 인스턴스를 생성할 때 .withReuse(true) 체이닝 메서드를 선언한다. 이렇게 구성하면 동일한 도커 이미지와 환경변수 구성을 가진 컨테이너가 이미 로컬 도커 데몬에서 실행 중일 경우 이를 강제로 종료하거나 새로 띄우지 않고 기존 컨테이너에 즉시 연결되므로, JVM 포크 사이사이에 발생하는 수십 초의 컨테이너 기동 대기 시간을 완전히 제거할 수 있다.

2. JIT 컴파일러 웜업 vs DB 버퍼 풀/커넥션 풀 웜업

JMH의 기본 웜업 단계(@Warmup)는 자바 바이트코드가 C1 및 C2 컴파일러에 의해 기계어로 변환되고 메서드가 인라이닝되는 과정을 위해 존재한다. 하지만 데이터베이스 통합 벤치마크에서는 JVM 내부의 코드 최적화뿐 아니라 외부 인프라 레벨의 예열 작업이 반드시 병행되어야 한다.

먼저 HikariCP 커넥션 풀의 웜업이 필수적이다. 애플리케이션 시작 직후 첫 번째 쿼리가 들어오는 시점에 물리적인 TCP 3-Way Handshake와 TLS 협상, 데이터베이스 인증 과정을 밟게 되면 초기 지연시간에 거대한 노이즈가 유입된다. 따라서 풀 설정 시 minimumIdle과 maximumPoolSize를 동일한 크기로 지정하여 풀을 사전에 고정시키고, 준비 단계에서 유효성 검사 쿼리를 수행하여 물리 커넥션들을 활성화해 두어야 한다.

이와 함께 데이터베이스 엔진의 버퍼 풀(Buffer Pool / Shared Buffers) 웜업도 다루어야 한다. 대상 데이터가 디스크에서 메모리로 적재되지 않은 콜드 캐시(Cold Cache) 상태에서는 무거운 디스크 I/O가 개입되어 실제 쿼리 실행 계획의 연산 능력이 가려진다. 따라서 순수한 쿼리 처리량과 인덱스 탐색 비용을 분리해 측정하고자 한다면, 본 측정에 돌입하기 전 대상 테이블을 풀 스캔하거나 주요 쿼리를 사전에 호출하여 관련 페이지를 메모리 버퍼에 예열해 두는 과정이 요구된다.

3. 결과 소비(DCE 방지)

데이터베이스에서 반환된 ResultSet이나 엔티티 객체를 메서드 내에서 변수에 할당만 하고 리턴하지 않으면, C2 컴파일러가 해당 코드가 부수 효과(Side-effect)가 없다고 판단하여 루프 전체를 최적화 과정에서 제거할 위험이 있다. 반드시 JMH의 Blackhole.consume()에 결과를 넘겨주어야 한다.


실전 구현: PostgreSQL 조회 및 배치 삽입 벤치마크

아래 예제는 PostgreSQL 컨테이너를 구동하고, HikariCP 커넥션 풀을 통해 단건 단일 조회, 건별 단일 삽입, JDBC 배치(Batch) 삽입의 성능 차이를 측정하는 완전한 코드이다.

1. 의존성 구성 (pom.xml)

<dependencies>
    <!-- JMH Core & Annotation Processor -->
    <dependency>
        <groupId>org.openjdk.jmh</groupId>
        <artifactId>jmh-core</artifactId>
        <version>1.37</version>
    </dependency>
    <dependency>
        <groupId>org.openjdk.jmh</groupId>
        <artifactId>jmh-generator-annprocess</artifactId>
        <version>1.37</version>
        <scope>provided</scope>
    </dependency>

    <!-- Testcontainers PostgreSQL -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <version>1.20.4</version>
    </dependency>

    <!-- HikariCP & PostgreSQL JDBC Driver -->
    <dependency>
        <groupId>com.zaxxer</groupId>
        <artifactId>HikariCP</artifactId>
        <version>5.1.0</version>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <version>42.7.3</version>
    </dependency>
</dependencies>

2. 벤치마크 소스 코드 (DatabaseBenchmark.java)

package com.example.benchmark;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import org.testcontainers.containers.PostgreSQLContainer;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
@Fork(value = 1)
public class DatabaseBenchmark {

    /**
     * JMH 공식 권장 패턴: 벤치마크 클래스 자체에 @State를 부여하기보다
     * 자원 관리와 수명주기를 전담하는 정적 중첩 클래스(DbState)로 분리한다.
     */
    @State(Scope.Benchmark)
    public static class DbState {
        private PostgreSQLContainer<?> postgres;
        private HikariDataSource dataSource;

        @Setup(Level.Trial)
        public void setupTrial() throws SQLException {
            // 1. PostgreSQL 컨테이너 기동 (재사용 옵션 활성화)
            postgres = new PostgreSQLContainer<>("postgres:16-alpine")
                    .withDatabaseName("benchdb")
                    .withUsername("benchuser")
                    .withPassword("benchpass")
                    .withReuse(true);
            postgres.start();

            // 2. HikariCP 커넥션 풀 구성
            HikariConfig config = new HikariConfig();
            config.setJdbcUrl(postgres.getJdbcUrl());
            config.setUsername(postgres.getUsername());
            config.setPassword(postgres.getPassword());
            config.setMaximumPoolSize(10);
            config.setMinimumIdle(10); // 풀 사전 예열
            config.setConnectionTimeout(30000);

            dataSource = new HikariDataSource(config);

            // 3. 테스트 스키마 생성 및 초기 시드 데이터 적재
            try (Connection conn = dataSource.getConnection();
                 var stmt = conn.createStatement()) {
                stmt.execute("CREATE TABLE IF NOT EXISTS orders (" +
                        "id BIGSERIAL PRIMARY KEY, " +
                        "order_no VARCHAR(64) NOT NULL, " +
                        "amount NUMERIC(12, 2) NOT NULL)");
                stmt.execute("CREATE INDEX IF NOT EXISTS idx_orders_no ON orders(order_no)");

                conn.setAutoCommit(false);
                try (var ps = conn.prepareStatement("INSERT INTO orders (order_no, amount) VALUES (?, ?)")) {
                    for (int i = 1; i <= 1000; i++) {
                        ps.setString(1, "ORD-" + i);
                        ps.setDouble(2, i * 10.5);
                        ps.addBatch();
                    }
                    ps.executeBatch();
                }
                conn.commit();
            }

            // 4. 버퍼 풀(Buffer Pool) 웜업 쿼리 실행
            try (Connection conn = dataSource.getConnection();
                 var ps = conn.prepareStatement("SELECT COUNT(*) FROM orders")) {
                ps.executeQuery();
            }
        }

        @TearDown(Level.Trial)
        public void tearDownTrial() {
            if (dataSource != null) {
                dataSource.close();
            }
            if (postgres != null) {
                postgres.stop();
            }
        }

        public HikariDataSource getDataSource() {
            return dataSource;
        }
    }

    @Benchmark
    public void selectSingleOrder(DbState state, Blackhole bh) throws SQLException {
        try (Connection conn = state.getDataSource().getConnection();
             PreparedStatement ps = conn.prepareStatement(
                     "SELECT id, order_no, amount FROM orders WHERE order_no = ?")) {
            ps.setString(1, "ORD-500");
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    long id = rs.getLong("id");
                    String orderNo = rs.getString("order_no");
                    double amount = rs.getDouble("amount");
                    bh.consume(id);
                    bh.consume(orderNo);
                    bh.consume(amount);
                }
            }
        }
    }

    @Benchmark
    public void insertBatch(DbState state, Blackhole bh) throws SQLException {
        int batchSize = 50;
        try (Connection conn = state.getDataSource().getConnection()) {
            conn.setAutoCommit(false);
            try (PreparedStatement ps = conn.prepareStatement(
                    "INSERT INTO orders (order_no, amount) VALUES (?, ?)")) {
                for (int i = 0; i < batchSize; i++) {
                    ps.setString(1, "BATCH-" + i);
                    ps.setDouble(2, 99.9);
                    ps.addBatch();
                }
                int[] results = ps.executeBatch();
                bh.consume(results);
            } finally {
                // 데이터 눈덩이 효과(Snowball Effect) 방지:
                // 매 벤치마크 호출마다 실제 커밋을 치면 수십만 건이 누적되어
                // B-Tree 인덱스 비대화로 인해 후반 Iteration 속도가 왜곡된다.
                // 따라서 측정 대상인 '배치 쿼리 전송 및 파싱/실행 계획 처리'를 완료한 후 명시적 롤백한다.
                conn.rollback();
            }
        }
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(DatabaseBenchmark.class.getSimpleName())
                .build();
        new Runner(opt).run();
    }
}

벤치마크 결과 예시 및 분석

상기 코드를 실행했을 때 출력되는 JMH 리포트 형식은 다음과 같다.

# JMH version: 1.37
# VM version: JDK 21.0.3, OpenJDK 64-Bit Server VM, 21.0.3+9-LTS
# Warmup: 3 iterations, 2 s each
# Measurement: 5 iterations, 2 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: com.example.benchmark.DatabaseBenchmark.selectSingleOrder

# Run progress: 0.00% complete, ETA 00:00:32
# Fork: 1 of 1
# Warmup Iteration   1: 0.412 ms/op
# Warmup Iteration   2: 0.389 ms/op
# Warmup Iteration   3: 0.382 ms/op
Iteration   1: 0.381 ms/op
Iteration   2: 0.378 ms/op
Iteration   3: 0.385 ms/op
Iteration   4: 0.379 ms/op
Iteration   5: 0.380 ms/op

Result "com.example.benchmark.DatabaseBenchmark.selectSingleOrder":
  0.381 ±(99.9%) 0.007 ms/op [Average]
  (min, avg, max) = (0.378, 0.381, 0.385), stdev = 0.003
  CI (99.9%): [0.373, 0.388] (assumes normal distribution)

Benchmark                              Mode  Cnt  Score   Error  Units
DatabaseBenchmark.selectSingleOrder    avgt    5  0.381 ± 0.007  ms/op
DatabaseBenchmark.insertBatch          avgt    5  1.152 ± 0.045  ms/op

측정 결과를 살펴보면 인덱스를 타는 단건 조회(selectSingleOrder)의 경우 1회 호출당 평균 0.381ms가 소요되었으며, 99.9% 신뢰구간에서 오차가 ±0.007ms에 불과하여 매우 안정적인 정규 분포를 형성함을 확인할 수 있다.

반면 50건의 레코드를 한 번에 묶어 처리하는 배치 삽입(insertBatch)은 평균 1.152ms의 실행 시간을 기록했다. 이는 건별 단일 삽입을 애플리케이션 레벨의 단순 루프로 50회 연속 호출할 때 발생하는 TCP 소켓 라운드트립 비용(통상 18~20ms)과 비교할 때 대략 1/15 이하 수준으로 네트워크 오버헤드가 압축되었음을 실증한다. 실제 운영 환경과 동일하게 WAL 디스크 영속화 커밋(fsync) 비용까지 종합적으로 평가해야 하는 시나리오라면, 각 반복(Iteration) 단위로 데이터를 일괄 비우는 정리 전략을 병행하여 커밋 비용을 온전히 포함시킬 수 있다.

벤치마킹 시 주의해야 할 안티패턴

나노초 착시(Nanosecond Illusion)와 지연시간 꼬리(Tail Latency)

I/O 작업이 개입되는 인프라 통합 벤치마크에서는 평균값(AverageTime)에만 의존해서는 시스템의 병목을 온전히 진단할 수 없다. 도커 브리지 네트워크 스택의 패킷 버퍼링, 가상 브리지 인터페이스의 컨텍스트 스위칭, 데이터베이스 백그라운드 체크포인트 등에 의해 일시적인 레이턴시 스파이크가 발생하기 때문이다. 따라서 평균값과 더불어 @BenchmarkMode(Mode.SampleTime)을 병행 설정하여 p95, p99, p99.9 백분위수 지연시간의 분포를 함께 추적해야 프로덕션 환경의 SLA 위협 요소를 사전에 감지할 수 있다.

동시성 스레드 수와 커넥션 풀 용량의 불일치

JMH의 @Threads(n) 옵션을 통해 동시 요청 부하를 모사할 때 HikariCP 풀 크기(maximumPoolSize)를 적절히 조율하지 않으면 엉뚱한 지표를 측정하게 된다. 예를 들어 16개의 작업 스레드가 동시에 벤치마크를 수행하는데 풀의 최대 커넥션 수가 10개로 제한되어 있다면, 나머지 6개 스레드는 쿼리를 실행하는 시간보다 커넥션 풀 락(Lock)을 획득하기 위해 대기하는 시간이 지배적이 된다. 이는 데이터베이스 처리 성능을 검증하는 것이 아니라 커넥션 풀의 자원 고갈 비용을 측정하는 전형적인 안티패턴이다.

쓰기 작업의 데이터 눈덩이 효과(Snowball Effect)

삽입이나 삭제 연산에 대한 벤치마크는 반복 횟수가 늘어남에 따라 대상 테이블의 레코드 수와 인덱스 B-Tree 깊이가 선형적으로 팽창한다. 이로 인해 측정 초반부의 반복보다 후반부의 반복이 점진적으로 느려지는 구조적 편향이 발생한다. 이를 차단하는 방법은 두 가지가 있다. 첫째는 예제 코드처럼 매 호출 직후 conn.rollback()을 호출하여 네트워크 전송 및 SQL 파싱/실행 비용만 순수하게 측정하고 테이블 상태를 영구적으로 불변 유지하는 방식이다. 둘째는 실제 커밋 비용까지 측정하되, 각 반복 단계(@Setup(Level.Iteration))에서 TRUNCATE orders RESTART IDENTITY를 실행하여 벤치마크 반복마다 테이블 크기를 엄격히 초기화하는 방식이다.

로컬 OS 가상화 레이어의 I/O 노이즈

macOS나 Windows 환경의 Docker Desktop은 가상화 하이퍼바이저(HyperKit, Apple Virtualization framework, WSL2) 위에서 구동되므로 네이티브 리눅스에 비해 파일 시스템 동기화(fsync) 및 가상 소켓 지연시간이 상대적으로 크게 측정된다. 따라서 로컬 개발 환경에서 도출된 절대적인 밀리초 수치를 곧바로 프로덕션 SLA의 절대 기준으로 삼아서는 안 된다. 로컬 벤치마크는 배치 처리, 인덱스 구조, 쿼리 튜닝 등 '대안 A 대비 대안 B가 몇 퍼센트의 성능 향상을 이끌어내는가'를 판별하는 상대적 성능 개선율(Relative Speedup) 검증 도구로 활용하는 것이 바람직하다.