Java 25 가상 스레드, 진짜로 1만 개를 띄워봤다 - 실무 도입 전 알아야 할 것들
오늘 올라온 목록 중에 "I Started 10,000 Java Threads. My Laptop Barely Noticed."라는 글이 눈에 띄었다. 제목만 보면 어그로 같은데, 사실 Java 21에서 정식 도입되고 25에서 더 다듬어진 가상 스레드(Virtual Threads, 프로젝트 Loom)를 두고 하는 얘기다. 인프라/백엔드 쪽에서 일하다 보면 "스레드 풀 사이즈 얼마로 잡아야 하냐"는 질문을 한 달에 한 번은 받는데, 이 기능은 그 질문 자체를 바꿔버린다. 한번 제대로 정리해보자.
왜 지금 가상 스레드인가
전통적인 자바 스레드는 OS 스레드와 1:1로 매핑된다. OS 스레드 하나가 보통 1MB 안팎의 스택 메모리를 잡고, 컨텍스트 스위칭도 커널이 관리한다. 그래서 수천 개만 띄워도 메모리가 터지거나 스케줄링 오버헤드로 시스템이 버벅인다. 우리가 톰캣 스레드 풀을 200, 400 이런 식으로 제한해온 이유가 바로 이거다.
문제는 웹 서버 워크로드 대부분이 CPU를 빡세게 쓰는 게 아니라 I/O 대기라는 점이다. DB 쿼리 날리고 응답 기다리고, 외부 API 호출하고 기다리고. 이 기다리는 동안 OS 스레드는 그냥 블로킹된 채 놀고 있다. 비싼 자원을 깔고 앉아서 아무것도 안 하는 거다.
가상 스레드는 이 구조를 뒤집는다. 수많은 가상 스레드를 소수의 OS 스레드(캐리어 스레드) 위에서 돌리고, 블로킹이 발생하면 가상 스레드를 캐리어에서 떼어내(unmount) 다른 가상 스레드를 올린다. 결과적으로 "스레드를 아끼지 말고 요청당 하나씩 그냥 만들어라"가 가능해진다.
동작 원리, 코드로 보면
일단 1만 개 띄우는 건 진짜 별거 아니다.
// Java 21+ 가상 스레드 1만 개
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // I/O 대기 흉내
return i;
});
});
} // close에서 모든 작업 완료까지 대기
플랫폼 스레드로 같은 걸 하면 메모리 부족으로 죽거나 한참 버벅인다. 가상 스레드는 스택을 힙에 저장하고, 필요할 때만 캐리어 스레드에 올린다. 핵심은 Thread.sleep이나 소켓 read 같은 블로킹 지점에서 JVM이 알아서 마운트/언마운트를 처리한다는 거다.
비유하자면 이렇다. 기존 방식은 손님(요청)마다 전담 직원(OS 스레드)을 한 명씩 붙여놓고, 손님이 화장실 갈 때(I/O 대기)도 직원이 문 앞에서 멍하니 기다리는 식이다. 가상 스레드는 직원 몇 명이 여러 손님을 돌아가며 응대하다가, 손님이 자리를 비우면 그 직원은 바로 다른 손님한테 간다. 직원(캐리어 스레드)은 적게 두고도 손님을 훨씬 많이 받는다.
// Spring Boot 3.2+ 에서는 설정 한 줄
spring.threads.virtual.enabled=true
톰캣 요청 처리를 가상 스레드로 돌리는 것도 이 정도로 간단하다. 다만 "켰으니 끝"은 절대 아니다. 여기서부터가 실무다.
실무에서 만나는 함정들
1. synchronized 안에서 블로킹하면 핀(pinning) 발생. 가장 악명 높은 함정이다. synchronized 블록 안에서 블로킹 I/O를 하면 가상 스레드가 캐리어에 "고정(pin)"되어 언마운트가 안 된다. 이러면 캐리어 스레드가 묶여서 가상 스레드의 장점이 사라진다. 레거시 코드나 오래된 라이브러리에 synchronized로 감싼 DB 접근 같은 게 숨어 있으면 성능이 기대만큼 안 나온다. 해결책은 ReentrantLock으로 바꾸는 것. 참고로 JDK 24부터는 이 핀 문제가 상당히 완화된 것으로 보이니, 정확한 동작은 사용하는 JDK 버전 릴리스 노트를 확인하는 게 좋다.
2. 스레드 풀로 가상 스레드를 재사용하려 하지 마라. 가상 스레드는 값싸게 만들고 버리는 게 전제다. 기존 습관대로 newFixedThreadPool 같은 데 넣고 풀링하면 의미가 없다. 풀링은 "비싼 자원을 재사용"하는 패턴인데, 가상 스레드는 안 비싸다. 작업당 하나씩 만들고 끝나면 버려라.
3. ThreadLocal 남용 주의. 가상 스레드가 수만 개 떠 있는데 각각 무거운 ThreadLocal 객체를 들고 있으면 메모리가 그만큼 곱해진다. 기존엔 스레드가 수백 개라 신경 안 썼던 부분이 터질 수 있다. Java는 대안으로 ScopedValue를 밀고 있다.
4. CPU 바운드 작업엔 효과 없다. 가상 스레드는 I/O 대기를 효율화하는 거지 연산을 빠르게 하는 게 아니다. 암호화, 이미지 처리, 빡센 계산 위주라면 그냥 코어 수만큼의 플랫폼 스레드가 맞다. 이걸 착각하고 도입하면 "왜 안 빨라지냐"는 소리가 나온다.
5. 다운스트림 폭격. 요청을 무한정 받아들일 수 있게 되면서, 그 뒤에 있는 DB 커넥션 풀이나 외부 API가 병목이 된다. 예전엔 톰캣 스레드 200개가 자연스러운 백프레셔 역할을 했는데, 가상 스레드를 켜면 그 방어막이 사라진다. 가상 스레드는 무제한이지만 DB 커넥션 풀은 여전히 유한하다는 걸 잊으면, 커넥션 대기 큐가 폭발하거나 DB가 먼저 쓰러진다. 세마포어 등으로 동시성 제한을 따로 거는 설계가 필요하다.
정리
한 줄 요약: 가상 스레드는 I/O 대기가 많은 워크로드에서 "스레드 풀 사이즈 고민"을 없애주는 도구지, 만능 성능 향상 스위치가 아니다.
외부 API 호출이 많거나 DB 의존도가 높은 백엔드 서버라면 도입 효과가 크다. 다만 켜기 전에 (1) 코드와 라이브러리에 synchronized 블로킹이 없는지, (2) DB 커넥션 풀 같은 다운스트림 한계에 대한 백프레셔가 있는지부터 점검하자. 반대로 CPU 바운드 배치 작업이라면 굳이 손댈 이유가 없다. 새 프로젝트라면 Spring Boot 설정 한 줄로 켜고 부하 테스트로 검증해보는 걸 추천한다. 핀 발생 여부는 -Djdk.tracePinnedThreads=full 옵션으로 잡아낼 수 있으니 도입 초기에 꼭 확인하길.