Throughput beyond # vCPU

AWS t3.small 인스턴스에서 부하 테스트 삽질기: CPU 점유율과 처리량의 미스터리

AWS의 t3.small 인스턴스를 활용하여 애플리케이션의 부하 테스트를 수행하고 있었습니다. t3.small 인스턴스는 2개의 vCPU를 제공하며, 일반적인 웹 애플리케이션이나 마이크로서비스에 적합한 사양입니다.


1. 초기의 접근: Thread.sleep()의 함정

저희는 스레드 50개를 사용했었고, 부하테스트 프로젝트를 진행하면서 특정 메서드에 2초동안 작업을 하게끔 보이는 코드가 필요했었습니다.

초기 부하 테스트 시나리오는 간단했습니다. 특정 API 엔드포인트가 호출되면, 내부적으로 약 2초간의 "작업"을 수행하는 것을 시뮬레이션하고 싶었습니다. 가장 직관적인 방법은 바로 Thread.sleep() 메서드를 사용하는 것이었습니다.

// 초기 구현 (개념 코드)
private void simulateWorkWithSleep() {
    try {
        // 2초 동안 대기
        Thread.sleep(2000);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

이 코드는 예상대로 동작했습니다. API를 호출하면 2초 후에 응답이 돌아왔죠. 하지만 문제는 다른 곳에 있었습니다. 부하 테스트 도구로 스레드 수를 늘려가며 CPU 사용량을 모니터링했을 때, t3.small 인스턴스의 CPU 사용률이 거의 오르지 않는 것을 확인했습니다. 2개의 vCPU가 거의 놀고 있는 상태였습니다.

이러한 현상이 발생한 이유는 아래와 같습니다. Thread.sleep()은 스레드를 지정된 시간 동안 "일시 중단"시키는 메서드입니다. 이 시간 동안 스레드는 CPU를 점유하지 않고 운영체제에게 CPU를 양보합니다. 즉, 실제 CPU 자원을 소모하는 작업이 아니라 단순히 시간을 지연시키는 용도로만 사용되는 것이었습니다. 저희의 목표는 "CPU를 점유하는 2초간의 작업"을 시뮬레이션하는 것이었기에, Thread.sleep()은 부하 테스트에 적합하지 않은 방법이었습니다.


2. CPU 점유를 위한 첫 시도: busyWait 메서드 (버전 1)

Thread.sleep()이 CPU를 사용하지 않는다는 것을 깨달은 후, 저희는 실제로 CPU를 적극적으로 사용하는 방법을 찾아야 했습니다. 아이디어는 간단했습니다. "아무것도 하지 않는 루프를 2초 동안 돌리면 CPU를 점유할 수 있지 않을까?"

그래서 다음과 같은 busyWait 메서드를 구현했습니다.

// CPU를 점유하기 위한 첫 시도 (버전 1)
private void busyWait(long seconds) {
    // 1초는 1,000,000,000 나노초입니다.
    double waitSeconds = seconds; // 2초의 배율을 위함
    long durationInNanos = (long)(waitSeconds * 1_000_000_000L);
    long startTime = System.nanoTime();

    // 현재 시간이 시작 시간 + 대기 시간보다 작을 동안 루프를 계속 실행합니다.
    while ((System.nanoTime() - startTime) < durationInNanos) {
        // 이 루프를 계속해서 돌면서 CPU를 점유합니다.
        // 안에는 아무런 코드도 실행할 필요가 없습니다.
    }
}

이 코드는 System.nanoTime()을 사용하여 현재 시간과 시작 시간을 비교하며, 지정된 seconds 동안 루프를 무한히 반복하도록 설계되었습니다. 이 루프 안에서 아무런 유의미한 작업을 하지 않더라도, 루프 자체를 돌리는 행위가 CPU 사이클을 소모할 것이라고 기대했습니다.

코드를 배포하고 50개의 스레드를 설정하여 부하 테스트를 다시 실행했습니다. 결과는 t3.small 인스턴스의 CPU 사용률이 드디어 100%까지 치솟았습니다. 성공이라고 생각했지만, 처리량(Throughput)을 확인했을 때 다시 한번 의문이 들었습니다. CPU는 100%인데, 처리량은 약 25 RPS (Requests Per Second) 수준에 머물러 있었습니다. 스레드 갯수만큼 처리량이 높았습니다.

잠시 혼란에 빠졌습니다. 2개의 vCPU를 가진 인스턴스에서 2초간 CPU를 점유하는 작업이라면, 이론적으로 초당 1개 정도의 요청만 처리할 수 있어야 합니다 (2 vCPU * 1초 = 2 CPU-초. 각 요청이 2 CPU-초를 소모한다면, 초당 1개). 근데 각 요청이 실제로는 2초보다 훨씬 적은 CPU 시간을 소모하고 있다는 의미였습니다.

혹시 AWS t3 인스턴스의 CPU 버스트 옵션 때문일까 의심했습니다. t3 인스턴스는 기본적으로 CPU 크레딧을 제공하여 일정 시간 동안 기준 성능 이상으로 버스트할 수 있습니다. 하지만 저희의 테스트는 장시간 지속되었고, CPU 크레딧이 소진된 후에도 비슷한 패턴을 보였습니다. 결국 CPU 버스트 문제는 아니라는 결론에 도달했습니다.


3. JIT 컴파일러 최적화 의심

높은 처리량의 현상을 이해하기 위해 다음으로 의심한 것은 바로 JVM의 JIT(Just-In-Time) 컴파일러였습니다. 자바 애플리케이션은 바이트코드로 컴파일된 후 JVM 위에서 실행됩니다. 이때 JIT 컴파일러는 자주 실행되는 코드(핫 스팟)를 감지하여 네이티브 머신 코드로 컴파일하고 최적화하여 성능을 향상시킵니다.

저희의 busyWait 메서드, 특히 while 루프 안의 빈 블록은 JIT 컴파일러의 완벽한 최적화 대상이 될 수 있습니다. JIT 컴파일러는 "이 루프 안에서 아무런 유의미한 작업도 하지 않고, 외부 상태를 변경하지도 않으며, 반환 값에도 영향을 주지 않는다면, 이 루프는 사실상 불필요하다"고 판단하고 통째로 제거해 버릴 수 있습니다.

만약 busyWait 메서드가 JIT 컴파일러에 의해 최적화되어 실제로는 2초 동안 CPU를 점유하지 않고 빠르게 종료된다면, CPU 사용률이 100%로 치솟는 것은 50개의 스레드가 동시에 이 "빠르게 종료되는" 작업을 무한히 반복하려고 경쟁하기 때문일 수 있습니다. 그리고 각 스레드가 실제로는 매우 짧은 시간 동안만 CPU를 점유하고 다음 요청을 처리할 준비가 되므로, 처리량이 높게 나오는 것이 설명될 수 있습니다.


4. JIT 최적화 회피 시도: dummy 변수 추가 (버전 2)

JIT 컴파일러의 최적화를 막기 위해, 저희는 루프 안에 "의미 없는" 작업을 추가하여 JIT 컴파일러가 루프를 제거하지 못하도록 속이는 방법을 시도했습니다. 바로 dummy 변수를 추가하고, 루프가 돌 때마다 이 변수의 값을 증가시킨 후, 최종적으로 이 값을 반환하도록 코드를 수정했습니다.

// JIT 최적화 회피 시도 (버전 2)
public static long busyWait(long seconds) {
    // 1초는 1,000,000,000 나노초입니다.
    double waitSeconds = seconds;
    long durationInNanos = (long)(waitSeconds * 1_000_000_000L);
    long startTime = System.nanoTime();

    long dummy = 0L; // JIT 최적화를 막기 위한 더미 변수

    // 현재 시간이 시작 시간 + 대기 시간보다 작을 동안 루프를 계속 실행합니다.
    while ((System.nanoTime() - startTime) < durationInNanos) {
        // 이 루프를 계속해서 돌면서 CPU를 점유합니다.
        // 안에는 아무런 코드도 실행할 필요가 없습니다.
        dummy++; // 더미 변수 증가: JIT가 이 루프를 제거하지 못하도록 함
    }
    return dummy; // 더미 값을 반환하여 JIT가 이 값을 사용한다고 인식하도록 함
}

'dummy` 변수를 `dummy++`로 증가시키고, 최종적으로 `return dummy;`를 통해 이 값을 반환함으로써, JIT 컴파일러는 `dummy` 변수의 값이 외부로 노출되고 사용될 가능성이 있다고 판단하여 루프를 통째로 제거하지 않을 것이라고 기대했습니다.

이제는 정말로 2초 동안 CPU를 점유하는 작업이 될 것이라고 믿고. 다시 부하 테스트를 실행했습니다. 하지만 결과는 여전히 비슷했습니다. CPU 사용률은 100%를 유지했고, 처리량도 여전히 50 RPS 수준이었습니다. JIT 최적화를 막았다고 생각했는데, 왜 여전히 2초간의 CPU 점유가 제대로 이루어지지 않는 것일지 궁금했습니다.


5. CPU 타임 슬라이스

여기서부터 저희는 문제를 더 깊이 파고들기 시작했습니다. 그리고 `System.nanoTime()`과 운영체제의 CPU 스케줄링 메커니즘에 대해 근본적인 오해를 하고 있었다는 것을 깨달았습니다.

저는 `while ((System.nanoTime() - startTime) < durationInNanos)` 루프가 2초 동안 "CPU를 점유하며" 실행될 것이라고 생각했습니다. `System.nanoTime()`은 시스템의 타이머를 사용하여 시간을 측정합니다. 즉, 이 루프는 실제로 2초의 벽시계 시간이 흐를 때까지 계속 실행될 것입니다.

문제는 "CPU를 점유하며"라는 부분에 있었습니다. 운영체제는 여러 스레드가 동시에 실행되는 것처럼 보이게 하기 위해 "타임 슬라이싱(Time-Slicing)"이라는 기법을 사용합니다. 각 스레드에게 아주 짧은 시간(타임 슬라이스) 동안 CPU를 할당하고, 이 시간이 지나면 다른 스레드에게 CPU를 넘겨줍니다.

저희의 경우, 50개의 스레드가 2개의 vCPU를 놓고 경쟁하고 있었습니다. 각 스레드는 `busyWait` 메서드 안에서 `dummy++`와 `System.nanoTime()` 비교를 반복하며 CPU를 사용하려고 합니다. 하지만 스레드가 CPU를 할당받지 못하고 대기하는 동안에도 `System.nanoTime()`은 계속해서 흘러갑니다.

'busyWait` 메서드가 시작된 시점부터 2초의 벽시계 시간이 흐르면, 스레드가 CPU를 점유했든 안 했든 상관없이 루프 조건은 `false`가 되고 메서드는 종료됩니다. 스레드가 CPU를 할당받지 못하고 대기했던 시간은 `System.nanoTime()`의 측정값에는 포함되지만, 그 시간 동안 해당 스레드는 CPU를 전혀 사용하지 않았습니다.

결과적으로, 50개의 스레드가 2개의 vCPU를 놓고 경쟁하는 상황에서, 각 스레드는 매우 짧은 CPU 타임 슬라이스만을 얻게 됩니다. `dummy++`와 같은 매우 가벼운 연산은 이 짧은 타임 슬라이스 내에서 빠르게 실행되고, 스레드는 다음 타임 슬라이스를 기다리며 대기 상태에 들어갑니다. 이렇게 되면 각 요청이 "2초간 CPU를 점유"하는 것이 아니라, "2초간 시계 시간 동안 CPU를 점유하다가 종료"되는 형태가 됩니다.

저는 진짜 실제로 "2초 동안 CPU를 점유"하는 메서드를 구현하기 위한 방법을 고민했고, `busyWait` 메서드를 개선했습니다.

6. 실제 CPU를 점유하는 시간을 측정 (버전 3)

public void realBusyCpuTime(long second) {
    ThreadMXBean bean = ManagementFactory.getThreadMXBean();
    if (!bean.isCurrentThreadCpuTimeSupported()) return;

    long target = second * 1_000_000_000L;
    long start = bean.getCurrentThreadCpuTime(); // CPU Time in nanoseconds

    long dummy = 0;
    while (bean.getCurrentThreadCpuTime() - start < target) {
        dummy++;
    }

    System.out.println("Finished with dummy: " + dummy);
}

위와 같은 방식으로 busyWait를 개선한 후, 다시 부하 테스트를 진행했습니다. 이제는 50개의 스레드를 사용했을 때 CPU는 여전히 100%를 기록했지만, 처리량은 예상했던 대로 2 RPS에 훨씬 가까운 수치로 떨어지는 것을 확인할 수 있었습니다. 이는 각 요청이 실제로 2초에 가까운 CPU 시간을 소모하고 있다는 것을 의미했습니다.


7. 결론

이번 AWS t3.small 인스턴스에서의 부하 테스트 삽질은 저희에게 많은 것을 가르쳐주었습니다.

  • Thread.sleep()의 한계: 단순히 시간을 지연시키는 용도이며 CPU를 점유하지 않는다는 점을 명확히 인지해야 합니다.

  • JIT 컴파일러의 영향: JVM의 JIT 컴파일러는 예상보다 훨씬 똑똑하며, 코드가 어떻게 최적화될 수 있는지 항상 염두에 두어야 합니다. 특히 부하 테스트처럼 특정 자원 소모를 시뮬레이션할 때는 JIT 최적화가 의도치 않은 결과를 초래할 수 있습니다.

  • CPU 타임 슬라이싱의 이해: 운영체제의 CPU 스케줄링 방식(타임 슬라이싱)과 System.nanoTime() 같은 시간 측정 메서드의 동작 방식을 정확히 이해하는 것이 중요합니다. 벽시계 시간과 스레드가 실제로 CPU를 점유한 시간은 다를 수 있습니다.

  • 정확한 부하 시뮬레이션의 중요성: 부하 테스트는 실제 시스템의 동작을 최대한 정확하게 시뮬레이션해야 합니다. 단순히 "2초 대기"가 아니라 "2초간 CPU를 점유하는 작업"이라면, 그에 맞는 정교한 구현이 필요합니다.

이번 경험을 통해 저는 단순히 코드를 작성하고 실행하는 것을 넘어, JVM 내부 동작과 운영체제의 기본 원리까지 깊이 이해하는 계기가 되었습니다.

참고:

  • AWS t3 인스턴스: 버스트 가능한 성능을 제공하는 범용 인스턴스입니다. CPU 크레딧을 사용하여 기준 성능 이상으로 버스트할 수 있습니다.

  • JIT 컴파일러 (Just-In-Time Compiler): JVM의 핵심 구성 요소 중 하나로, 런타임에 바이트코드를 네이티브 머신 코드로 변환하고 최적화하여 애플리케이션 성능을 향상시킵니다.

  • CPU 타임 슬라이싱 (Time-Slicing): 운영체제가 여러 프로세스나 스레드에게 CPU 시간을 아주 짧게 나누어 할당하는 기법입니다. 이를 통해 여러 작업이 동시에 실행되는 것처럼 보이게 합니다.

Last updated