Contents
see List가상 스레드를 도입하기 전에 확인할 것
Java 가상 스레드는 요청마다 스레드를 하나씩 배정하는 코드를 더 단순하게 만들 수 있습니다. 특히 HTTP 호출, 데이터베이스 조회, 파일 I/O처럼 기다리는 시간이 긴 업무에서 효과가 큽니다. 하지만 기존 플랫폼 스레드 풀을 그대로 가상 스레드로 바꾸기만 하면 장애가 줄어드는 것은 아닙니다. 가상 스레드는 생성 비용이 낮지만 외부 시스템의 처리량, 데이터베이스 커넥션 수, API 제한, 로그 추적 방식은 그대로 남아 있습니다. 그래서 운영 환경에서는 “스레드를 많이 만들 수 있다”보다 “어디까지 동시에 실행해도 되는가”를 먼저 정해야 합니다.
가상 스레드 전환 후보는 블로킹 작업이 많고, 작업 단위가 짧으며, 스레드 로컬 사용이 과하지 않은 코드입니다. 반대로 CPU 계산이 대부분인 작업, 긴 시간 락을 잡는 작업, 커넥션 풀 크기보다 훨씬 많은 DB 호출을 한꺼번에 보내는 작업은 효과가 제한적입니다. 먼저 현재 요청 흐름을 HTTP 수신, 검증, 외부 API 호출, DB 저장, 응답 조립처럼 나누고 각 단계가 실제로 기다리는 시간인지 계산하는 시간인지 구분해야 합니다.
Executor를 바꾸되 동시성 제한은 따로 둔다
가상 스레드는 보통 작업마다 새 스레드를 만드는 실행기를 사용합니다. 중요한 점은 이 실행기가 처리량 제한 장치가 아니라는 것입니다. 기존 고정 스레드 풀은 동시에 실행되는 작업 수를 제한하는 효과가 있었지만, 가상 스레드 실행기는 더 많은 작업을 받아들일 수 있습니다. 따라서 외부 API, 파일 변환, 대량 DB 조회처럼 보호해야 하는 구간에는 세마포어, 큐, RateLimiter, 커넥션 풀 설정 같은 별도 제한을 둬야 합니다.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class VirtualThreadBatchClient {
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
private final Semaphore apiLimit = new Semaphore(20);
public List<String> fetchAll(List<URI> uris) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = uris.stream()
.map(uri -> executor.submit(() -> fetchOne(uri)))
.toList();
return futures.stream()
.map(future -> {
try {
return future.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList();
}
}
private String fetchOne(URI uri) throws Exception {
if (!apiLimit.tryAcquire(Duration.ofSeconds(2))) {
throw new IllegalStateException("external api concurrency limit exceeded");
}
try {
var request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(5))
.GET()
.build();
return client.send(request, HttpResponse.BodyHandlers.ofString()).body();
} finally {
apiLimit.release();
}
}
}
위 예제의 핵심은 가상 스레드를 작업 실행 방식으로 쓰면서, 외부 API에 대해서는 동시 실행 수를 20개로 제한한다는 점입니다. 이 제한이 없으면 순간적으로 수백 개 요청이 나가고, 상대 서버의 429 응답이나 내부 네트워크 대기열 증가가 발생할 수 있습니다. 데이터베이스도 마찬가지입니다. 커넥션 풀이 30개인데 가상 스레드 500개가 동시에 쿼리를 기다리면 스레드 부족은 사라져도 커넥션 대기는 늘어납니다. 운영 지표에는 스레드 수뿐 아니라 커넥션 대기 시간, 외부 API 오류율, 타임아웃 비율을 함께 넣어야 합니다.
ThreadLocal과 로그 추적을 점검한다
웹 애플리케이션은 요청 ID, 사용자 ID, 테넌트 ID를 ThreadLocal이나 MDC에 넣어 로그를 남기는 경우가 많습니다. 가상 스레드는 작업마다 새로 만들어지는 방식이 자연스럽기 때문에 플랫폼 스레드 풀의 재사용 문제는 줄어들 수 있지만, 값을 넣고 지우는 규칙은 여전히 필요합니다. 특히 비동기 작업 안에서 요청 컨텍스트를 복사하지 않으면 로그가 끊기거나, 반대로 정리하지 않은 값이 예외 경로에서 남아 분석을 어렵게 만들 수 있습니다.
- 요청 시작 시 requestId를 생성하고 모든 외부 호출 로그에 포함합니다.
- 작업 제출 전에 필요한 컨텍스트만 명시적으로 전달합니다.
- ThreadLocal에 큰 객체, 인증 원문, 응답 본문 전체를 저장하지 않습니다.
- finally 블록에서 MDC와 ThreadLocal 정리를 보장합니다.
락과 동기화 구간은 짧게 유지한다
가상 스레드가 많아져도 synchronized, 데이터베이스 행 잠금, 파일 잠금 같은 공유 자원 대기는 그대로 병목이 됩니다. 특히 오래 걸리는 I/O를 락 안에서 실행하면 많은 가상 스레드가 같은 지점에 몰립니다. 전환 전에는 synchronized 메서드, 전역 캐시 갱신, 단일 파일 쓰기, 순번 채번 로직을 점검해야 합니다. 락 안에서는 상태 확인과 값 교체만 수행하고, HTTP 호출이나 DB 조회는 락 밖으로 빼는 것이 기본입니다.
또한 장애 전파를 막으려면 모든 블로킹 호출에 타임아웃을 둬야 합니다. 가상 스레드는 대기 비용을 낮춰주지만 무한 대기를 안전하게 만들어주지는 않습니다. HTTP connect timeout, request timeout, JDBC query timeout, 커넥션 풀 acquisition timeout을 각각 명시하고, 타임아웃이 발생했을 때 재시도할 수 있는 작업과 즉시 실패해야 하는 작업을 구분해야 합니다. 재시도는 짧은 지수 백오프와 최대 횟수를 두고, 결제나 주문처럼 중복 실행이 위험한 작업은 멱등 키를 같이 설계해야 합니다.
운영 전환 순서
- 요청 경로 중 블로킹 I/O 비율이 높은 기능을 한두 개만 고릅니다.
- 기존 고정 스레드 풀 크기가 맡고 있던 동시성 제한 역할을 목록으로 정리합니다.
- 외부 API, DB, 파일 처리마다 별도의 제한값과 타임아웃을 설정합니다.
- 요청 ID, MDC, ThreadLocal 정리 규칙을 테스트 코드로 확인합니다.
- 전환 전후 p95 응답 시간, 타임아웃 수, 커넥션 대기 시간, 오류율을 비교합니다.
정리하면 가상 스레드는 블로킹 I/O 중심 Java 서비스를 단순하게 만들 수 있는 좋은 도구입니다. 다만 운영 성공 기준은 스레드 수 감소가 아니라 지연 시간, 장애 격리, 외부 시스템 보호입니다. 먼저 작은 기능에 적용하고, 동시성 제한과 타임아웃, 로그 추적, 공유 자원 잠금을 함께 점검하면 전환 리스크를 크게 줄일 수 있습니다.