Published on

Spring Boot 3 가상 스레드로 TPS 2배 올리기

Authors

서버 TPS를 올리는 가장 흔한 방법은 더 빠른 알고리즘, 캐시, 쿼리 튜닝, 수평 확장입니다. 그런데 Spring MVC 기반의 전통적인 동기 처리 모델에서는, 결국 요청 하나당 플랫폼 스레드(커널 스레드) 하나를 오래 붙잡는 구조가 병목이 되기 쉽습니다. 특히 외부 API 호출, DB I/O, 파일 I/O처럼 대기 시간이 긴 블로킹 작업이 섞이면, CPU는 놀고 있는데 스레드가 모자라서 TPS가 막히는 상황이 자주 발생합니다.

Spring Boot 3(정확히는 Java 21의 Project Loom 기반)에서 제공하는 가상 스레드(virtual thread)는 이 문제를 정면으로 겨냥합니다. 핵심은 "블로킹 코드를 유지하면서도" 동시성을 크게 늘릴 수 있다는 점입니다. 이 글에서는 Spring Boot 3에서 가상 스레드를 적용해 TPS를 2배 수준으로 끌어올릴 때의 접근법을, 설정과 벤치마킹, 그리고 반드시 같이 만져야 하는 DB 풀/외부 호출/관측 포인트까지 묶어서 설명합니다.

가상 스레드가 TPS를 올리는 원리

플랫폼 스레드의 비용

플랫폼 스레드는 OS 스레드에 1:1로 매핑됩니다. 그래서 다음과 같은 비용이 큽니다.

  • 스레드 생성/컨텍스트 스위칭 비용
  • 스레드 스택 메모리(기본 수 MB 단위)로 인한 메모리 압박
  • 동시 요청이 늘면 스레드 풀 큐 대기가 늘고, tail latency가 급증

동기 Spring MVC에서 흔한 구성은 Tomcat의 요청 처리 스레드 풀이며, 기본적으로 요청은 이 풀의 스레드를 점유한 채로 DB/외부 I/O를 기다립니다.

가상 스레드의 핵심: 블로킹을 "싼" 것으로 만들기

가상 스레드는 JVM이 관리하는 경량 스레드입니다. 블로킹 I/O에서 가상 스레드는 캐리어(carrier) 플랫폼 스레드를 점유하지 않고, 적절한 지점에서 파킹(parking)되어 다른 가상 스레드가 캐리어를 사용할 수 있습니다.

정리하면:

  • 코드 스타일은 동기(블로킹) 그대로 유지 가능
  • 동시성을 크게 늘려도 OS 스레드가 폭증하지 않음
  • I/O 대기 시간이 많은 워크로드에서 TPS와 지연이 개선될 여지가 큼

다만 "모든 상황에서 무조건 2배"는 아닙니다. CPU 바운드 작업이 대부분이거나, DB 커넥션 풀이 이미 병목이면 효과가 제한됩니다. 즉, 가상 스레드는 "스레드 부족"을 해결해주지만 "DB, 락, 외부 시스템" 병목은 그대로입니다.

적용 전 체크리스트: 내 병목이 스레드인가?

가상 스레드 적용 전에 아래를 확인하면 성공 확률이 높습니다.

  • 트래픽 증가 시 CPU 사용률이 낮은데 TPS가 더 안 오름
  • Tomcat maxThreads를 올리면 TPS가 오르지만 메모리/컨텍스트 스위칭이 급증
  • APM에서 요청 시간의 상당 부분이 DB/HTTP 호출 대기
  • 스레드 덤프에서 RUNNABLE보다 WAITING/TIMED_WAITING 상태가 많음

반대로 다음 케이스는 가상 스레드만으로는 한계가 큽니다.

  • DB 커넥션 풀이 꽉 차서 대기(예: Hikari pool wait time 증가)
  • JPA N+1, 비효율 쿼리로 DB가 병목
  • synchronized/락 경합이 심함

특히 JPA 쿼리 병목이 섞여 있다면 가상 스레드 적용과 함께 쿼리 최적화를 병행하는 편이 좋습니다. 관련해서는 Spring Boot 3 JPA N+1 폭발을 끝내는 법도 같이 참고하면 전체 TPS가 더 잘 오릅니다.

Spring Boot 3에서 가상 스레드 켜는 방법

1) Java 21 사용

가상 스레드의 안정적인 사용을 위해 Java 21 LTS를 권장합니다.

  • 로컬/CI/JDK 모두 Java 21로 통일
  • 컨테이너라면 base image도 21 기반으로

2) 가장 쉬운 설정: Spring Boot 프로퍼티

Spring Boot 3.2+에서는 다음 설정으로 요청 처리에 가상 스레드를 사용할 수 있습니다.

spring:
  threads:
    virtual:
      enabled: true

