본문 바로가기

Language/Java

가상스레드

가상 스레드는 자바 동시성 도구에 추가된 획기적인 기능이며, 개발자가 동시성 프로그램을 작성하는 방식을 근본적으로 바꾸고 있다. 덕분에 스레드를 굉장히 큰 규모의 동시성을 다루는 데 필수적인 기본 단위로 사용하는 것이 현실적으로 가능해졌다. 

 

가상 스레드는 플랫폼 스레드와 다르게 JVM에 의해 관리된다. 운영체제가 관리하는 스레드에서 JVM이 관리하는 스레드로의 전환은 단순히 구현 세부 내용만 달라진 것이 아니다. 애플리케이션의 메모리 고갈이나 성능 저하 없이 수백만 개의 스레드를 생성해서 사용하는 방식은 전통적인 플랫폼 스레드에서는 아예 불가능했지만 가상 스레드를 사용하면 가능하며, 덕분에 훨씬 더 편리하게 동시성 프로그램을 작성할 수 있게 됐다. 

 

가상 스레드는 캐리어 스레드(carrier thread) 위에서 실행되는데, 캐리어 스레드는 본질적으로 포크/조인 풀에서 가져온 스레드다. 이 덕에 가상 스레드는 고급 스레드 풀링 메커니즘과 효율적인 Work-Stealing 알고리즘의 장점을 그대로 물려받았다. JVM 내부에 구현된 가상 스레드 스케줄러가 Work-Stealing 방식의 ForkJoinPool을 기반으로 하지만, 선입선출(FIFO) 모드로 동작한다는 점이 중요하다. 

 

가상 스레드 스케줄러의 병렬성은 가상 스레드 스케줄링에 사용할 수 있는 플랫폼 스레드의 수를 의미하며 설정 가능한 파라미터다. 기본값은 시스템에서 사용 가능한 프로세서의 수로 설정되며, 이를 통해 하드웨어 자원을 최적으로 활용할 수 있다. 혹은 시스템 프로퍼티인 jdk.virtualThreadScheduler.parallelism을 사용해서 특정 애플리케이션 요구사항 및 작업 부하 특성에 맞도록 세밀하게 설정할 수 있다. 예를 들어, 자바에서 애플리케이션 시작 시 다음과 같이 -D 옵션으로 시스템 프로퍼티를 지정할 수 있다.

