Platform Thread vs Virtual Thread의 AI I/O 블로킹 효율 비교

2026. 7. 4. 04:49·java

Platform Thread

 

Platform Thread는 운영체제의 1:1 스레드 매핑 방식을 선택했습니다. 여기서 1:1 매핑이란 사용자 영역에 존재하는 JVM에서 생성된 스레드가 커널 영역의 스레드와 1대1 형태로 매핑된것을 말하며 스레드 스케줄링의 통제권을 운영체제 커널에 전적으로 위임 했다는 뜻 입니다.

1:1 매핑

 

이 방식을 선택 한 이유는 지금의 CPU는 멀티코어 환경으로 CPU의 성능을 최대로 사용하기 위함 입니다 OS 스케줄러가 TCB를 직접
관리 하면서 CPU의 멀티코어 자원에 자바 스레드를 골고루 분산 배치 해 물리적으로 동시에 연산이 가능하게 합니다.

 

초창기 자바는 1:N 매핑 방식을 사용했습니다 이 방식의 한계는 1개의 OS 스레드가 JVM의 여러 스레드를 관리 하는 방식으로 만약 하나의 스레드에서 I/O 블로킹이 발생 한다면 다른 스레드도 같이 대기 해 버리는 문제가 발생 했습니다.

 

이 문제를 해결 하기 위해 1:1 매핑 방식을 선택 함으로, 하나의 스레드가 I/O 블로킹이 발생하게 되면 OS 커널은 그 스레드를 재우고 커널 영역의 다음 스레드를 올려 작업을 진행하게 됩니다 이런 방식은 하나의 자바 스레드가 블로킹 되었을 때 프로세스 전체(혹은 다른 자바 스레드들까지) 같이 멈춰버리는 문제를 해결 합니다.

 

이런 방식에도 문제가 있습니다 사용자 영역에서 생성된 스레드는 커널 영역의 스레드와 1:1 매핑이 되기 때문에 사용자 영역에서 생성된 스레드가 많게 되면 커널 영역에 생성되어야 하는 스레드도 같이 증가하게 됩니다, 이런 방식은 CPU의 컨텍스트 스위칭 문제로 시스템 전체의 처리량을 떨어뜨릴 뿐 아니라 스레드 당 고정적으로 할당되는 스택 메모리로 인해 메모리 고갈 현상을 나타낼 수 있습니다.

 

또한 JVM 내부에서 커널 영역에 스레드를 하나 생성 해 달라는 시스템 콜을 하기 때문에 생성 비용이 비싼 작업을 하게 됩니다 비록 스레드 풀을 통해 스레드를 재사용함으로써 초기의 시스템 콜을 최소화할 수 있으나, 대규모 트래픽이 몰려 스레드 풀의 최대 크기를 넘어서는 요청이 들어올 경우, 제한된 스레드가 모두 I/O 블로킹에 묶여 후속 요청들이 대기 큐에서 정체되거나 타임아웃으로 실패하는 근본적인 병목 현상을 막을 수 없습니다

 

이런 문제를 해결 할 수 있는 기술중 Virtual Thread 가 있습니다

 


 

Virtual Thread

 

Virtual Thread는 수많은 가상 스레드(M)를 소수의 OS 스레드(N) 위에서 실행시키는 N:M 스레드 매핑 방식을 선택 해 사용합니다

 

가상 스레드 N:M

 

여기서 핵심은 Platform Thread는 OS 영역에 스케줄링을 위임 했다면 Virtual Thread는 사용자 영역에서 스케줄링을 한다는 점과

Carrier Platform Thread 가 커널 영역의 스레드와 1:1 매핑이 되어있다는 점 입니다.

 

커널에서 스케줄링, 사용자 영역에서 스케줄링의 큰 차이점은 스케줄링을 사용자가 제어할 수 있다는 점 입니다 여기서 핵심적인 동작을 하는것은 Carrier Platform Thread 입니다 이 스레드는 커널 영역의 스레드와 1:1 매핑이 되어있으면서 JVM의 사용자 영역에서의 스케줄링으로 작업 할 가상 스레드를 유동적으로 변경 할 수 있고 이렇게 되면 결과적으로 커널 영역의 OS 스케줄러는 오직 고정된
Carrier Thread 만을 바라보기 때문에, 유저 영역에서 가상 스레드가 교체되어도 이를 전혀 인지하지 못합니다. 즉, 스케줄링의 제어권을 애플리케이션(사용자)이 완벽히 쥐게 됨으로써, 커널 레벨의 컨텍스트 스위칭 부하를 원천적으로 차단하고 대규모 I/O 블로킹을 효율적으로 처리할 수 있게 됩니다

 