이 설정은 Spring MVC의 요청 처리 스레드(서블릿 컨테이너 요청 스레드)와 @Async 실행 등에서 가상 스레드 기반 실행기를 사용하도록 돕습니다. 단, 사용 중인 내장 컨테이너(Tomcat/Jetty/Undertow) 버전과 조합에 따라 동작 범위가 다를 수 있으니, 적용 후 반드시 실제 스레드 이름과 스레드 덤프로 확인하세요.

3) 명시적으로 Executor 구성(권장)

서비스 내에서 별도 스레드 풀을 사용하거나, @Async를 많이 쓰는 경우에는 명시적으로 가상 스레드 실행기를 주입하는 편이 안전합니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

@Configuration
public class VirtualThreadConfig {

    @Bean
    public Executor applicationTaskExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

그리고 @Async를 쓴다면:

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class ReportService {

    @Async
    public void generateReport() {
        // 블로킹 I/O가 있어도 가상 스레드에서 처리되도록 유도
    }
}

주의할 점은, 기존에 ThreadPoolTaskExecutor로 큐잉/스로틀링을 하던 곳을 무작정 가상 스레드로 바꾸면 "너무 많은 동시 작업"이 한 번에 몰릴 수 있다는 점입니다. 가상 스레드는 싸지만 무한정 공짜는 아니며, 특히 DB/외부 API를 과도하게 때리면 downstream이 먼저 터집니다.

TPS 2배를 만들려면 같이 손봐야 하는 것들

가상 스레드로 요청을 더 많이 동시에 처리할 수 있게 되면, 다음 병목이 앞으로 당겨집니다. 여기서 튜닝을 같이 해야 TPS가 의미 있게 상승합니다.

1) DB 커넥션 풀(HikariCP) 재점검

가상 스레드의 대표적인 함정은 "스레드가 많아졌으니 DB도 더 빠르겠지"라는 착각입니다.

  • DB 커넥션 풀은 여전히 제한 자원
  • 동시 요청이 늘면 커넥션 대기가 늘어 tail latency가 악화될 수 있음

점검 포인트:

  • 풀 사이즈(maximumPoolSize)를 CPU 코어 수만큼 단순히 늘리지 말고, DB가 감당 가능한 QPS와 쿼리 비용 기준으로 결정
  • connectionTimeout이 자주 터지면 풀 병목이거나 누수 가능성
  • Micrometer로 pool metrics를 반드시 수집

예시 설정:

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 10
      connection-timeout: 2000
      max-lifetime: 1800000

풀을 올리기 전에 쿼리 최적화를 먼저 하는 것이 정석입니다. N+1이나 불필요한 eager 로딩이 있으면, 가상 스레드로 동시성을 늘린 만큼 DB가 더 빨리 병목이 됩니다.

2) 외부 HTTP 호출은 타임아웃과 커넥션 풀을 명확히

외부 API 호출이 블로킹이라면 가상 스레드와 궁합이 좋습니다. 하지만 다음을 하지 않으면 동시 요청이 늘면서 장애가 더 커질 수 있습니다.

  • connect/read timeout 필수
  • HTTP 클라이언트 커넥션 풀 사이즈 점검
  • 재시도는 제한적으로(지수 백오프, 서킷 브레이커)

Java HttpClient 예시:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class ExternalCaller {

    private final HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofMillis(300))
            .build();

    public String call() throws Exception {
        HttpRequest req = HttpRequest.newBuilder()
                .uri(URI.create("https://example.com/api"))
                .timeout(Duration.ofSeconds(1))
                .GET()
                .build();

        return client.send(req, HttpResponse.BodyHandlers.ofString()).body();
    }
}

3) 동기 락(synchronized)과 ThreadLocal 남용 줄이기

가상 스레드는 동시성을 크게 늘리므로, 락 경합이 있으면 더 빨리 드러납니다.

  • synchronized 구간이 긴지 확인
  • 전역 캐시 갱신, 싱글톤 상태 변경 로직이 병목인지 확인
  • ThreadLocal 기반 컨텍스트가 과도하게 커지지 않는지 확인

특히 "요청당 ThreadLocal에 큰 객체를 넣는" 패턴은 동시 요청이 늘수록 메모리 압박이 커집니다.

벤치마크로 TPS 2배를 검증하는 방법

가상 스레드는 "적용했다"가 아니라 "측정해서 이득이 있는지"가 중요합니다. 다음 순서로 측정하면 재현성이 좋습니다.

1) 워크로드를 두 가지로 나누기

  • I/O 바운드 엔드포인트: DB 조회 + 외부 API 호출 포함
  • CPU 바운드 엔드포인트: JSON 파싱, 암호화, 압축 등

가상 스레드는 보통 I/O 바운드에서 이득이 큽니다.

