- Published on
Spring Boot 3 가상스레드 후 TPS 하락 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에 virtual thread를 켰는데도 TPS가 떨어졌다면, 대부분은 “가상 스레드가 느려서”가 아니라 병목이 다른 곳으로 이동했거나, 가상 스레드의 장점이 상쇄되는 설정/코드 경로가 남아있기 때문입니다. 이 글은 Spring Boot 3에서 가상 스레드 적용 후 TPS 하락을 만드는 대표 원인 7가지를, 재현 포인트와 점검 방법 중심으로 정리합니다.
가상 스레드로 TPS를 올리는 기본 접근은 아래 글에서 먼저 잡고 오면 좋습니다.
1) DB 커넥션 풀 포화: 스레드는 늘었는데 커넥션은 그대로
가상 스레드를 켜면 동시 요청을 더 많이 받아들이게 됩니다. 그런데 DB 커넥션 풀(HikariCP 등)은 그대로라면, 요청은 DB 앞에서 줄을 서게 되고 TPS는 오히려 하락할 수 있습니다. 특히 다음 패턴에서 자주 발생합니다.
- 기존에는 톰캣 워커 스레드 수가 제한이라 DB 동시 접근도 자연스럽게 제한됨
- 가상 스레드로 바꾸면서 “대기 비용”이 줄어들어 DB로 더 많은 동시 요청이 몰림
- 결과적으로 풀 대기, 락 경합, 쿼리 지연이 증가
점검
- Hikari 메트릭에서
active가maximumPoolSize에 자주 붙는지 - 풀 대기 시간(
connection timeout)이 증가하는지 - DB에서 동시 쿼리 수, 락 대기, CPU가 급등하는지
대응
- 풀 사이즈를 무작정 키우기보다, DB가 감당 가능한 동시성부터 산정
- 병목 쿼리 인덱스/플랜 개선, 트랜잭션 범위 축소
- 읽기 부하는 캐시 또는 리드 레플리카로 분산
관련해서 레플리카 지연과 캐시 튜닝 관점은 아래 글도 참고할 만합니다.
예시: 풀 포화 확인용 설정
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
# Hikari 메트릭을 Prometheus로 수집한다고 가정
Prometheus에서 hikaricp_connections_active와 hikaricp_connections_max를 같이 보고, active가 max에 붙는 구간이 TPS 하락 구간과 일치하는지 확인합니다.
2) “블로킹이 아닌 작업”에서 가상 스레드가 이득이 없다
가상 스레드는 블로킹 I/O 대기 비용을 싸게 만들어줍니다. 반대로 CPU 바운드 작업(암호화, 압축, 대용량 JSON 직렬화, 이미지 처리 등)에서는 가상 스레드가 TPS를 올려주지 않습니다. 오히려 다음 이유로 떨어질 수 있습니다.
- 동시성이 증가하면서 CPU 런큐가 길어짐
- 컨텍스트 스위칭과 스케줄링 오버헤드가 증가
- GC 압력이 증가(요청이 더 많이 “동시에” 진행되며 객체 생성량이 증가)
점검
async-profiler로 CPU flame graph를 떠서 CPU hotspot 확인- 요청 처리 시간 중 I/O 대기보다 CPU 비중이 큰지
대응
- CPU 바운드 구간을 분리해 제한된 풀에서 처리
- 직렬화 비용 최적화(필드 축소, 스트리밍, 캐시)
- 암호화/서명은 키 캐시 및 알고리즘 선택 재검토
예시: CPU 바운드 구간을 별도 풀로 격리
가상 스레드로 요청을 받되, CPU 바운드 작업은 제한된 풀에서 실행해 전체 시스템이 흔들리지 않게 합니다.
@Bean
Executor cpuBoundExecutor() {
return Executors.newFixedThreadPool(
Math.max(2, Runtime.getRuntime().availableProcessors() - 1)
);
}
public String handle(Executor cpuBoundExecutor) {
return CompletableFuture.supplyAsync(() -> {
// CPU 바운드 작업
return expensiveSignOrCompress();
}, cpuBoundExecutor).join();
}
3) 동기 HTTP 클라이언트 사용 시 외부 서비스 병목이 폭발한다
가상 스레드 적용 후 TPS가 떨어지는 전형적인 케이스가 “외부 API 호출”입니다.
- 기존: 워커 스레드 수 제한으로 외부 호출 동시성도 제한됨
- 변경: 가상 스레드로 동시 호출이 폭증
- 결과: 외부 서비스의
429, 타임아웃, 재시도 폭증, 커넥션 풀 고갈 - 최종: 평균 지연 증가로 TPS 하락
점검
- 외부 호출 실패율(
429,5xx)과 재시도 횟수 증가 - HTTP 클라이언트 커넥션 풀 대기 증가
- 타임아웃이 늘면서 요청이 더 오래 살아남아 동시 처리량이 역으로 감소
대응
- 외부 호출에 동시성 제한(벌크헤드) 적용
- 재시도는 지수 백오프 + 지터, 그리고 idempotency 설계
- 커넥션 풀 설정을 트래픽 모델에 맞게 조정
재시도와 멱등성 관점은 아래 글이 실전적으로 도움이 됩니다.
예시: 세마포어로 외부 호출 동시성 제한
@Component
public class ExternalApiBulkhead {
private final Semaphore semaphore = new Semaphore(100); // 서비스별로 산정
public <T> T call(Callable<T> action) throws Exception {
boolean acquired = semaphore.tryAcquire(1, TimeUnit.SECONDS);
if (!acquired) {
throw new RuntimeException("external api overloaded");
}
try {
return action.call();
} finally {
semaphore.release();
}
}
}
Semaphore 숫자는 “가상 스레드니까 크게”가 아니라, 외부 서비스와 네트워크가 안정적으로 감당 가능한 동시성으로 잡아야 합니다.
4) 동기 로깅/감사 로그가 병목으로 바뀐다
가상 스레드로 동시 요청이 늘면 로그도 동시에 늘어납니다. 이때 다음이 있으면 TPS가 쉽게 떨어집니다.
- 동기 appender(파일, 콘솔, 네트워크)
- 과도한
INFO로그, 큰 JSON 로그 - MDC 조작 비용 증가
특히 컨테이너 환경에서 표준 출력이 수집 에이전트를 거치며 병목이 되는 경우가 많습니다.
점검
- TPS 하락 구간에 CPU가
logging관련으로 뜨는지 - 로그 라인 수가 급증하는지
- stdout write가 지연되는지
대응
- 비동기 appender 사용
- 핫패스 로그 레벨 재조정, 샘플링
- 요청당 로그 크기 축소
예시: Logback 비동기 appender
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>8192</queueSize>
<discardingThreshold>0</discardingThreshold>
<appender-ref ref="STDOUT"/>
</appender>
<root level="INFO">
<appender-ref ref="ASYNC"/>
</root>
</configuration>
5) synchronized / 락 경합이 숨겨진 병목으로 드러난다
가상 스레드는 “대기”가 싸지면서 더 많은 코드가 동시에 실행됩니다. 그 결과 기존에는 잘 안 보이던 락 경합이 크게 부각됩니다.
synchronized로 보호된 캐시- 전역 카운터, 전역 맵
- 세션/토큰 검증 캐시의 단일 락
이런 병목은 TPS를 급격히 깎고 tail latency를 악화시킵니다.
점검
- 스레드 덤프에서 동일 모니터 대기 블록이 많은지
async-profiler의 lock profile 모드로 경합 지점 확인
대응
ConcurrentHashMap+computeIfAbsent로 락 범위 축소LongAdder같은 경합 친화 구조 사용- 가능한 경우 불변 구조 및 샤딩
예시: 전역 락 제거
// 나쁜 예: 전역 synchronized
public class TokenCache {
private final Map<String, String> cache = new HashMap<>();
public synchronized String getOrLoad(String key, Supplier<String> loader) {
return cache.computeIfAbsent(key, k -> loader.get());
}
}
// 개선 예: ConcurrentHashMap
public class TokenCache2 {
private final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();
public String getOrLoad(String key, Supplier<String> loader) {
return cache.computeIfAbsent(key, k -> loader.get());
}
}
6) ThreadLocal 기반 컨텍스트가 비용과 누수를 만든다
가상 스레드는 스레드 수가 많아질 수 있고, 요청 단위로 생성/종료가 빈번합니다. 이때 ThreadLocal을 과도하게 쓰면 다음 문제가 생깁니다.
- 컨텍스트 설정/해제 비용 증가
- 정리 누락 시 메모리 누수성 증상
- 프레임워크가 컨텍스트 전파를 위해 추가 작업을 수행
대표적으로 MDC, 보안 컨텍스트, 트레이싱 컨텍스트가 여기에 해당합니다.
점검
- 요청 수 증가 대비 메모리 사용량이 비정상적으로 증가
- GC 빈도 증가, pause 증가
- ThreadLocal 관련 객체가 힙에 다량 잔존
대응
- 필요한 범위에서만 MDC 설정
- 필수 컨텍스트만 남기고 축소
- 라이브러리의 컨텍스트 전파 방식 점검
예시: MDC 범위 최소화
try {
MDC.put("requestId", requestId);
// 필요한 로그 구간만
log.info("start");
doWork();
} finally {
MDC.remove("requestId");
}
7) 측정/부하 테스트 방법이 바뀌어 “하락처럼 보이는” 착시
가상 스레드 적용 후 TPS가 떨어졌다고 판단했지만, 실제로는 측정 기준이 달라진 경우도 많습니다.
- 부하 발생기가 병목(클라이언트 CPU, 커넥션 제한)
- 서버의 동시성이 늘면서 tail latency가 늘고, 부하 도구가 타임아웃으로 TPS를 낮게 보고
- 오토스케일링, 노드 리소스 제한,
cgroupCPU quota 영향 - 워밍업 부족으로 JIT 최적화가 덜 된 상태에서 비교
특히 컨테이너 환경에서 CPU quota가 걸리면, 가상 스레드가 동시성을 늘려도 CPU가 더 나오지 않아 오히려 스케줄링 경합만 늘어날 수 있습니다.
점검
- 서버/클라이언트 모두에서 TPS를 교차 검증
- 평균만 보지 말고
p95,p99지연과 타임아웃을 함께 확인 - 동일한 워밍업 시간, 동일한 데이터셋, 동일한 커넥션 수로 비교
대응
- 부하 도구를 분산 실행하거나, 클라이언트 병목 제거
- 타임아웃/재시도 정책을 고정하고 비교
- 컨테이너 CPU limit을 명확히 하고 실험
예시: 간단한 지표 비교 체크리스트
- 서버 측: RPS, p95, error rate, GC pause, CPU, DB pool wait
- 클라이언트 측: achieved RPS, timeout count, open connections
- 동일 조건: warmup, duration, payload, keep-alive, TLS
적용 순서 추천: “가상 스레드”가 아니라 “병목”을 먼저 잡는다
TPS 하락을 빠르게 복구하려면, 아래 순서가 효율적입니다.
- DB 풀 포화 여부부터 확인(대부분 1순위)
- 외부 API 호출 동시성 폭증 여부 확인(실패율과 재시도 포함)
- 로깅/관측(특히 stdout) 병목 확인
- 락 경합과 ThreadLocal 비용 확인
- CPU 바운드 핫스팟 확인 후 격리
- 마지막으로 부하 테스트 방법론 재점검
가상 스레드는 “동시성의 비용”을 낮춰주지만, 그만큼 리소스 한계가 더 빨리 드러나고, 제한을 걸어야 할 지점에 제한이 없으면 전체 TPS가 무너질 수 있습니다. 결국 핵심은 가상 스레드를 켠 뒤에야 보이는 병목(커넥션 풀, 외부 호출, 락, 로깅)을 정확히 찾아내고, 시스템의 병목 지점에 맞춰 동시성을 설계하는 것입니다.