또한 1:1 매핑 방식에서 발생한 메모리 문제도 이제 커널 영역에서 생성되는 무거운 스레드 생성 방식이 아닌 JVM의 힙 영역에 생성되기 때문에 더욱 작은 공간을 가지는 스레드를 생성 해 내부에서 동적으로 공간을 변경 할 수 있고 GC를 통해 메모리 회수 등등 메모리 제어권을 사용자가 가지고 있어 이에 대한 최적화도 진행 할 수 있게 됩니다.

 


 

비교

 

테스트 환경

  • 기본 플랫폼 스레드 풀 방식
  • 가상 스레드 방식

 

Platform Thread를 사용해서 테스트를 진행 한 결과 

 

사용자의 요청이 들어올때 스레드는 OS 영역에 무겁게 쌓이고, 대신 스레드가 미처 처리하지 못한 '수천 개의 요청 대기 객체(큐)와 통신 버퍼 객체가 힙 영역에 쌓여서 상승하고 있는 모습 입니다. 

 

또한 Platform Thread를 사용함으로 요청 1개 당 1개의 커널 스레드와 JVM의 스레드가 매핑이 되어 하단 Thread 부분에 그래프가 급격히 상승하는 모습을 볼수 있습니다.  

 

또한 스레드가 계속 Wait 상태였다 Park 상태로 들어가게 되는 모습을 볼수 있습니다, 이 문제는 요청 대기 큐는 동시성 큐이기 때문에 접근을 하려면 락을 얻어야 하는데 이 락을 얻기 위해서 경쟁이 심해지게 되면 Park 상태로 스레드 상태를 변경하게 됩니다. 

 

아래 코드는  Platform Thread Pool을 만드는 코드로 내부적으로 LinkedBlockingQueue 를 가지고 있습니다.

ExecutorService executor = Executors.newFixedThreadPool(200);
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}

 

LinkedBlockingQueue는 내부에 ReentrantLock 라는 락 관련 필드를 가지고 있고 클래스 내부에는 락을 가지는 코드가 존재 합니다.

private final ReentrantLock putLock = new ReentrantLock();

 

이 코드 내부로 들어가 보면 아래 락을 얻는 진입점으로 들어가게 되고 

@ReservedStackAccess
final void lock() {
    if (!initialTryLock())
        acquire(1);
}

 

AbstractQueuedSynchronizer 추상 클래스의 acquire 메서드를 호출하게 됩니다 이 메서드는 간단히 tryAcquire 메서드를 통해 락을 한번 더 확인 후 없으면 이 추상 클래스의 acquire 메서드를 호출하게 됩니다.

public final void acquire(int arg) {
    if (!tryAcquire(arg))
        acquire(null, arg, false, false, false, 0L);
}

 

호출된 acquire 메서드는 내부적으로 무한 루프를 돌면서 락을 얻고 락을 얻지 못한 스레드는 LockSupport.park(..) 메서드를 통해 스레드의 상태를 park 상태로 변경 합니다.

final int acquire(Node node, int arg, boolean shared, ...) {
    for (;;) { 
        if (first || pred == null) {
            if (tryAcquire(arg)) { 
                return 1;          
            }
        }
        // ... 중간에 힙 영역에 줄 세우는 코드 (`new ExclusiveNode()`) 실행
        else {
            LockSupport.park(this); 
        }
    }
}

 

이렇게 park 상태로 들어가게 되면 작업 큐에 쌓여 있는 요청을 소모하지 못하게 되는 상태가 되기 때문에 플랫폼 스레드 풀 모델이 대규모 트래픽에서 문제가 될 수 있습니다.

 

Instruments 분석

 

이 지표는 K6 테스트를 하면서 발생한 지표 입니다

 

System Call Trace 부분을 보면 초반에 스레드 생성으로 인한 시스템 콜의 지표가 높은것을 알수 있습니다, 1:1 매핑은 OS 스레드와 사용자 스레드가 매핑이 되어야 하기 때문에 높은걸 알 수 있습니다, 또한 테스트가 끝날때 까지 지속적인 시스템 콜을 하고 있는데 이 부분은 스레스 생성 뿐 아니라, 스레드 상태 변경 등등 많은 시스템 콜을 하고 있는것을 알 수 있습니다. 

 

Thread State Trace 부분을 보면 OS 커널 스케줄러는 큐의 키 경쟁에서 키를 잡지 못한 무거운 플랫폼 스레드들을 CPU 코어에서 끌어내리고(Block/Park), 깨우는 작업을 무한 반복 합니다. 이 과정에서 발생한 컨텍스트 스위칭 오버헤드는 맥 OS 커널 레벨에 엄청난 연산 부하를 주었다는것을 알 수 있습니다.

 

