- Published on
Spring Boot 3 가상스레드 데드락·성능 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 동시성 문제를 해결하려고 Spring Boot 3 가상스레드(virtual threads)를 켰는데, 오히려 지연이 늘거나 데드락처럼 보이는 정체가 생기는 경우가 있습니다. 가상스레드는 "스레드를 싸게" 만들어 주지만, 블로킹 호출 자체를 없애주지는 않습니다. 즉, 병목의 위치가 CPU에서 I/O, 락, 커넥션 풀로 이동하면서 증상이 더 선명하게 드러납니다.
이 글에서는 Spring Boot 3에서 가상스레드 적용 시 자주 만나는 데드락·락 경합·성능 역전 패턴을 정리하고, 재현 가능한 튜닝 포인트를 코드와 함께 설명합니다.
- 트랜잭션과 DB 풀 튜닝은 아래 글에서 더 깊게 다룹니다: Spring Boot 3 가상스레드 적용 시 트랜잭션·DB풀 튜닝
- DB 레벨 데드락(예: MySQL 1213) 분석은 이 글이 도움이 됩니다: MySQL Deadlock 1213 재현·로그·인덱스로 해결
가상스레드 적용의 핵심 전제: 병목은 사라지지 않고 이동한다
가상스레드는 플랫폼 스레드(캐리어 스레드) 위에서 스케줄링되는 경량 스레드입니다. 요청당 스레드를 만들어도 부담이 적어져서, 전통적인 스레드 풀 고갈 문제는 줄어듭니다.
하지만 다음은 그대로입니다.
- DB 커넥션 풀 크기는 유한하다
- 트랜잭션이 길어지면 락 점유 시간이 늘어난다
- 동기 I/O 호출은 여전히 블로킹이다
- 특정 락(예:
synchronized,ReentrantLock)은 경합 시 대기열을 만든다
따라서 가상스레드 도입 후에 흔히 나타나는 현상은 다음과 같습니다.
- "스레드 고갈" 대신 "DB 커넥션 대기"가 폭증
- TPS는 늘지 않는데 동시 요청만 더 들어와서 DB 락 경합이 증가
- 잘못된 락 순서로 인해 애플리케이션 레벨 데드락이 더 자주 재현
Spring Boot 3에서 가상스레드 활성화
Spring Boot 3.2 이상에서는 설정 한 줄로 톰캣 요청 처리에 가상스레드를 사용할 수 있습니다.
spring:
threads:
virtual:
enabled: true
다만 여기서 중요한 포인트는 "요청 처리 스레드"만 가상스레드로 바뀐다는 점입니다. 애플리케이션 내부에서 별도의 Executor를 쓰거나, 스케줄러/비동기 작업을 별도로 구성했다면 그쪽은 그대로 플랫폼 스레드 풀일 수 있습니다.
데드락으로 보이는 3가지 대표 원인
가상스레드를 켠 뒤 "데드락"이라는 표현이 나오는 상황은 실제로는 크게 세 부류입니다.
- 애플리케이션 락 데드락
- DB 트랜잭션 데드락
- 커넥션 풀/외부 의존성 대기열 정체(데드락처럼 보이는 정체)
각각의 특징과 튜닝 방법이 다릅니다.
1) 애플리케이션 레벨 데드락: 락 순서가 엇갈릴 때
가상스레드는 동시 실행 수를 크게 늘릴 수 있으므로, 기존에는 "운 좋게" 잘 안 터지던 락 순서 문제가 더 자주 드러납니다.
예를 들어 두 락을 서로 다른 순서로 획득하면 데드락이 발생할 수 있습니다.
import java.util.concurrent.locks.ReentrantLock;
public class DeadlockExample {
private final ReentrantLock lockA = new ReentrantLock();
private final ReentrantLock lockB = new ReentrantLock();
public void task1() {
lockA.lock();
try {
sleep(50);
lockB.lock();
try {
// do work
} finally {
lockB.unlock();
}
} finally {
lockA.unlock();
}
}
public void task2() {
lockB.lock();
try {
sleep(50);
lockA.lock();
try {
// do work
} finally {
lockA.unlock();
}
} finally {
lockB.unlock();
}
}
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
해결 원칙
- 락 획득 순서를 전역적으로 통일합니다. 예: 항상
A를 잡고B를 잡는다. - 가능하면 락 범위를 줄이고, 락 내부에서 I/O(특히 DB/HTTP)를 하지 않습니다.
tryLock과 타임아웃을 사용해 "영원히" 대기하지 않게 만듭니다.
if (lockA.tryLock(100, java.util.concurrent.TimeUnit.MILLISECONDS)) {
try {
if (lockB.tryLock(100, java.util.concurrent.TimeUnit.MILLISECONDS)) {
try {
// do work
} finally {
lockB.unlock();
}
} else {
// fallback or retry
}
} finally {
lockA.unlock();
}
}
관측 포인트
- 스레드 덤프에서 서로가 서로의 락을 기다리는지 확인합니다.
- 가상스레드는 수가 많으므로, 덤프가 방대해집니다. "어떤 락 객체"에서 대기 중인지 키워드로 필터링하는 습관이 필요합니다.
2) DB 트랜잭션 데드락: 동시성이 늘면 더 자주 터진다
가상스레드를 켜면 애플리케이션이 더 많은 동시 요청을 처리하려고 시도합니다. 이때 DB에서 다음이 증가합니다.
- 동일 로우/인덱스 범위에 대한 업데이트 경쟁
- 잠금 대기(lock wait) 및 데드락 빈도
특히 다음 패턴이 위험합니다.
- 서로 다른 순서로 로우를 업데이트
- 인덱스가 부족해 범위 잠금이 커짐
- 트랜잭션이 길어서 락 점유 시간이 길어짐
DB 레벨 분석과 인덱스/쿼리 개선은 MySQL Deadlock 1213 재현·로그·인덱스로 해결에서 상세히 다뤘습니다.
애플리케이션에서 할 수 있는 튜닝
- 트랜잭션 범위를 최소화하고, 트랜잭션 내부에서 외부 API 호출을 제거
- 업데이트 순서를 통일(예: 항상
id오름차순으로 갱신) - 재시도 정책을 "무조건"이 아니라 데드락 에러에만 제한적으로 적용
Spring에서 대표적으로는 다음처럼 데드락 계열 예외에만 재시도를 붙입니다.
import org.springframework.dao.DeadlockLoserDataAccessException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@EnableRetry
@Service
public class PaymentService {
@Retryable(
retryFor = DeadlockLoserDataAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2)
)
@Transactional
public void confirmPayment(long paymentId) {
// 짧고 결정적인 쿼리만 수행
// 외부 HTTP 호출은 트랜잭션 밖으로 이동
}
}
3) 데드락처럼 보이는 정체: DB 커넥션 풀 대기열 폭증
가상스레드 도입 후 가장 흔한 "성능 역전" 시나리오입니다.
- 가상스레드로 인해 요청이 더 많이 동시에 들어옴
- 각 요청이 DB 커넥션을 잡으려 함
- 풀 크기는 그대로라서 대기열이 길어짐
- 평균 지연이 급증하고 타임아웃이 발생
이건 엄밀한 의미의 데드락은 아니지만, 운영에서는 "다 멈춘 것 같다"로 관측됩니다.
증상
- 애플리케이션 CPU는 낮은데 응답이 느림
HikariPool관련 타임아웃 로그 증가- DB 커넥션 수가 상한에 붙어 있음
해결 방향
- 풀 크기를 무작정 키우기 전에, 먼저 "동시 DB 사용량"을 제한합니다.
- 트랜잭션 시간을 줄여 커넥션 점유 시간을 줄입니다.
동시 DB 진입 제한(세마포어)
특정 엔드포인트가 DB를 과도하게 두드리면, 가상스레드가 그 트래픽을 전부 흡수하면서 DB가 먼저 무너집니다. 이때는 애플리케이션에서 "DB에 들어가는 동시성"을 제한하는 게 효과적입니다.
import java.util.concurrent.Semaphore;
public class DbConcurrencyGate {
private final Semaphore semaphore;
public DbConcurrencyGate(int maxConcurrentDbOps) {
this.semaphore = new Semaphore(maxConcurrentDbOps);
}
public <T> T call(CheckedSupplier<T> supplier) throws Exception {
semaphore.acquire();
try {
return supplier.get();
} finally {
semaphore.release();
}
}
@FunctionalInterface
public interface CheckedSupplier<T> {
T get() throws Exception;
}
}
위 코드에서 제네릭 표기 T 는 MDX에서 오해될 수 있으니 반드시 인라인 코드로 감싸야 합니다.
이 방식은 "서버는 살아있는데 DB가 죽는" 상황을 줄이고, 과부하 시 빠르게 백프레셔(backpressure)를 걸 수 있습니다.
성능 튜닝 체크리스트: 가상스레드에서 특히 중요한 것들
1) 타임아웃을 계층별로 명시하라
가상스레드는 대기 비용이 낮아 "타임아웃 없이 무한 대기"가 더 오래 지속될 수 있습니다. 다음 타임아웃을 전부 명시하세요.
- DB: 커넥션 획득 타임아웃, 쿼리 타임아웃
- HTTP 클라이언트: connect/read timeout
- 애플리케이션: 요청 처리 타임아웃, 서킷브레이커
예: RestClient 또는 WebClient에서 타임아웃을 강제합니다.
import org.springframework.web.client.RestClient;
RestClient client = RestClient.builder()
.requestFactory(requestFactory -> {
requestFactory.setConnectTimeout(java.time.Duration.ofSeconds(2));
requestFactory.setReadTimeout(java.time.Duration.ofSeconds(3));
})
.build();
2) "가상스레드니까 블로킹 OK"를 오해하지 말 것
블로킹이 허용되는 것과, 블로킹이 공짜인 것은 다릅니다.
- 블로킹이 길어지면 그만큼 요청이 더 쌓이고, DB/외부 API에 더 큰 동시 부하를 줍니다.
- 결과적으로 다운스트림이 병목이면 더 빨리 포화됩니다.
따라서 가상스레드 도입 후에는 특히 다음을 점검해야 합니다.
- 외부 API 호출의 QPS 제한, 큐잉, 백오프
- DB로 향하는 동시성 제한
레이트리밋과 재시도/백오프 설계는 다음 글도 참고할 만합니다: OpenAI 429/RateLimitError 재시도·백오프·큐 설계
3) 플랫폼 스레드 핀ning(pin) 이슈를 의식하라
가상스레드는 특정 상황에서 캐리어 스레드를 "붙잡아" 둘 수 있습니다. 대표적으로는 다음과 같은 경우가 언급됩니다.
synchronized블록 내부에서 블로킹 I/O- 일부 네이티브 호출 또는 오래된 드라이버
실무 팁은 단순합니다.
- 큰 범위의
synchronized를 피하고, 가능하면 더 세밀한 락으로 교체 - 락 내부에서 DB/HTTP 호출 금지
- 드라이버와 라이브러리를 최신으로 유지
4) 관측(Observability) 없이 튜닝하지 말 것
가상스레드는 "스레드 수" 자체가 지표로서 의미가 약해집니다. 대신 다음을 보세요.
- p95, p99 latency
- DB 커넥션 사용률, 커넥션 획득 대기 시간
- DB 락 대기 시간, 데드락 횟수
- 외부 API별 타임아웃/에러율
Micrometer를 쓰고 있다면, Hikari 지표를 대시보드에 고정으로 올리세요.
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
안전한 적용 전략: 한 번에 전체 적용하지 않기
가상스레드 적용은 기능 플래그처럼 단계적으로 진행하는 편이 안전합니다.
- 특정 서비스 또는 특정 엔드포인트만 먼저 적용
- 부하 테스트로 DB 커넥션 대기/락 경합을 먼저 확인
- 동시성 제한(세마포어), 타임아웃, 재시도 정책을 정리
- 그 다음에 전체로 확대
특히 "가상스레드로 TPS가 늘 것"이라는 기대만으로 롤아웃하면, 실제 병목이 DB나 외부 API인 서비스에서는 장애를 더 빨리 유발할 수 있습니다.
결론: 가상스레드는 동시성의 "증폭기"다
Spring Boot 3 가상스레드는 요청 처리 모델을 단순화하고, 스레드 풀 튜닝 부담을 줄여줍니다. 하지만 동시에 병목을 증폭시킵니다.
- 애플리케이션 락은 더 자주 경합하고 데드락이 더 잘 드러납니다.
- DB는 커넥션 풀과 락 경합으로 먼저 포화될 수 있습니다.
- 타임아웃과 백프레셔가 없으면 "멈춘 것 같은" 정체가 쉽게 발생합니다.
정리하면, 가상스레드 적용의 성패는 "스레드"가 아니라 트랜잭션 길이, DB 동시성, 락 설계, 타임아웃, 관측에 달려 있습니다. 위 체크리스트대로 병목을 계측하고, 병목 지점에만 정확히 칼을 대면 가상스레드는 매우 강력한 무기가 됩니다.