java -Djdk.virtualThreadScheduler.parallelism=4 -jar {appname.jar

자바의 두 가지 스레드 유형

가상 스레드가 도입되면서 자바에는 플랫폼 스레드와 가상 스레드, 이렇게 두 가지 종류의 스레드가 존재한다.

플랫폼 스레드

자바가 처음 만들어졌을 때부터 존재하던 네이티브 스레드이다. 네이티브 스레드 또는 운영체제 스레드라고 부르기도 하지만, JDK에서의 공식적인 이름은 플랫폼 스레드다. 

 

플랫폼 스레드는 운영체제에 의해 실행되는 무거운 스레드이며 스케줄링과 관리를 운영체제에 의존하고, 자바 스레드와 커널 스레드 사이에 일대일 관계를 유지한다. 또한 운영체제의 스케줄링과 컨텍스트 전환 메커니즘을 활용한다. 플랫폼 스레드는 자바 언어의 첫 시작부터 동시성 프로그래밍 모델의 근간이 되어왔다.

가상 스레드

사용자 모드 스레드 또는 경량 스레드라고 부르기도 하는 가상스레드는 JDK 21 부터 새로 도입된 자바의 동시성 모델이다. 플랫폰 스레드와 달리 전적으로 JVM에 의해 관리되며, 수백만 개의 스레드를 생성하더라도 시스템 자원이 고갈되지 않는다. 가상 스레드는 직접적으로 커널 스레드와 매핑되지 않고 다수의 가상 스레드가 적은 수의 캐리어 스레드(플랫폼 스레드를 의미) 풀을 공유한다. 덕분에 JVM이 상대적으로 적은 운영체제 자원으로도 많은 수의 가상 스레드를 효율적으로 다중화(multiplex)할 수 있다.

플랫폼 스레드와의 결정적인 차이

가벼움

가상 스레드는 플랫픔 스레드에 비해 훨씬 적은 양의 메모리를 사용하고 시스템 자원을 더 적게 소모한다. 그래서 수백만 개의 가상 스레드를 생성하더라도 시스템 자원이 고갈되지 않는다.

스케줄링

가상 스레드는 운영체제가 아닌 JVM에 의해 스케줄링되므로 CPU 사이클을 낭비 없이 더 잘 사용할 수 있고 운영체제 스레드 스케줄링에서 발생되는 오버헤드를 피할 수 있다.

블로킹 허용 능력

가상 스레드는 콘솔이나 파일, 네트워크에서 데이터를 읽을 때나 네트워크를 통해 파일에 저장하거나 또는 sleep 메서드 호출 같은 블로킹 연산을 수행할 때도 성능 저하가 발생하지 않는다. 블로킹 연산을 수행할 때 불필요하게 시스템 지원을 점유하지 않고 제어권을 캐리어 스레드에게 넘겨서, 다른 가상 스레드들이 계속 효율적으로 실행될 수 있기 때문이다.

매끄러운 통합

가상 스레드는 기존 코드베이스를 크게 변경할 필요 없이 매끄럽게 통합되도록 설계됐다. 개발자는 기존에 사용해 온 익숙한 코딩 패턴이나 추상화를 그대로 이어서 사용할 수 있다.

가상 스레드의 효과가 큰 상황

이상적인 상황을 가정할 때, 가상 스레드는 다음 특징을 가진 애플리케이션에서 처리량을 특히 더 향상할 수 있다.

  • 높은 동시 작업 수: 가상 스레드는 수천 개의 요청을 동시에 처리해야 하는 대용량 웹 서버나, 병렬로 많은 수의 I/O 집중적인 작업을 수행하는 애플리케이션에서 가장 이상적인 효과를 볼 수 있다.
  • CPU 집중적이지 않은 작업 부하: 가상 스레드는 CPU 집중적인 작업을 수행하는 시간보다 I/O 작업처럼 대기하는 시간이 많이 발생할 때 특히 유용하다. 

가상 스레드가 자바 애플리케이션에 새로운 수준의 확장성을 도입한 것은 사실이지만, 동시성 문제의 만병통치약은 아니라는 점에 주의해야 한다. 가상 스레드는 수많은 동시 작업이 CPU 집중적이지 않은 작업 부하와 결합될 때 가장 효과적일 가능성이 높다. 

이를 통해 개발자들은 CPU 시간이나 메모리에 부담을 주지 않고도 방대한 수의 요청을 처리할 수 있는 고수준의 동시성과 확장성을 갖춘 애플리케이션을 구축할 수 있다. 

가상 스레드 확장성의 근본 원칙

리틀의 법칙이란 지연 시간, 동시성, 처리량 사이의 수학적 관계를 보여준다. 공식은 '처리량 = 동시성 / 응답 시간' 이다.

 

전통적인 스레딩 모델은 운영체제 수준의 제약으로 인해 생성할 수 있는 스레드 수에 한계에 부딪힌다. 즉, 동시성 을 더이상 늘릴 수 없다. 따라서 처리량 을 높이기 위해서는 응답 시간 을 줄여야 한다. 그러나 I/O 집중적인 작업에서는 지연 시간 을 줄이는 것이 항상 개발자의 통제 범위 내에 있는 것은 아니다. 

 

반면, 가상 스레드를 사용하면 더 높은 동시성을 확보할 수 있으므로 전통적인 스레딩 모델의 제약에서 벗어날 수 있다. 즉, 응답 시간 을 줄이지 않고도 처리량 을 높일 수 있다.

 

정리하자면, 플랫폼 스레드를 사용할 때 더 많은 수의 스레드를 사용하면 처리량 을 늘릴 수 있지만, 운영체제 자원이 한정적이며 스레드 관리에 대한 부담은 병목 현상으로 이어질 수 있다. 반면 가상 스레드를 사용하면 지연 시간 을 줄이지 않더라도 동시성 을 높여서 처리량 을 높일 수 있다. 따라서 가상 스레드는 I/O 집중적인 작업처럼 지연 시간을 현실적으로 줄일 수 없는 상황에서 처리량을 높여야 할 때 유용하다. 하지만 가상 스레드가 모든 성능 문제를 해결해주는 것은 아니다. CPU 집중적인 작업량이 병목의 원인일 때는 가상 스레드가 전혀 도움이 되지 않는다.

가상 스레드 내부 동작 방식

스택 프레임과 메모리 관리

기존 플랫폼 스레드는 운영체제가 스레드마다 고정 크기의 연속된 스택 메모리를 미리 할당한다. 따라서 스레드에 필요한 스택 크기를 예측해야만 했고, 스레드 수가 많아질수록 메모리 사용량이 급격히 증가한다.

 

반면 가상 스레드스택 프레임을 GC의 대상이 되는 heap에 동적으로 저장한다. 따라서 스레드에 필요한 스택 크기를 예측할 필요다 없다. 가상 스레드가 사용할 수 있는 메모리 사용 공간은 수백 바이트에서 시작하며, 호출 스택이 커지고 줄어듦에 따라 자동으로 조정된다. 이러한 동적 메모리 관리를 통해 자원 효율성을 크게 높일 수 있다. 

캐리어 스레드와 운영체제의 개입

운영체제는 가상 스레드의 존재를 알지 못하며, 오직 운영체제 수준의 스케줄링 단위인 플랫폼 스레드만 인식할 수 있다. 자바 런타임은 코드를 가상 스레드에서 실행하기 위해 가상 스레드를 플랫폼 스레드에 마운트(mount)하는데, 이때 사용되는 플랫폼 스레드를 캐리어 스레드라고 부른다. 캐리어 스레드는 특화된 ForkJoinPool의 일부다. 마운트 과정에는 힙에 있는 스택 프레임을 캐리어 스레드의 스택으로 임시 복사하는 것도 포함된다. 

블로킹 연산 처리

가상 스레드가 I/O 대기처럼 일반적으로 스레드를 블로킹하는 연산을 만나면, 캐리어 스레드로부터 언마운트(unmount)될 수 있다. 캐리어 스레드의 스택에 복사된 후 코드가 실행되면서 변경이 발생한 스택 프레임 내용이 힙으로 다시 복사되고, 캐리어 스레드는 자유로운 몸이 되어 다른 작업을 수행할 수 있게 된다. 가상 스레드는 이런 블로킹 연산 처리 방식 덕분에 자원 효율성을 극도로 높일 수 있다. 

투명성과 비가시성

가상 스레드를 마운트하고 언마운트하는 과정은 투명해서 자바 코드에서 보이지 않는다. 현재 어떤 캐리어 스레드가 가상 스레드를 마운트해서 실행하는지 코드상에서 식별할 수 있는 방법은 없다. 캐리어 스레드의 ThreadLocal 값조차 가상 스레드에게는 보이지 않는다. 이런 높은 수준의 추상화 덕분에 기존 자바 코드베이스를 변경할 필요 없이 가상 스레드를 완벽하게 사용할 수 있게 된다. 

 

가상 스레드 개념은 가상 메모리 시스템과 유사하다. 가상 메모리 시스템에서 애플리케이션은 사실상 크기가 무제한인 주소 공간에 접근하는 듯한 환상 속에서 실행된다. 이는 가상 메모리의 어떤 부분이 실제 메모리에, 어떤 부분이 디스크에 매핑되는지를 하드웨어 수준에서 판별해서 처리하기 때문에 가능하다. 이와 유사하게, 가상 스레드는 희소하고 비용이 많이 드는 플랫폼 스레드를 공유함으로써 사실상 무제한적인 멀티스레딩의 환상을 만들어낸다. 가상 메모리 시스템에서 사용되지 않는 메모리 페이지가 디스크로 '페이지 아웃'되는 것처럼, 사용되지 않는 가상 스레드의 스택은 힙으로 '페이지 아웃'된다. 

비동기 연산 단순화

가상 스레드를 사용하면 비동기 연산과 태스크 집계(aggregation)를 쉽게 할 수 있고, 비동기 태스크가 종료될 때까지 기다려야 하는 블로킹 부담이 사라진다. 그래서 Future의 블로킹 메서드인 get을 호출하더라도 성능 저하를 걱정할 필요가 없다. 

 

특히 가상 스레드가 빛을 발휘하는 곳은 다수의 비동기 태스크 결과를 집계할 때다. 현대 애플리케이션은 다수의 태스크를 동시에 처리하고 결과를 효율적으로 집계해야 할 때가 많은데, 이에 가상스레드가 유용하다.

가상 스레드 주의사항: 요청 제한을 통한 자원 제약 관리

가상 스레드를 사용하여 수많은 요청을 받아 처리할 수 있지만, 웹 애플리케이션이 사용하는 데이터베이스는 그렇게 많은 요청을 처리하지 못할 수도 있다. 가상스레드 이전에는 스레드 풀을 통해 요청을 제한할 수 있었다. 즉, 스레드 풀이 1,000개의 스레드가 있다면, 스레드 풀을 통해 접근할 수 있는 밑바탕의 자원은 기껏해야 1,000개의 동시 부하만 발생하고 그 이상은 불가능했다. 그러나 가상 스레드는 사실상 무제한일 수 있기 때문에 과거와는 다른 난관에 맞닥뜨리게 된다. 그래서 가상 스레드를 사용할 때는 서비스 과부하를 어떻게 방지할지 고민해야 한다. 이 문제를 해결하려면 결국 접근하려는 자원에 특화된 요청 제한(rate limiting) 메커니즘이 필요하다. 

가상 스레드의 한계

pinning

가상 스레드에는 고정(pinning)이라고 알려진 제약사항이 있으며, 이로 인해 잠재적으로 애플리케이션의 확장성과 성능에 좋지 않은 영향을 줄 수 있다.

 

고정은 가상 스레드가 자신의 캐리어 스레드에 묶여서 고정되는 상황을 의미한다. 고정된 상태에 빠진 가상 스레드는 블로킹 연산을 실행할 때 캐리어 스레드로부터 언마운트할 수 없으며, 고정돼 있는 동안 해당 캐리어 스레드를 독점적으로 점유하게 된다. 

 

고정은 주로 두 가지 시나리오에서 발생한다.

  • synchronized 블록 또는 메서드
  • 네이티브 메서드 또는 함수

고정된 가상 스레드가 캐리어 스레드를 범유하므로 그만큼 사용 가능한 캐리어의 수가 줄어들고, 다른 가상 스레드들은 사용 가능한 캐리어 스레드를 더 오래 기다려야 한다. 결과적으로 시스템의 전반적인 처리량은 떨어진다.

 

synchronized 블록이나 메서드 대신, ReentrantLock을 사용하면 블로킹 발생 시 가상 스레드가 언마운트되므로 캐리어 스레드가 다른 태스크를 실행할 수 있다.

TreadLocal 변수의 문제

ThreadLocal 클래스를 사용하면 변수를 생성한 스레드만 읽고 쓸 수 있는 변수를 만들 수 있다. 덕분에 여러 스레드가 동시에 동일 데이터에 접근하는 상황에서 동기화를 사용할 필요가 없어진다. ThreadLocal의 사용 사례는 다음과 같다.

  • 자원 격리: ThreadLocal 변수는 스레드 안전하지 않은 자원을 저장할 때 사용된다. 각 스레드가 서로 다른 변수나 인스턴스를 가짐으로써 충돌을 피할 수 있다.
  • 암묵적 컨텍스트: 데이터베이스 연결, 사용자 세션 데이터, 트랜잭션 ID와 같이 스레드가 수행하는 태스크와 관련된 컨텍스트 정보를 저장하는데 사용된다.

그러나 가상 스레드에서 ThreadLocal을 과도하게 사용하면 다음과 같은 문제가 생길 수 있다.

  • 메모리 소비: 수백만 개의 가상 스레드가 각각 ThreadLocal 변수의 복사본을 갖게 되면, 특히 저장되는 데이터 크기가 클 경우 메모리 사용량이 급증할 수 있다.
  • 오버헤드: ThreadLocal을 초기화하고 정리하는 작업에는 오버헤드가 따른다. 적은 수도 아니고 수백만 개의 가상 스레드에서는 이런 오버헤드도 성능에 큰 부담이 될 수 있다.
  • 상속: 가상 스레드는 전통적인 스레드처럼 부모 스레드로부터 ThreadLocal 값을 상속받는다. 이 상속은 추적과 디버깅을 어렵게 만드는 미묘한 버그를 유발할 수 있다. 

Reference

  • 모던 자바 동시성 프로그래밍 | ANM 바즐루어 라만 | 책만