java 부분을 보면 CPU 사용량과 컨텍스트 스위칭 수치가 일정하지 않고 중간중간 튀어 나와있는 것을 알 수 있습니다 이는 CPU 코어가 진짜 가상 사용자의 요청을 처리하는 작업을 한 것이 아니라, 스레드의 상태 변경과 TCB를 갈아끼우는 오버헤드(Context Switch)에 CPU 시간을 사용 했고 사용자의 요청을 올바르게 처리하지 못했다는 것을 알 수 있습니다.

 

K6 지표 분석

 

생성된 200개의 요청에 대한 각각의 스레드는 1.5초 대기 후 2초동안 작업을 하고 응답을 처리후 다음 작업을 작업 대기 큐에서 가져와 실행하게 됩니다, 그렇게 되면 응답 속도는 매우 느려지게 됩니다 

 

k6의 HTTP 지표를 보게되면 평균 응답 속도는 25.59초 가장 늦게 들어온 사용자의 요청은 47.04초가 걸려 응답을 받은것을 볼 수 있습니다. 

 

테스트 기간 중 순간 10000명의 동시 사용자가 한꺼번에 서버 접속을 시도 했지만 http_reqs (123.71/s) 서버의 초당 처리량은 플랫폼 스레드 200개라는 문제 때문에 초당 123개의 요청밖에 처리하지 못하는 큰 병목이 발생 한것을 알 수 있습니다.

 


 

Virtual Thread를 사용해서 테스트를 진행 한 결과 

 

Virtual Thread를 사용 했을 때 힙 공간이 늘어나는 이유는 K6 테스트를 통해 들어온 요청을 가상 스레드 객체로 힙 영역에 생성했기 때문에 증가 하는 모습을 보이고 있습니다. 가상 스레드(Virtual Thread)가 OS 커널에 종속되지 않고 JVM 힙 영역에 직접 적재되는 초경량 자바 객체이기 때문에. 수만 개의 요청이 들어오는 즉시 힙 공간에 가상 스레드 객체 형태로 실시간 할당되었다가, 비즈니스 로직 및 I/O 처리가 완료되는 대로 가비지 컬렉터(GC)에 의해 신속하게 소모, 회수되면서 대규모 부하 속에서도 메모리를 효율적으로 사용할 수 있는 모습입니다.

 

여기서 Thread 부분을 보게 되면 Platform Thread  와 다르게 커널 스레드와 매핑이 되는 스레드의 개수가 최대 31개인 모습을 볼 수 있습니다. 이유는 위에서 설명한 Virtual Thread의 실행 방식 때문 입니다. 오른쪽 사진을 보게되면 커널 스레드와 JVM의 스레드 매핑은 고작 12개 정도밖에 되지 않습니다 여기서 ForkJoinPool 접미사를 가진 스레드가 Carrier Platform Thread 입니다 위에서 설명 했듯이 1대1 매핑이 되는 스레드는 Carrier Platform Thread가 되고 이 스레드의 스케줄링을 JVM에서 진행 하면서 커널 공간에서는 접근할 수 없던 부분인 스케줄링 부분을 사용자 영역으로 이동시켜 더욱 유연한 사용자 레벨의 컨텍스트 스위칭을 할 수 있습니다.

 

여기서 왜 처음부터 ForkJoinPool 스레드가 Park 상태인지 의문이 들었는데 플랫폼 스레드는 1.5초의 대기 지연을 OS 커널 스레드가 진행 하기 때문에 초반 Wait 상태가 표시 되었지만, 가상 스레드는 1.5초의 대기 지연을 JVM 힙 영역 내부로 유턴시켜 격리(안 찍힘)하고, 물리 캐리어 스레드는 오직 나노초 단위의 순수 실행(Running) 순간에만 실행 되고 즉시 대기실(Park)로 복귀하기 때문에 지표상 주황색만 보이는것이라고 한다. 또한 Running를 보기 위해서 5억번 정도의 루프를 돌게 했지만 이번에는 Wait 막대만 나오는것을 보고 찾아봤더니 소켓 I/O 컨텍스트 내부에서 연산이 돌 때 JVM의 장부(ThreadMXBean)가 상태를 WAIT로 오독하는 기술적 착시가 있다고 한다. 

 

이 가상 스레드는 ThreadFactory 를 통해 생성이 된다 실제 저장 위치는 VM Heap(힙) 영역이고 실행할 때만 잠깐 캐리어 스레드 메모리를 빌려 쓰고, 멈추거나 쉴 때는 다시 Heap 영역의 일반 객체 저장소로 돌아와 있는 구조이다.

 

이렇기 때문에 I/O 바운디드 작업에서 가상 스레드의 강점이 나타나게 된다

 

Instruments 분석

 

System Call Trace 부분을 보면 테스트 초반부에 그래프가 소폭 상승하는 구간이 존재합니다. 가상 스레드 모델이라 하더라도 실제 연산을 수행할 물리 스레드인 '캐리어 플랫폼 스레드(ForkJoinPool)'가 필요하기 때문에 조금 상승하는 모습을 보이고 있습니다.

