Java 21부터 공식적으로 가상 스레드 개념이 우리 Spring Boot 프로젝트에 들어왔다. 운영 환경에서의 성능이 개선될 수 있다는 하나만 보고 관련해서 많은 공부를 해봤는데, 단순히 spring.threads.virtual.enabled=true로 설정한다고 끝나는 일은 절대 아니다 싶다. 배운 내용을 바탕으로 결론 내린 사항을 아래와 같은 항목으로 정리해 볼 것이니, 여전히 사람이 작성한 게시물로 도움을 받는 사람들에게 조금이나마 보탬이 되기를 바란다.
1. 가상 스레드를 사용하는 건 기존 플랫폼(캐리어) 스레드만 사용하는 것과 어떻게 다른가?
2. 가상 스레드를 쓴다고 하면 어떻게 써야 하는가?
3. 가상 스레드 기반 모델은 왜 문제가 될 수 있는가?
4. 가상 스레드 기반 모델을 쓰기 위해 어떤 작업이 선행되어야 하는가?
1. 가상 스레드를 사용하는 건 기존 플랫폼 스레드만 사용하는 것과 어떻게 다른가?
우리 프로젝트의 코드는 주로 I/O 집약적으로 구성되어 있는데, 예를 들어 한 요청이 완료되기 위해 총 5번의 DB 커넥션 작업(넓게는 I/O 작업)이 요구된다고 하자. 그러면, 대략적으로 아래와 같은 방식으로 동작한다는 말이 된다(현재 맥락에서 필요 없는 부분은 과감히 제거하였다).
1. 코드 실행 중에 I/O 작업을 만난다.
2. 스레드는 Blocked 상태로 전환되고, CPU에 대한 권한을 반납하며 웨이트 큐(Wait Queue)에 진입한다.
3. CPU는 컨텍스트 스위칭을 통해 레디 큐(Ready Queue)에 있는 다른 스레드를 Running 상태로 만든다(실행한다).
4. 추후 I/O 작업이 끝나면 해당 스레드가 다시 레디 큐로 돌아온다.
5. 해당 스레드가 실행되는 순서는 CPU 스케줄링에 따른다.
여기서 중요한 건 컨텍스트 스위칭이 발생했다는 것 자체이고, 이는 CPU가 아이들(idle) 시간을 보냈다는 점을 의미하기도 한다. 성능 최적화를 위해서는 CPU가 아이들 시간 없이 최대한 많은 일을 하도록 해야 하므로, 컨텍스트 스위칭이 덜 일어나도록 코드를 실행하는 환경을 조정할 수 있다면 고려해봄직할 것이다. I/O 작업 한 번당 컨텍스트 스위칭이 총 2번 일어나고(스레드가 CPU 권한을 반환할 때, 다시 얻을 때), 요청 한 번당 I/O 작업이 5번이면 총 10번의 컨텍스트 스위칭이 발생한다. 내장 Tomcat이 관리하는 최대 스레드(server.tomcat.threads.max)가 100개일 때 이 스레드가 모두 위와 같은 조건으로 실행된다면, 벌써 총 1,000번의 컨텍스트 스위칭이 발생한다. CPU 코어 수가 적을수록 컨텍스트 스위칭으로 인해 CPU가 아이들 상태로 남는 비율도 올라갈 것이다.
가상 스레드의 본질적인 목적은 이 컨텍스트 스위칭을 상당 부분 없애는 것이다. 가상 스레드는 캐리어 스레드와 함께 동작하는데, 기존의 플랫폼 스레드가 당장 작업을 수행할 수 없으면 블로킹되던 것과 달리 가상 스레드는 작업 수행이 불가하면 자신이 갖고 있던 데이터를 힙 메모리에 보관하고 "언마운트"된다. 캐리어 스레드는 그저 다른 가상 스레드를 "마운트"하여 함께 작업을 이어나갈 뿐이며, 플랫폼 스레드 기반 실행 모델에서 벌어지는 컨텍스트 스위칭은 이 과정에서 일어나지 않는다. 가상 스레드의 이러한 장점은 앞에서 제시했던 I/O 집약적인 App 또는 대량의 요청을 처리하는 App에서 더욱 빛을 발한다. 둘 모두 컨텍스트 스위칭이 빈번하게 일어난다는 특징이 있다.
2. 가상 스레드를 쓴다고 하면 어떻게 써야 하는가?
가상 스레드의 장점이 무엇인지 알았다면, 이제 이러한 기술을 어떻게 적용해야 할지 고민할 차례이다. 다만 가상 스레드를 사용할 때는 기존 플랫폼 스레드 기반 환경에서만 개발을 했을 때는 미처 깨닫지 못했을 수도 있는 많은 문제를 고려할 필요가 있다. 만약에 플랫폼 스레드로부터 가상 스레드를 생성하는 방법을 고안한다고 하자. 이럴 때 생기는 문제 중 하나는 가상 스레드가 기존 플랫폼 스레드의 ThreadLocal 객체를 전혀 사용할 수 없다는 점이다. 이 객체들은 태생부터 스레드 독립적으로 설계되었기 때문이다. 이로 인해 사용할 수 없게 되는 객체 중 하나로 Connection이 있다. 따라서, 가상 스레드를 쓴다고 할 때 단순히 플랫폼 스레드로부터 가상 스레드를 생성한다면 원래보다 더욱 많은 Connection 객체가 요청에 사용되므로, 가용한 Connection이 줄어드는 효과가 발생할 것이다.
가용한 플랫폼 스레드 또한 마찬가지로 줄어드는 효과가 발생한다. 원래는 Thread-Per-Request 모델에 의거하여 플랫폼 스레드 하나만으로 모든 요청을 다룰 수 있는데, 플랫폼 스레드에서 가상 스레드를 생성한다면 가상 스레드를 실행하는데 필요한 캐리어 스레드가 추가적으로 소모된다. 결국 한 번에 다룰 수 있는 요청의 수 자체도 줄어들게 된다. 이런 점으로 미루어 점차 명확해지는 사실은 가상 스레드에서 "무한"한 건 가상 스레드 뿐이라는 것이다. 나머지 인프라의 현황은 기존 플랫폼 스레드 모델과 다를 게 없다. 이를 무시하고 개발한다면 어떻게 될까? 예를 들어 가상 스레드 10,000개가 최대 100개인 커넥션 풀에 연결을 시도한다고 하자. 그러면 100개만 DB와 상호 작용을 할 뿐 나머지 9,900개는 대기해야 한다. 이후에 타임아웃 오류가 터지면 대량의 오류가 서버를 뒤덮을 수 있고, 결국 서버가 마비되는 것도 충분히 가능하다. 즉, 가상 스레드를 적용할 때는 여전히 유한한 리소스를 어떻게 다루느냐가 중요한 고려사항이 된다.
정답은 가상 스레드를 생성하는 플랫폼 스레드, 그 자체를 가상 스레드로 바꾸는 것이다. 플랫폼 스레드 기반 모델에서 가상 스레드 기반 모델로 전환하는 것을 의미하기도 한다. 이를 달성하기 위해서는 단 하나의 프로퍼티만 설정하면 된다. 아래와 같다.
spring.threads.virtual.enabled=true
이렇게 하면 개별적인 요청에 플랫폼 스레드 대신 가상 스레드가 할당된다. 즉, 이제 요청이 I/O 작업을 만난다거나 하면 해당 가상 스레드는 캐리어 스레드로부터 언마운트되며, 캐리어 스레드는 자신을 요구하는 다른 가상 스레드를 마운트시키고 계속해서 실행된다. 이제 플랫폼 스레드 수준의 컨텍스트 스위칭은(비록 여러 이유로 완전히 일어나지 않는 건 아닐 수 있지만) 상당히 줄어들고, CPU 효율성도 더 높아진다. 가상 스레드가 언마운트 / 마운트되는 동안 데이터 증발을 우려할 필요도 없다. 가상 스레드는 언마운트될 때 ThreadLocal, 실행 스택 및 스택 프레임 등에 관한 정보를 힙 메모리에 저장하기 때문이다. 그리고 마운트될 때 해당 데이터를 불러와서 다시 사용할 수 있다(이러한 과정을 통틀어 컨티뉴에이션(Continuation)이라고 부른다).
이제 문제는 없는 것처럼 보일 수 있지만, 과연 그럴까?
3. 가상 스레드 기반 모델은 왜 문제가 될 수 있는가?
가상 스레드 기반 모델이 가진 문제는 크게 두 가지에서 비롯한다. 첫 번째는 피닝(Pinning)이다. synchronized 블록에서 가상 스레드가 캐리어 스레드로부터 언마운트되지 못하는 현상이다. synchronized 블록을 안 써서 괜찮다고 생각할 수도 있겠지만, 가상 스레드 "모델"은 모든 요청에 대해서 가상 스레드를 사용하겠다고 하는 것이다. 자신의 프로젝트에서 사용하고 있는 의존성이 프로젝트 어딘가에서는 사용되고 있을 텐데, 만약 그런 곳에서 synchronized 블록을 쓰고 있다면 꼼짝없이 피닝 현상으로 고통받을 수 있는 여지가 생긴다. 물론 Java는 Java 24 버전부터 <JEP 491: Synchronize Virtual Threads without Pinning>을 통해 synchronized 블록에서 일어나는 피닝을 근본적으로 해소했지만 그건 Java 24고, Java 21에서 문제는 그대로 남아 있다. 그래서 가상 스레드를 사용하려면 Java 버전을 25(최신 LTS 버전)로 업그레이드하는 것이 강력히 권장된다.
두 번째로 거론할 원인은 요청당 가상 스레드를 생산하는 방식이다. 이것 자체는 지극히 당연하다. 가상 스레드는 플랫폼 스레드에 비해 생성하고 폐기하는 비용이 매우 싸서 기존의 고정 스레드를 활용하는 스레드 풀 기반 방식이 비효율적이기 때문이다. 문제는 대량의 요청을 관리하는 데 있다. 플랫폼 스레드 모델에서는 스레드 풀에 포함된 스레드 개수가 들어오는 요청을 자연스럽게 쓰로틀링(Throttling)했다. 요청이 상당히 많이 들어온다고 해도 어차피 스레드 풀에서 정의된 최대 스레드를 초과하는 수의 스레드는 실행되지 않기 때문에, 당장 실행되고 있는 스레드의 활동에 대한 영향은 제한적이다. 하지만 가상 스레드는 최대 스레드 수가 그런 식으로 미리 정의되어 있지 않다. 요청이 들어오면 그저 그에 대한 가상 스레드를 하나씩 할당할 따름이다. 그러다 보니 이미 언급했듯이 리소스의 고갈이라든지 대규모의 타임아웃 오류 발생과 같은 상황이 일어날 수 있다.
4. 가상 스레드 기반 모델을 쓰기 위해 어떤 작업이 선행되어야 하는가?
위의 논의를 토대로 하여, 우리는 가상 스레드를 도입하겠다고 마음 먹었다면 그에 맞춰 준비가 필요하다는 것을 알게 되었다. 아래에서는 그 준비로 무엇무엇이 있는지를 열거한다(Java 버전 업그레이드 제외).
1. 비율 제한자(Rate Limiter)
들어오는 요청을 쓰로틀링하기 위한 구성 요소이다. 가상 스레드 모델에서 요청당 가상 스레드가 생성되는 것은 막을 수 없기 때문에, 실행하는 요청 수 자체를 제한하는 쪽으로 접근하는 것이 옳다. 가상 스레드 모델은 들어오는 요청을 어떻게 처리할지에 관한 것일 뿐, 요청이 얼마나 들어올 수 있는지까지 관리하지 않는다. 비율 제한을 적용함으로써 서버가 넘쳐나는 트래픽을 감당하지 못하는 경우를 차단하고, 어느 경우에도 일정량의 요청을 안정적으로 처리할 수 있도록 보장할 수 있다. 적용하는 알고리즘에 따라 트래픽의 스파이크에도 대응할 수 있도록 하기 때문에, 단순히 server.tomcat.threads.max를 설정하는 것보다 유연한 구성을 채택할 수도 있을 것이다.
2. 세마포어(Semaphore)
비율 제한자를 사용한다고 할지라도, 우리 서버가 감당할 수 있는 요청 수준을 잘못 판단할 수도 있을 것이다. 이럴 때 서버가 압도되는 것을 막으려면 요청이 리소스를 거덜내지 않도록 "안전 장치"가 필요하다. 세마포어(특히, 카운팅 세마포어)가 그런 역할을 할 수 있다. 세마포어는 App이 특정한 리소스에 접근할 수 있는 최대치를 설정하고, 그 이상의 접근이 발생한다면 해당 요청들을 블로킹으로 대기시키도록 한다. 이를 통해 외부 서비스가 많은 접근으로 인해 병목을 일으키는 현상을 막을 수 있어 가용성을 보호할 수 있다. 이런 점에서는 비율 제한자와 비슷하다고 표현할 수 있겠다.
3. 성능 테스트(Performance Test)
비율 제한자와 세마포어 관련 설명에서 암시했듯, 우리는 서버가 얼마나 많은 요청을 감당할 수 있는지(#1), 요청의 급등을 견딜 수 있는지(#2), 그리고 과도한 요청이 발생한다면 어떻게 행동하는지(#3)를 알 필요가 있다. 성능 테스트는 이들을 알기 위해 필수적으로 거쳐야 하는 과정이라고 할 수 있다. 필자가 아는 성능 테스트는 3가지로 구성된다: 부하 테스트(Load Test), 스파이크 테스트(Spike Test), 그리고 스트레스 테스트(Stress Test). 각각 #1, #2, #3을 위한 것이다. 이들을 철저하게 한다면 우리는 어떤 상황에서도 서버가 가용하도록 하고, 유저에게 최상의 응답을 줄 수 있게 될 것이다.
포스팅을 마치며,
지금까지 가상 스레드에 관해 알아 보았는데, 개인적으로 이에 관해 알아보면서 여러 궁금증을 해소하였고, 그로 인해 얻은 바가 많다. 예를 들자면 플랫폼 스레드에서의 동작 방식을 파고들다보니 스레드가 블로킹되고 다시 실행되기까지 어떤 일이 일어나는지 배우게 되었는데, 그 과정에서 프로세스의 상태라든지 OS 및 CPU 수준에서 프로세스를 관리하기 위해 어떤 장치들을 소유하고 있는지까지 연결지어 이해할 수 있었다. 또한 server.tomcat.threads.max가 어떤 식으로 적용되는지를 파보다 보니 Tomcat을 구성하고 있는 요소(Catalina, Coyote)들이 어떻게 일을 분담하고 있는지와 내장 Tomcat에서 요청을 어떤 식으로 처리하는지를 NIO 소켓과 연결지어 정리할 수 있었다.
공부를 함에 따라 CS가 실 서버 운영과는 딱히 관련이 없는 것처럼 보여도 모두 큰 그림을 그리는 데 필요한 지식이라는 점을 더욱 깊숙이 깨닫고 있다. 더 치열하게 질문하고, 답을 얻고, 얻은 바를 정리하다 보면 훗날에 이들 모두가 백엔드 전반을 이해하고 우리 프로젝트에 필요한 기능을 적용하는데 도움이 될 것으로 믿어 의심치 않는다.
'웹 개발 (Spring Boot)' 카테고리의 다른 글
| 데이터 스토리지 솔루션 고르기 (2) (0) | 2026.01.10 |
|---|---|
| 데이터 스토리지 솔루션 고르기 (1) (0) | 2026.01.01 |
| 검증을 어디서 할지 정하기 (1) | 2025.12.22 |
| 레이어 아키텍처에서 클린 아키텍처 + DDD로 리팩토링하기 (2) (1) | 2025.12.10 |
| 레이어 아키텍처에서 클린 아키텍처 + DDD로 리팩토링하기 (1) (0) | 2025.12.04 |