TestForge | Aidevops | 📊 Plogger ✍️ Blog 📚 Docs
plogger

AI DevOps Korea

AI 서비스 개발, 운영, 성능개선을 하나의 루프로 연결합니다

aidevops.kr에서 LLMOps, RAG, AI Agent, 관측성, 평가, 비용-성능 최적화를 실전 운영 관점으로 정리합니다.

Java 21 Virtual Threads 가상 스레드 실전 가이드

· 수정 4월 21일
Java 21 Virtual Threads 가상 스레드 실전 가이드 다이어그램
이 그림은 가상 스레드의 이점과 실제 서비스 처리량을 제한하는 하위 병목 지점을 분리해서 이해하도록 돕습니다.
Virtual Threads가 Java 21에서 정식 기능이 됐다고 해서, 곧바로 "이제 reactive는 필요 없고 성능도 자동으로 오른다"라고 결론내리면 곤란합니다. 실무에서 더 중요한 질문은 따로 있습니다. 지금 서비스의 병목이 정말 스레드 수인지, 아니면 DB 풀, 외부 API, CPU, 락 경합 같은 다른 경계인지 먼저 구분해야 합니다.

Virtual Threads의 진짜 장점은 비동기 체인을 강요하지 않으면서도 I/O 대기 시간이 많은 서비스를 높은 동시성으로 다룰 수 있게 해준다는 점입니다. 즉 “새로운 동시성 이론”이라기보다, 읽기 쉬운 blocking 코드 구조를 유지한 채 동시성 한계를 늦추는 도구에 가깝습니다.

Virtual Threads가 실제로 바꾸는 것

가상 스레드는 운영체제 스레드와 1:1로 고정되지 않고 JVM이 스케줄링합니다. 그래서 수천, 수만 개의 작업을 platform thread처럼 무겁게 다루지 않아도 됩니다.

하지만 여기서 자주 오해가 생깁니다.

  • 스레드가 가벼워졌다고 해서 DB 커넥션이 늘어나는 것은 아닙니다.
  • 가상 스레드가 많다고 해서 외부 API rate limit이 사라지지 않습니다.
  • CPU 바운드 작업이 갑자기 빨라지는 것도 아닙니다.

결국 Virtual Threads는 “대기 비용이 큰 요청을 더 단순한 코드로 다룰 수 있게 하는 기술”이지, 시스템 전체 용량을 자동으로 늘려주는 기능은 아닙니다.

잘 맞는 상황

다음 조건이 동시에 맞으면 Virtual Threads는 꽤 좋은 선택이 됩니다.

  • 요청 처리 중 네트워크나 스토리지 I/O 대기가 길다
  • blocking 코드가 reactive 체인보다 팀 유지보수에 유리하다
  • 사용하는 드라이버와 SDK가 치명적인 thread pinning 문제를 만들지 않는다
  • 팀이 동시성을 코드 취향이 아니라 지표로 검증할 준비가 돼 있다

예를 들면 여러 내부 API를 fan-out 하는 집계 서비스, 외부 시스템과 I/O 왕복이 많은 BFF, reactive로 완전히 갈아타기엔 리스크가 큰 기존 Spring Boot 서비스가 여기에 들어갑니다.

잘 안 맞는 상황

반대로 아래 상황에서는 기대만큼 이득이 크지 않거나, 오히려 실패 모드가 더 나빠질 수 있습니다.

  • CPU가 이미 포화 상태다
  • 굵은 synchronized 구간이 병목이다
  • DB 풀 크기가 실제 상한이다
  • 오래된 드라이버가 carrier thread를 오래 붙잡는다
  • timeout, retry, bulkhead 규칙이 약하다

DB 연결이 100개뿐인 서비스에서 가상 스레드를 2만 개 열어도 처리 용량이 늘어나는 것은 아닙니다. 대부분은 더 긴 대기열과 더 느린 장애 복구로 돌아옵니다.

안전한 도입 경계

실무에서는 보통 “코드 곳곳에서 쓰기”보다 executor 경계에서 도입하는 편이 훨씬 안전합니다.

초기 도입 전에 팀이 먼저 정해야 할 것은 세 가지입니다.

  1. 어떤 종류의 작업에만 Virtual Threads를 허용할지
  2. 어떤 외부 호출에 timeout과 bulkhead를 반드시 둘지
  3. 어떤 지표를 보고 도입 성공 여부를 판단할지