지표를 보게되면 얼핏 Platform Thread 보다 안좋은거 아니야 라는 생각을 할 수 있는데 이는 지속적이고 건강한 시스템 콜 입니다 테스트가 끝날 때까지 완만한 능선 모양으로 지속적인 시스템 콜이 발생하는 것은, 플랫폼 스레드처럼 락 때문에 기절하는 비정상적인 호출이 아닙니다. 만 명의 가상 사용자의 요청으로 인한 네트워크 패킷을 처리하기 위해 스레드의 동적 상태 제어 및 실제 소켓 Read/Write 등 필수적인 하드웨어 자원 동기화 시스템 콜을 멈춤 없이 안정적으로 수행하고 있음을 보여주고 있습니다.

 

Thread State Trace 부분을 보면 플랫폼 스레드 때처럼 100% 위로 튀지 않고, 부하 유입 모양 그대로 유연한 곡선을 그리는 이유가 여기에 있습니다. 큐의 자물쇠 경쟁에서 패배한 수많은 가상 스레드들을 CPU 코어에서 끌어내리고 깨우는 무거운 작업을 OS 커널 스케줄러가 아니라 JVM 사용자 영역에서 전담했기 때문입니다 커널 레벨의 컨텍스트 스위칭 오버헤드가 방어되어 macOS 커널이 부하를 적절하게 받아내고 있음을 보여줍니다.

 

java 부분을 보면 CPU 사용량과 컨텍스트 스위칭 수치가 크게 튀지 않고, 전체 부하 진입(Ramp-up) 곡선과 완벽하게 정렬되어 일정하게 움직입니다. 이는 CPU 코어가 '무거운 스레드 상태 변경이나 TCB 장부를 갈아끼우는 오버헤드'에 자원을 사용한 것이 아님을 뜻합니다.
가상 스레드의 경량 유저 레벨 컨텍스트 스위칭 덕분에 CPU 시간이 오직 가상 사용자의 비즈니스 요청을 올바르게 처리하고 소모하는 일에 집중되었다고 할 수 있습니다

 

K6 지표 분석

 

k6의 HTTP 지표를 보게되면 avg=1.5s min=1.5s max=1.54s 등등 1.5초 근처(최대 1.54초)를 기록하고 있는 모습 입니다
상단에서 Park 상태인 막대만 보였지만 실질적으로 모든 요청이 성공적으로 응답이 왔다는것을 증명 합니다.

 

checks_succeeded, http_req_failed 부분을 보게되면 가상 사용자가 1분 10초 동안 무려 118,212번의 요청을 보냈지만 100% 성공적으로 처리 한 모습 입니다.

 

interrupted iterations 부분은 테스트가 강제로 종료되거나 부하 한계치를 넘어서 줄 서다가 타임아웃으로 잘려 나간 요청이 정확히 0건으로 나타나고 있습니다.

 

결론적으로 가상 스레드는 대기 지연이 큰 I/O 바운드 작업에서 물리 캐리어 스레드를 기절시키지 않고 JVM 사용자 영역 스케줄링으로 자원을 극대화 해 소프트웨어 아키텍처의 전환만으로 많은 처리량 향상과 최적의 자원 효율성 나타내고 있다.

'java' 카테고리의 다른 글

RAG(검색증강생성)를 위한 코사인 유사도(Cosine Similarity) 고속 연산  (0) 2026.07.03
Thread-Per-Connection 소켓 폭발 및 락 병목 스레드 덤프 분석  (0) 2026.06.26
Wrapper 객체 오버헤드 프로파일링 & 소켓 버퍼 벤치마크  (0) 2026.06.21
'java' 카테고리의 다른 글
  • RAG(검색증강생성)를 위한 코사인 유사도(Cosine Similarity) 고속 연산
  • Thread-Per-Connection 소켓 폭발 및 락 병목 스레드 덤프 분석
  • Wrapper 객체 오버헤드 프로파일링 & 소켓 버퍼 벤치마크
taetae-o
taetae-o
taetae-o 님의 블로그 입니다.
  • taetae-o
    taetae-o 님의 블로그
    taetae-o
  • 전체
    오늘
    어제
    • 분류 전체보기 (19)
      • HTTP (5)
      • 우테코 (4)
      • java (4)
      • ai (2)
      • spring boot (1)
      • 프로젝트 (1)
        • Tooplay (1)
      • DB (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    개발자
    꿈
    java
    백엔드
    spring
    대학생
    프리코스
    HTTP
    네이버
    Thread
    우테코
    벡엔드
    Spring Boot
    열심히
    Tomcat
    카카오
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
taetae-o
Platform Thread vs Virtual Thread의 AI I/O 블로킹 효율 비교
상단으로

티스토리툴바