- Published on
Spring Boot 3 가상스레드 적용 후 TPS 급락 원인
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 TPS가 한계에 부딪혀 Spring Boot 3 + Java 21의 가상 스레드(virtual thread)를 적용했는데, 기대와 달리 TPS가 급락하는 사례가 종종 있습니다.
가상 스레드는 **“스레드 수를 늘려도 OS 스레드(플랫폼 스레드)를 많이 쓰지 않는다”**는 장점이 있지만, 이는 어디까지나 블로킹이 ‘잘 격리’되고, 병목이 스레드가 아닌 다른 곳에 있을 때 효과가 큽니다. 반대로 기존에는 스레드 풀 크기(예: Tomcat maxThreads)가 자연스러운 백프레셔(backpressure) 역할을 하던 시스템에서 가상 스레드를 켜면, 숨겨진 병목(커넥션 풀, DB 락, 외부 API, 동기 로깅, CPU, 타임아웃) 이 한꺼번에 노출되며 TPS가 떨어질 수 있습니다.
아래는 “가상 스레드 적용 후 TPS 급락”을 만드는 대표 원인과, 짧은 시간에 원인 좁히는 방법입니다.
1) 가상 스레드는 ‘무한 동시성’이 아니다: 백프레셔 붕괴
전통적인 서블릿 모델(Tomcat)에서 maxThreads=200 같은 설정은 동시에 처리할 요청 수를 제한합니다. 이 제한이 DB 커넥션 풀(예: Hikari 20~50), 외부 API QPS 제한, 내부 락 경합을 보호해주는 역할을 합니다.
가상 스레드를 활성화하면(특히 “요청당 가상 스레드” 형태로) 애플리케이션은 훨씬 많은 요청을 동시에 “진입”시킵니다. 결과적으로:
- DB 커넥션 풀이 먼저 바닥나며 대기열이 늘어남
- 외부 API 타임아웃이 급증하며 재시도 폭풍
- 락 경합/데드락 빈도 증가
- GC/메모리 압박 증가
즉, TPS가 떨어진 게 아니라 평균/상위 지연이 폭증하면서 전체 처리량이 무너지는 패턴이 흔합니다.
체크 포인트
- p95/p99 지연이 가상 스레드 적용 후 급증했는가?
- DB 커넥션 풀
active=poolSize상태가 오래 지속되는가? - 외부 호출 타임아웃/재시도 횟수가 늘었는가?
2) DB 커넥션 풀(HikariCP)이 병목으로 변한다
가상 스레드는 “대기”를 싸게 만들지만, DB 커넥션 수 자체는 늘어나지 않습니다.
요청 동시성이 늘어났는데 커넥션 풀이 그대로라면, 대부분의 요청은 다음 상태가 됩니다.
- “가상 스레드가 DB 커넥션을 기다리며 주차(park)됨”
- 응답 지연 증가 → 타임아웃 증가 → 재시도 증가 → 더 많은 동시성 유입
이때 TPS는 오히려 떨어집니다.
개선 접근
- 커넥션 풀을 무작정 키우기 전에 DB가 감당 가능한 동시 쿼리 수를 먼저 산정
- 애플리케이션 레벨에서 동시 DB 작업 수를 제한(세마포어/벌크헤드)
- 쿼리/인덱스 튜닝으로 커넥션 점유 시간을 줄이기
DB 락/데드락이 같이 보인다면 아래 글의 방법으로 락 대기와 인덱스/트랜잭션 범위를 먼저 점검하는 편이 빠릅니다.
예시: DB 동시성 벌크헤드(세마포어)
가상 스레드를 쓰더라도 DB는 보호해야 합니다.
import java.util.concurrent.Semaphore;
@Service
public class UserQueryService {
// DB 동시 접근 상한을 명시적으로 둔다 (예: 커넥션 풀의 70~90%)
private final Semaphore dbBulkhead = new Semaphore(30);
private final UserRepository repo;
public UserQueryService(UserRepository repo) {
this.repo = repo;
}
public User findUser(String id) {
boolean acquired = false;
try {
dbBulkhead.acquire();
acquired = true;
return repo.findById(id).orElseThrow();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} finally {
if (acquired) dbBulkhead.release();
}
}
}
이 방식은 “가상 스레드로 무한히 밀어 넣는” 상황을 막아 TPS 급락을 완화합니다.
3) 외부 API/네트워크 타임아웃이 TPS를 갉아먹는다
가상 스레드를 켜면 외부 호출도 동시에 더 많이 발생합니다. 이때 외부 시스템이 감당 못하면:
- 연결 대기(connect timeout)
- 읽기 지연(read timeout)
- 5xx/429 증가 및 재시도
가상 스레드 자체는 대기 비용을 줄이지만, 대기 시간이 길어지면 요청이 시스템에 더 오래 머물며(체류시간 증가) TPS는 감소합니다.
특히 쿠버네티스/EKS 환경에서는 L7/L4 타임아웃이 겹치며 증상이 악화됩니다. 예를 들어 ALB idle timeout, Envoy upstream timeout 등으로 “느린 요청”이 잘려나가면 재시도가 폭증합니다.
진단 팁
- APM에서 “외부 호출 span”의 p95/p99를 먼저 확인
- 커넥션 풀(HTTP client pool) 고갈 여부 확인
- 재시도 정책(무지성 재시도) 존재 여부 확인
4) Pinning(플랫폼 스레드 점유)으로 가상 스레드 이점이 사라진다
가상 스레드는 블로킹 시 플랫폼 스레드를 양보(unmount)할 수 있어야 이점이 큽니다. 그런데 특정 상황에서는 가상 스레드가 플랫폼 스레드에 ‘고정(pinning)’ 되어, 블로킹이 발생해도 플랫폼 스레드를 계속 점유합니다.
대표적인 핀ning 유발 요인:
synchronized블록/메서드 안에서 블로킹 I/O 수행- 네이티브 호출, 일부 JNI 연계
- 오래된 드라이버/라이브러리의 모니터 락 사용 패턴
재현 예시: synchronized 안에서 sleep/IO
public class PinnedExample {
private final Object lock = new Object();
public String bad() {
synchronized (lock) {
// 블로킹 작업이 락 내부에 있으면 pinning이 발생할 수 있다
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "ok";
}
}
}
개선 방향
synchronized구간에서 블로킹 호출 제거- 락 범위를 최소화하고, 필요 시
ReentrantLock/CAS 구조로 전환 - JFR(Java Flight Recorder)로 Virtual Thread pinning 이벤트를 확인
5) 로그/메트릭이 동기(블로킹)로 바뀌며 병목이 된다
가상 스레드를 켜면 요청 수가 늘고, 그만큼 로그/메트릭 이벤트도 늘어납니다. 이때 다음이 병목이 될 수 있습니다.
- 동기 파일 로그(디스크 flush)
- 네트워크 로깅(예: TCP appender)
- 과도한 MDC/JSON 직렬화 비용
증상은 “CPU는 남는데 TPS가 안 나옴”, “스레드 덤프에서 로깅 관련 호출이 많음” 등으로 나타납니다.
개선 방향
- 비동기 로깅(예: Logback AsyncAppender) 검토
- 로그 레벨/샘플링 적용
- 고비용 payload 로깅 제거
6) CPU 바운드 작업이 섞여 있으면 컨텍스트 스위칭만 늘어난다
가상 스레드는 I/O 대기에 강하지만, CPU 바운드 작업을 빠르게 만들어주지 않습니다.
- JSON 대량 직렬화/역직렬화
- 암호화/서명
- 이미지/압축
- 대규모 컬렉션 연산
이런 작업이 많으면 동시성을 늘리는 것이 오히려 캐시 미스/스케줄링 비용을 키워 TPS가 떨어질 수 있습니다.
진단
- JFR/프로파일러로 CPU hot spot 확인
- p99에서 CPU 사용률이 100% 근처인지 확인
7) 설정 실수: “가상 스레드 ON”이 실제로는 혼합 모델
Spring Boot 3에서 가상 스레드를 켰다고 해도, 실제로는 다음처럼 혼합될 수 있습니다.
- 서블릿 컨테이너는 가상 스레드인데, 내부 비동기 작업은 여전히 작은 고정 풀 사용
@Async기본 executor가 병목- 스케줄러가 단일 스레드로 병목
즉, 요청 처리 스레드만 늘어나고, 핵심 작업은 작은 풀에서 대기하면서 전체 TPS가 떨어집니다.
예시: @Async executor를 가상 스레드로
import org.springframework.context.annotation.*;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.scheduling.annotation.*;
import java.util.concurrent.Executors;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
// Spring이 인식하는 AsyncTaskExecutor로 감싸서 제공
var executor = Executors.newVirtualThreadPerTaskExecutor();
return new TaskExecutorAdapter(executor);
}
}
또는 스케줄링 작업도 병목이 될 수 있으니 스케줄러 풀 크기를 점검하세요.
8) 타임아웃/재시도 정책이 가상 스레드에서 더 치명적이 된다
가상 스레드는 “대기”를 쉽게 만들기 때문에, 타임아웃이 길고 재시도가 공격적이면 시스템이 요청으로 가득 차는 현상이 빨라집니다.
- connect/read timeout이 과도하게 큼
- 무제한 재시도 + 짧은 backoff
- 서킷브레이커/벌크헤드 부재
이는 외부 API뿐 아니라 내부 DB/캐시에도 동일하게 적용됩니다.
개선 방향
- 타임아웃을 “짧게, 명확하게”
- 재시도는 조건부(멱등/일시 오류) + 지수 백오프 + 지터
- 서킷브레이커/벌크헤드로 동시성 제한
9) 빠른 원인 규명을 위한 체크리스트(현장용)
가상 스레드 적용 후 TPS 급락을 30~60분 내에 좁히려면 아래 순서가 효율적입니다.
- 지연 분포 확인: p50은 비슷한데 p95/p99만 폭증이면 “대기/병목” 가능성이 큼
- DB 커넥션 풀: active/idle/pending, 커넥션 획득 시간(connection acquire time)
- 외부 호출: 타임아웃/재시도, upstream 5xx/504/503
- 락/데드락: DB 락 대기, 애플리케이션 synchronized 경합
- JFR: pinning 이벤트, CPU hot spot, GC pause
- 로깅/메트릭: 동기 I/O 여부, 로그량 급증 여부
10) 결론: “가상 스레드로 TPS를 올린다”가 아니라 “병목을 드러낸다”
Spring Boot 3의 가상 스레드는 I/O 대기 비용을 줄여 동시성을 더 안전하게 가져갈 수 있게 해줍니다. 하지만 동시에 기존 스레드 풀이 해주던 백프레셔가 사라지면서, DB/외부 API/락/타임아웃 같은 병목이 한꺼번에 수면 위로 올라옵니다.
따라서 TPS 급락을 해결하는 핵심은:
- 병목 자원(DB 커넥션, 외부 API, 락)별로 명시적 동시성 제한(벌크헤드) 을 두고
- 타임아웃/재시도 정책을 현실화하며
- pinning을 유발하는 코드/라이브러리를 제거하고
- JFR/APM으로 “어디서 기다리는지”를 수치로 확인하는 것
입니다.
가상 스레드는 만능 스위치가 아니라, 시스템의 병목을 더 정확히 보여주는 확대경에 가깝습니다. 확대경으로 드러난 병목을 하나씩 제거하면, 그때부터 가상 스레드의 장점(높은 동시성, 적은 플랫폼 스레드, 더 단순한 동기 코드)이 제대로 성과로 연결됩니다.