2) k6로 부하 테스트

예시 k6 스크립트:

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  scenarios: {
    steady: {
      executor: 'constant-arrival-rate',
      rate: 500,
      timeUnit: '1s',
      duration: '2m',
      preAllocatedVUs: 200,
      maxVUs: 2000,
    },
  },
};

export default function () {
  http.get('http://localhost:8080/api/orders');
  sleep(0.01);
}

측정 지표:

  • TPS(성공 응답 기준)
  • p95, p99 latency
  • 에러율(타임아웃, 5xx)
  • DB 커넥션 풀 대기 시간

3) JVM/스레드 관측 포인트

가상 스레드 적용 후에는 플랫폼 스레드 수가 과도하게 늘지 않는지 확인해야 합니다.

  • jcmd로 스레드/클래스/GC 상태 확인
  • APM에서 "스레드 풀 큐 대기"가 줄었는지 확인
  • GC 빈도와 힙 사용량 변화 확인

운영 환경이 Kubernetes라면, 동시성이 늘면서 메모리/FD 사용량이 변할 수 있으니 리소스 리밋과 종료 시그널 처리도 같이 점검하세요. 장애 상황에서 Pod가 종료되지 않고 매달리는 케이스는 Azure AKS에서 Pod가 Terminating에 멈출 때 해결법처럼 런타임 종료 훅/프리스트롭과도 연결됩니다.

실전 구성 예시: MVC + JPA에서 안전하게 올리기

아래는 "가상 스레드로 동시성 확장"과 "DB 병목 방지"를 같이 고려한 전형적인 접근입니다.

  1. spring.threads.virtual.enabled로 요청 처리 가상 스레드 활성화
  2. Hikari 풀 메트릭 수집 및 풀 사이즈 보수적으로 조정
  3. 느린 쿼리/빈번 쿼리부터 튜닝(N+1 제거, 인덱스, 불필요한 트랜잭션 범위 축소)
  4. 외부 호출 타임아웃/서킷 브레이커 적용
  5. k6로 동일 조건 A/B 테스트(가상 스레드 off vs on)

JPA 트랜잭션 범위 예시(불필요하게 길지 않게):

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderQueryService {

    private final OrderRepository orderRepository;

    public OrderQueryService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true)
    public OrderDto getOrder(Long id) {
        var order = orderRepository.findById(id)
                .orElseThrow();
        return OrderDto.from(order);
    }
}

트랜잭션이 길어지면 DB 커넥션 점유 시간이 늘고, 가상 스레드로 동시 요청을 늘릴수록 커넥션 풀이 먼저 고갈될 수 있습니다.

자주 겪는 문제와 해결 방향

TPS는 올랐는데 p99가 나빠졌다

  • 원인: DB 풀 대기, 외부 API 타임아웃 누락, 락 경합
  • 대응: 풀 메트릭에서 wait time 확인, 타임아웃 설정, 병목 구간 프로파일링

CPU가 오히려 올라갔다

  • 원인: 동시성이 늘면서 JSON 직렬화/역직렬화, 로깅, 암호화 등 CPU 작업이 병렬로 더 많이 수행
  • 대응: 로깅 샘플링/비동기 로깅, 핫패스 최적화, 필요 시 rate limit

장애 시 연쇄적으로 타임아웃이 늘었다

  • 원인: 가상 스레드로 더 많은 요청이 동시에 downstream을 때림
  • 대응: bulkhead(격리), 서킷 브레이커, 큐잉/백프레셔 도입

Kubernetes에서 이런 상황이 Ingress 503으로 보일 수도 있으니, 인프라 관점 점검은 EKS Ingress 503인데 Pod 정상일 때 점검 가이드도 함께 보면 원인 분리가 빨라집니다.

결론: 가상 스레드는 "동기 코드의 동시성 확장"을 쉽게 만든다

Spring Boot 3의 가상 스레드는 "동기 MVC + 블로킹 I/O"라는 현실적인 코드베이스에서 TPS를 올리는 가장 비용 대비 효과가 좋은 카드 중 하나입니다. 특히 I/O 대기가 많은 서비스라면, 요청 처리 스레드 부족으로 생기던 큐 대기와 컨텍스트 스위칭 비용을 줄여 TPS를 2배 수준까지도 현실적으로 노려볼 수 있습니다.

다만 성공의 조건은 명확합니다.

  • 내 병목이 스레드인지 먼저 확인
  • 가상 스레드 적용 후, DB 풀/외부 호출/락 경합이 다음 병목으로 올라오는 것을 전제로 튜닝
  • 벤치마크로 p95, p99까지 포함해 검증

이 순서로 접근하면 "가상 스레드를 켰더니 빨라졌다"가 아니라, 운영에서 재현 가능한 성능 개선으로 연결할 수 있습니다.