Contents
see List왜 Virtual Threads 전환 전에 점검이 필요한가
Java Virtual Threads는 요청마다 스레드를 하나씩 배정하는 단순한 동시성 모델을 다시 실무 선택지로 만들어 줍니다. 기존 플랫폼 스레드는 생성 비용과 메모리 비용이 커서 웹 요청, 외부 API 호출, 데이터베이스 조회처럼 대기 시간이 많은 작업에서도 제한된 스레드 풀을 조심스럽게 나누어 써야 했습니다. 반면 Virtual Threads는 훨씬 가볍기 때문에 블로킹 코드를 비동기 체인으로 억지 변환하지 않고도 높은 동시성을 다룰 수 있습니다. 하지만 스레드가 가벼워졌다고 해서 데이터베이스 커넥션, 외부 API 제한, 파일 디스크립터, 락 경합까지 함께 사라지는 것은 아닙니다. 운영 전환의 핵심은 스레드 수를 늘리는 것이 아니라, 실제 병목 자원이 어디인지 드러나게 만드는 것입니다.
특히 기존 서비스가 고정 크기 Executor, 큰 큐, 긴 타임아웃, 동기 JDBC 호출, synchronized 블록을 섞어 쓰고 있다면 Virtual Threads를 적용한 뒤에도 처리량이 기대만큼 오르지 않을 수 있습니다. 더 나쁜 경우에는 기존 스레드 풀에서 자연스럽게 걸리던 압력이 사라져 데이터베이스나 하위 API에 훨씬 많은 동시 요청이 몰릴 수 있습니다. 따라서 전환은 전체 서비스를 한 번에 바꾸기보다 요청 처리, 배치, 외부 연동처럼 경계가 분명한 구간부터 시작하는 편이 안전합니다.
적용 후보를 고르는 기준
- 대기 시간이 대부분인 작업부터 검토합니다. HTTP 호출, JDBC 조회, 파일 I/O, 메시지 처리처럼 CPU보다 응답 대기가 많은 코드가 좋은 후보입니다.
- CPU 사용량이 높은 이미지 처리, 암호화, 대용량 압축, 복잡한 계산은 Virtual Threads만으로 빨라지지 않습니다. 이런 작업은 별도 크기 제한이 있는 플랫폼 스레드 풀이나 작업 큐가 더 적합합니다.
- 작업 내부에서 synchronized, 전역 락, 단일 커넥션, 단일 클라이언트 병목을 오래 잡고 있는지 확인합니다. 많은 Virtual Threads가 같은 락 앞에 줄을 서면 동시성 이점이 줄어듭니다.
- 하위 시스템의 허용 동시성을 먼저 정합니다. 데이터베이스 커넥션 풀, 외부 API rate limit, 검색 엔진, 메시지 브로커의 처리 한계를 기준으로 애플리케이션 동시성을 제한해야 합니다.
기본 Executor 구성 예제
Virtual Threads는 요청 단위 작업을 명확하게 제출하고, 작업이 끝나면 스레드도 함께 정리되는 구조에서 다루기 쉽습니다. 아래 예제는 외부 API를 여러 건 호출하되, 외부 시스템 보호를 위해 Semaphore로 실제 동시 호출 수를 제한하는 패턴입니다. 핵심은 Virtual Threads를 무제한 처리량 보장 장치로 보지 않고, 코드 구조를 단순하게 만드는 실행 단위로 보는 것입니다.
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 VirtualThreadApiLoader {
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
private final Semaphore downstreamLimit = new Semaphore(30);
public List<String> loadAll(List<URI> uris) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = uris.stream()
.map(uri -> executor.submit(() -> fetchWithLimit(uri)))
.toList();
return futures.stream()
.map(future -> {
try {
return future.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList();
}
}
private String fetchWithLimit(URI uri) throws Exception {
if (!downstreamLimit.tryAcquire()) {
throw new IllegalStateException("external API concurrency limit exceeded");
}
try {
var request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(5))
.GET()
.build();
return http.send(request, HttpResponse.BodyHandlers.ofString()).body();
} finally {
downstreamLimit.release();
}
}
}
데이터베이스 커넥션 풀을 먼저 조정해야 하는 이유
Virtual Threads 전환 후 가장 흔한 오해는 애플리케이션이 더 많은 요청을 동시에 받을 수 있으니 커넥션 풀도 크게 늘리면 된다는 생각입니다. 실제로는 데이터베이스가 동시에 처리할 수 있는 쿼리 수가 제한되어 있고, 커넥션을 과하게 늘리면 컨텍스트 스위칭, 락 대기, 버퍼 경합이 늘어 전체 지연 시간이 커질 수 있습니다. 애플리케이션 스레드 수와 커넥션 수를 같은 기준으로 맞추지 말고, 데이터베이스 CPU 사용률, active session, slow query, lock wait를 기준으로 풀 크기를 정해야 합니다.
운영에서는 먼저 현재 피크 시간대의 커넥션 사용률과 대기 시간을 기록합니다. 그 다음 특정 API 한두 개를 Virtual Threads로 전환하고, 커넥션 풀 대기 시간이 줄었는지 또는 반대로 데이터베이스 active query가 급증했는지 봅니다. HikariCP를 쓴다면 maximumPoolSize를 무작정 키우는 대신 connectionTimeout을 짧게 두고, 풀 고갈이 발생했을 때 요청이 오래 매달리지 않도록 실패를 빠르게 드러내는 편이 장애 전파를 막는 데 유리합니다.
관측 지표와 로그 설계
- 요청 처리 시간은 평균보다 p95, p99를 봅니다. Virtual Threads 적용 후 평균이 좋아져도 긴 꼬리 지연이 늘면 하위 시스템 병목이 숨어 있을 수 있습니다.
- 데이터베이스 커넥션 획득 시간, active connection, idle connection, timeout 횟수를 함께 봅니다.
- 외부 API 호출은 성공률, 타임아웃, 재시도 횟수, 동시 호출 제한 초과를 별도 지표로 남깁니다.
- JVM 관점에서는 스레드 덤프와 JFR 이벤트를 이용해 Virtual Threads가 어디에서 대기하는지 확인합니다. 동일한 락, 동일한 커넥션 풀, 동일한 HTTP 호스트에 대기가 몰려 있다면 스레드 모델이 아니라 자원 설계를 손봐야 합니다.
운영 전환 순서
첫 단계는 Executor를 직접 생성하는 내부 작업부터 정리하는 것입니다. 오래 살아 있는 고정 스레드 풀과 큰 큐를 사용하던 코드를 요청 단위 실행으로 바꾸고, 큐가 담당하던 완충 역할은 명시적인 동시성 제한과 빠른 실패 정책으로 옮깁니다. 두 번째 단계는 타임아웃을 표준화하는 것입니다. Virtual Threads에서는 많은 작업이 동시에 대기할 수 있으므로, 타임아웃 없는 I/O가 누적되면 장애 감지가 늦어집니다. HTTP connect timeout, read timeout, JDBC query timeout, 트랜잭션 timeout을 API 성격에 맞게 정해야 합니다.
세 번째 단계는 부하 테스트입니다. 기존 최대 동시 요청 수만 재현하지 말고, 하위 API 지연, 데이터베이스 지연, 일부 요청 타임아웃 같은 장애 조건을 함께 넣어야 합니다. 정상 상황에서 처리량이 좋아져도 장애 상황에서 대기 작업이 폭증하면 운영 안정성은 오히려 나빠집니다. 마지막 단계는 점진 배포입니다. 특정 엔드포인트, 특정 배치 잡, 특정 내부 API 클라이언트부터 적용하고, 지표가 안정적일 때 범위를 넓히는 방식이 적합합니다.
마무리 체크리스트
- Virtual Threads 적용 대상이 CPU 작업이 아니라 I/O 대기 작업인지 확인했습니다.
- 데이터베이스 커넥션 풀 크기와 대기 시간을 전환 전후로 비교할 수 있습니다.
- 외부 API 호출에는 타임아웃, 동시성 제한, 실패 로그가 들어 있습니다.
- 큰 큐로 장애를 숨기던 Executor를 명시적인 제한과 빠른 실패 정책으로 바꾸었습니다.
- p95, p99 지연 시간과 하위 시스템 지표를 함께 보며 점진 배포할 계획이 있습니다.