이 기준이 없으면 Virtual Threads는 금방 “새로운 문법 취향”처럼 퍼지고, 나중에는 어디가 안전한 경계였는지 아무도 설명하지 못하게 됩니다.

예시: fan-out은 하되 하한선은 유지하기

아래 코드는 가상 스레드를 활용하되, 외부 제공자에 대한 동시 접근 수는 세마포어로 제한하는 방식입니다.

private static final Semaphore BULKHEAD = new Semaphore(40);

public List<Quote> fetchQuotes(List<QuoteRequest> requests) throws Exception {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<Future<Quote>> futures = requests.stream()
            .map(request -> executor.submit(() -> fetchOneQuote(request)))
            .toList();

        List<Quote> result = new ArrayList<>(futures.size());
        for (Future<Quote> future : futures) {
            result.add(future.get());
        }
        return result;
    }
}

private Quote fetchOneQuote(QuoteRequest request) throws Exception {
    if (!BULKHEAD.tryAcquire(1, TimeUnit.SECONDS)) {
        throw new TimeoutException("quote provider bulkhead is full");
    }

    try {
        return quoteClient.fetch(request);
    } finally {
        BULKHEAD.release();
    }
}

핵심은 newVirtualThreadPerTaskExecutor() 그 자체가 아닙니다. 외부 의존성에 대한 동시성 제한이 여전히 명시적이어야 한다는 점이 핵심입니다.

Spring Boot에서 함께 봐야 할 것

Spring Boot에서 Virtual Threads를 켠다고 끝나지 않습니다. 실제로는 다음도 같이 점검해야 합니다.

  • 서블릿 컨테이너 설정
  • JDBC 드라이버 동작 방식
  • 커넥션 풀 크기와 대기 시간
  • HTTP/DB 클라이언트 timeout 기본값
  • trace, metric, blocked work 관측 가능성

이 부분이 불분명하면 기능 테스트에서는 멀쩡해 보여도, 실제 동시 부하에서는 tail latency와 queueing이 훨씬 나빠질 수 있습니다.

꼭 봐야 하는 운영 지표

도입 전후를 비교할 때는 아래 지표를 같이 봐야 합니다.

  • p50, p95, p99 지연 시간
  • 동일 에러 예산에서의 처리량
  • DB pool wait time
  • 외부 API 포화 여부
  • 피크 동시성에서의 heap 증가
  • carrier thread pinning 또는 blocked thread 징후

좋은 도입은 처리량이 늘면서 tail latency가 안정적입니다. 나쁜 도입은 순간 처리량은 늘어도, 뒤에서 풀 대기와 외부 호출 정체가 같이 커집니다.

자주 실패하는 패턴

  • platform thread만 virtual thread로 바꾸고 풀 제한은 그대로 두지 않는 경우
  • 모든 blocking 라이브러리가 잘 맞을 거라고 가정하는 경우
  • timeout 설계 부실을 virtual threads로 덮으려는 경우
  • CPU 작업까지 같은 전략으로 밀어 넣는 경우
  • 관측 지표 없이 “잘 된 것 같다”로 끝내는 경우

실패의 원인은 대체로 언어 기능이 아니라 운영 모델 부재입니다.

리뷰 체크리스트

  • 지금 병목이 정말 스레드 부족인가, 아니면 다른 하위 시스템인가
  • timeout, semaphore, pool limit, retry 규칙이 여전히 보이는가
  • carrier thread를 오래 붙잡는 라이브러리를 알고 있는가
  • 운영 환경에서 대기열과 blocked work를 추적할 수 있는가
  • 여기서는 reactive보다 blocking 모델 유지가 정말 더 큰 이점인가

마무리 판단

Virtual Threads는 성능 마법 버튼이 아니라 아키텍처 단순화 도구로 보는 편이 정확합니다. I/O 중심 서비스에서 읽기 쉬운 blocking 코드를 유지하면서도 경계별 제한을 분명히 둘 수 있다면 강력합니다. 반대로 병목이 원래 스레드 문제가 아니었다면, Virtual Threads는 문제를 해결하기보다 더 넓게 퍼뜨릴 가능성이 큽니다.

Continue Reading

다음으로 읽기 좋은 글

다음 탐색

이 주제를 시스템 관점으로 더 이어서 보기