ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 가상 스레드 (Virtual Thread) 성능 테스트 해보기
    백엔드 : 서버공부/Spring 2025. 10. 30. 17:12
    728x90

    이전 글에서는 자바 21의 가상 스레드의 이론적인 점만 살펴봤다면,
    이번 글에서는 경량 스레드를 직접 다뤄보면서 실제로 얼마나 성능적인 이점을 주는지 테스트해보려 한다.

     

    토이 프로젝트의 비즈니스 로직에는 아직 적용하지 못했고, 일단은 가장 단순한 형태의 예제 컨트롤러를 만들어서 요청이 들어오면 스레드를 여러 개 띄워보는 방식으로 진행했다.
    테스트는 우선 작업 개수(task)를 1,000개로 고정해서 비교했다.

     

    1. 테스트 조건

     

    • JDK: 21 (Virtual Thread 정식 포함 버전)
    • 작업 수: 1,000
    • 작업 내용: Thread.sleep(taskDurationMs)로 I/O 대기와 같은 시간을 강제로 만든다.
    • 비교 대상
      • 고정 스레드 풀: Executors.newFixedThreadPool(...)
      • 가상 스레드: Executors.newVirtualThreadPerTaskExecutor()
    • 공통 구조: ExecutorService가 작업을 받고, 각 작업이 끝날 때마다 CountDownLatch로 카운트다운해서 1,000개가 전부 끝난 시점을 측정한다.

     

    2. 테스트 코드 구조

    2-1. 가상 스레드로 실행

     

     

    이 코드가 하는 일은 아래와 같다.

    1. 시작 시간 기록
    2. Executors.newVirtualThreadPerTaskExecutor() 로 가상 스레드 실행기 생성
    3. CountDownLatch(1000)으로 1,000개가 끝날 때까지 기다릴 준비
    4. for문 1,000번 돌면서 executorService.submit(() -> { Thread.sleep(...); latch.countDown(); })
    5. latch.await()로 전부 끝날 때까지 대기
    6. 끝난 시간에서 시작 시간 빼서 총 실행 시간(ms) 계산
    7. 결과 객체로 묶어서 반환

    여기서 핵심은 submit이 “실행”이 아니라 “등록”이라는 점이다.

    실제 실행은 Executor가 들고 있는 캐리어 스레드에서 병렬로 처리된다.

     

    2-1. 고정 스레드 풀로 실행

     

    고정 스레드 풀 버전은 가상 스레드 버전과 구조가 거의 같다. 다른 건 이 한 줄뿐이다.

    ExecutorService executorService = Executors.newFixedThreadPool(threadPoolSize);

     

    여기서 내가 풀 크기를 CPU 코어 × 2 정도로 잡은 이유는, I/O 대기 작업이라 코어 수보다 살짝 많은 스레드를 돌려도 감당할 수 있기 때문이다. 그래도 결국 이 풀 크기가 병렬 실행의 상한이 된다. 1,000개를 넣어도 동시에는 16개 정도만 실행되고 나머지는 기다린다.

     

    3. 응답 결과

    고정 스레드 풀 결과

     

     

    가상 스레드 결과

     

    이 결과만 보면 메시지가 아주 명확하다.

    • 같은 1,000개 작업
    • 같은 작업 내용 (Thread.sleep(100))
    • 같은 JVM

    인데도

    • 고정 스레드 풀: 약 5.1초
    • 가상 스레드: 약 0.1초

    즉, 가상 스레드가 약 50배 이상 빠르게 끝났다.

    4. 왜 이렇게 차이가 났는가

    1. 고정 스레드 풀은 풀 크기가 상한이다.
      풀을 16개로 만들었다고 하면 한 번에 16개 작업만 실행된다. 1,000개 중 984개는 대기열에 있고, 앞에 있는 작업이 Thread.sleep() 으로 100ms씩 쉬는 동안 뒤에 있는 작업은 시작조차 못 한다. 이게 총 실행 시간을 끌어올린다.

    sleep 인데 스레드는 왜 갖고가냐

    1. 가상 스레드는 블로킹 시 캐리어 스레드를 반납한다.
      가상 스레드가 Thread.sleep() 을 만나면 실제 OS 스레드를 점유한 채로 자는 게 아니라, JVM이 해당 가상 스레드를 잠깐 내려놓고 그 OS 스레드로 다른 가상 스레드를 실행시킨다. 그래서 1,000개가 다 “자고 있어도” 병렬로 처리되는 것처럼 보이는 것이다. 반면에 자바에서 우리가 흔히 쓰는 플랫폼 스레드(Platform Thread) 는 OS 커널의 스레드 리소스를 점유한채로 대기한다.
    2. 작업당 비용이 작기 때문에 1,000개를 그대로 만들 수 있다.
      플랫폼 스레드를 1,000개 만들면 스택만 해도 몇백 MB가 날아가는데, 가상 스레드는 힙에 필요한 만큼만 스택을 올렸다 내렸다 하기 때문에 이런 테스트에서는 큰 부담이 없다.
    3. 테스트 작업이 I/O 대기형이었기 때문이다.
      이번 테스트는 CPU를 태우는 작업이 아니라 잠깐씩 자는(sleep) 작업이었다. 이런 작업에서 Virtual Thread가 최대 효과를 낸다. 반대로 CPU를 꽉 채우는 연산을 넣으면 이런 차이가 줄어든다.

    5. 테스트하면서 느낀 점

    • 구조는 생각보다 단순했다. 기존에 쓰던 ExecutorService 패턴을 그대로 쓰고, newVirtualThreadPerTaskExecutor() 만 바꿨는데 결과가 확 바뀐다.
    • CountDownLatch 없으면 컨트롤러가 먼저 리턴해버려서 시간 측정이 안 된다. 꼭 있어야 한다.
    • 메모리는 두 케이스 모두 크게 늘지 않았다. 이 예제에서는 작업이 매우 짧고 끝나자마자 내려가서 그런 듯하다. 이건 “가상 스레드가 무조건 메모리를 덜 쓴다”는 근거로 쓰면 안 되고, “적어도 이런 I/O 대기형에서는 부담이 적다” 정도로 적는 게 맞다.
    • 실제 서비스 코드에 끼워 넣으려면 synchronized, 네이티브 호출, 특정 JDBC 드라이버처럼 핀(pinning) 걸릴 수 있는 부분은 점검을 해야 한다. 위 테스트는 아주 깨끗한 코드라서 이상적인 수치가 나온 거다.

    6. 한계

    • 이 테스트는 Thread.sleep() 으로 I/O를 흉내낸 것이다. 실제 네트워크 I/O에서는 커넥션 풀, 드라이버가 가상 스레드를 얼마나 지원하느냐, 타임아웃 설정 등 변수가 더 많다.
    • CPU 바운드 작업으로 바꾸면 이런 차이는 안 나온다. 이 글은 어디까지나 “동시에 많이 대기해야 하는 작업”에서 Virtual Thread가 얼마나 유리한가를 보는 것.
    • Spring 전체 필터 체인, 트랜잭션, DB까지 다 태운 통합 시나리오는 아니다. 지금은 구조가 보이게 하려고 가장 단순한 형태로 테스트했다.

    7. 정리

    • 같은 1,000개 작업을 던졌을 때 고정 스레드 풀은 풀 크기만큼만 병렬로 실행돼서 총 5초가 걸렸고, 가상 스레드는 블로킹 시 OS 스레드를 바로 반납해서 약 100ms 안에 처리가 끝났다.
    • 코드 구조는 거의 동일했고 Executor만 바꿨다.
    • 실제 서비스 코드에 가져가려면 핀닝 위험 코드와 라이브러리 호환성은 따로 확인해야 한다.

     

    마무리

    진짜 단순한 테스트 였다.

    이제 야구보러 가야겠다!

     

     

    그나저나 테스트가 한번에 너무 의도한채로 나와서 불안하다... 흠,,

Designed by Tistory.