로그 한 줄 없이 서버가 멈췄다 - Virtual Thread와 커넥션 풀 Starvation
서론
개발 서버가 완전히 멈췄습니다.
어드민 페이지에서 검색 버튼을 눌렀는데, 아무 반응이 없었습니다.
처음엔 네트워크 문제라고 생각했습니다. 새로고침해서 다시 눌러봤습니다.
여전히 반응 없음.
DataDog를 확인했을 때 진짜 이상한 걸 발견했습니다.
제가 버튼을 누른 시간 이후로 모든 요청이 사라졌습니다.
심지어 ALB의 헬스체크조차 찍히지 않았습니다.
서버가 죽은 건가? 하지만 빈스톡에서 확인해보니 CPU, 메모리, 네트워크 I/O, 디스크 모두 정상이었습니다.
컨테이너 안으로 들어가서 자바 프로세스에 ping을 날려봤습니다. 반응 없음.
프로세스는 떠 있는데, 모든 요청이 그냥 펜딩되는 느낌이었습니다.
가장 당황스러웠던 건 로그가 단 한 줄도 없었다는 것입니다.
HikariCP 타임아웃도, 커넥션 리크 경고도, 에러 로그도 없었습니다.
그냥 조용히, 완전히 멈춰있었습니다.
이번 포스팅에서는 이 장애의 원인과 해결 과정을 공유합니다.
Virtual Thread, 병렬 DB 검색, 그리고 개발 환경의 작은 커넥션 풀이 만난 완벽한 재앙이었습니다.
문제 상황 정리
초기 증상
사용자 행동: 관리자 페이지에서 "전체 검색" 버튼 클릭
시간: 14:23:1514:23:15: 버튼 클릭
14:23:20: 응답 없음 (새로고침 후 재시도)
14:23:25: 여전히 응답 없음
14:23:30: DataDog 확인 → 14:23:15 이후 모든 요청 사라짐
인프라 상태 확인
1. AWS 빈스톡 (Elastic Beanstalk)
WAS 인스턴스: 2대
CPU: 20% (정상)
메모리: 40% (정상)
네트워크 I/O: 정상
디스크: 정상모든 메트릭이 정상이었습니다.
2. DataDog APM
# 14:23:15 이후
- Request Count: 0
- ALB Health Check: 0 (찍히지 않음)
- JVM Metrics: 정상 (GC, Heap 모두 정상)가장 이상한 점은 ALB 헬스체크조차 응답하지 않는다는 것이었습니다.
서버가 아예 죽은 게 아니라면, 헬스체크는 응답해야 정상입니다.
3. 데이터베이스
-- MySQL processlist 확인
mysql> show processlist;
+-----+------+-----------+------+---------+------+----------+------------------+
| Id | User | Host | db | Command | Time | State | Info |
+-----+------+-----------+------+---------+------+----------+------------------+
| 123 | app | 10.0.1.5 | mydb | Sleep | 45 | (none) | NULL |
| 124 | app | 10.0.1.6 | mydb | Sleep | 42 | (none) | NULL |
...
+-----+------+-----------+------+---------+------+----------+------------------+DB 쪽에는 락(Lock)이 잡힌 쿼리가 없었습니다.
커넥션들이 Sleep 상태로 그냥 놀고 있었습니다.
4. 컨테이너 내부 확인
# 컨테이너 접속
$ docker exec -it <container_id> bash
# 자바 프로세스 확인
$ ps aux | grep java
app 1 2.1 12.3 4567890 1234567 ? Ssl 14:00 0:35 java -jar app.jar
# 프로세스는 떠 있음
# 하지만 curl로 헬스체크 요청 시도
$ curl http://localhost:8080/actuator/health
(응답 없음, 타임아웃)프로세스는 살아있는데, 모든 요청이 그냥 멈춰있었습니다.
원인 분석
팀원과의 대화
나: "혹시 최근에 이 검색 API 손대신 거 있으세요?"
팀원: "아, 그거 너무 느려서 병렬로 바꿨습니다."
나: "...병렬로요?"
팀원: "네, Virtual Thread로 100개 테이블을 동시에 검색하게 했어요."
나: "100개요...?"검색 로직을 확인해봤습니다.
순간 식은땀이 났습니다.
문제의 코드
@Service
public class SearchService {
@Autowired
private List<SearchRepository> repositories; // 100개의 Repository
public SearchResult search(SearchRequest request) {
// Virtual Thread로 병렬 검색
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<CompletableFuture<List<Item>>> futures = repositories.stream()
.map(repo -> CompletableFuture.supplyAsync(
() -> repo.search(request),
executor
))
.toList();
// 모든 결과 대기
List<Item> allResults = futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.toList();
return new SearchResult(allResults);
}
}
}한 번의 검색 요청이 100개의 DB 커넥션을 동시에 획득하려고 합니다.
환경 설정 확인
운영 환경 (application-prod.yml)
spring:
datasource:
hikari:
maximum-pool-size: 255운영 환경은 최대 255개의 커넥션을 사용할 수 있습니다.
개발 환경 (application-dev.yml)
spring:
datasource:
hikari:
maximum-pool-size: 50개발 환경은 고작 50개.
문제의 조합
검색 요청 1회 = DB 커넥션 100개 동시 필요
WAS 인스턴스 2대
개발 환경 커넥션 풀: 인스턴스당 50개계산해보면:
검색 요청 1번만 해도 = 100개 커넥션 필요
하지만 인스턴스당 최대 50개밖에 없음
→ 50개는 획득 성공, 나머지 50개는 대기
대기 중인 50개는 영원히 커넥션을 받을 수 없음
→ 왜? 이미 획득한 50개가 반환되지 않기 때문
→ 전체 요청이 멈춤 (Deadlock)제가 버튼을 두 번 눌렀습니다.
첫 번째 요청이 50개를 물고 멈췄고, 두 번째 요청도 대기 상태로 진입했습니다.
그 순간부터 모든 요청이 얼어붙었습니다.
Virtual Thread가 뭐길래?
본격적으로 왜 로그가 없었는지 설명하기 전에, Virtual Thread에 대해 간단히 알아보겠습니다.
Platform Thread vs Virtual Thread
기존 Java의 Platform Thread(일반 스레드)는 OS 스레드와 1:1 매핑됩니다.
OS 스레드는 생성 비용이 크고, 개수도 제한적입니다.
// Platform Thread: OS 스레드와 1:1 매핑
Thread platformThread = new Thread(() -> {
// 무거움, 개수 제한 있음
});Virtual Thread(Java 21+) 는 다릅니다.
JVM이 관리하는 가벼운 스레드입니다.
// Virtual Thread: JVM이 관리
Thread virtualThread = Thread.ofVirtual().start(() -> {
// 가벼움, 수백만 개 생성 가능
});Carrier Thread (운반 스레드)
Virtual Thread는 실제로 실행될 때 Carrier Thread(Platform Thread) 위에서 돌아갑니다.
Virtual Thread 1 ──┐
Virtual Thread 2 ──┼── Carrier Thread 1 (Platform Thread)
Virtual Thread 3 ──┘
Virtual Thread 4 ──┐
Virtual Thread 5 ──┼── Carrier Thread 2 (Platform Thread)
Virtual Thread 6 ──┘핵심 메커니즘:
Virtual Thread가 블로킹 작업을 만나면?
1. Carrier Thread를 양보 (Unmount)
2. 대기 큐로 이동
3. 다른 Virtual Thread가 Carrier Thread 사용
4. 블로킹이 끝나면 다시 Carrier Thread에 탑승 (Mount)이제 이 메커니즘이 왜 문제였는지 알아보겠습니다.
왜 로그가 없었을까?
가장 당황스러웠던 점은 HikariCP의 타임아웃이나 리크 경고가 전혀 없었다는 것입니다.
HikariCP 타임아웃 조건
HikariCP는 다음 조건에서 타임아웃을 발생시킵니다:
spring:
datasource:
hikari:
connection-timeout: 30000 # 30초 (기본값)커넥션 획득을 시도했지만 30초 내에 얻지 못하면 예외 발생:
SQLTransientConnectionException:
HikariPool - Connection is not available, request timed out after 30000ms.문제: Virtual Thread의 블로킹
Virtual Thread는 블로킹 작업을 만나면 Carrier Thread(Platform Thread)를 양보하고 대기합니다.
// Virtual Thread가 커넥션 획득 시도
Connection conn = dataSource.getConnection(); // 여기서 블로킹
// Carrier Thread는 양보되고,
// Virtual Thread는 대기 큐에서 조용히 기다림문제는 모든 Virtual Thread가 동시에 대기 상태에 빠졌다는 것입니다.
Virtual Thread 1~50: 커넥션 획득 대기 (블로킹)
Virtual Thread 51~60: 커넥션 획득 대기 (블로킹)
...Carrier Thread는 양보됐지만, Virtual Thread들은 그냥 조용히 대기 큐에서 기다립니다.
타임아웃 체크를 할 Platform Thread 자체가 없는 상태였습니다.
HikariCP 리크 탐지는?
HikariCP에는 커넥션 리크(Connection Leak)를 탐지하는 기능이 있습니다:
spring:
datasource:
hikari:
leak-detection-threshold: 60000 # 60초리크 탐지 조건:
1. 커넥션을 획득함 ✓
2. 60초 동안 반환하지 않음 ✓
→ "커넥션 리크 경고" 출력하지만 우리 상황은 이랬습니다:
1. 커넥션 획득 시도 중 ✗ (대기만 하고 있음)
2. 아직 획득하지 못함 ✗
→ 리크 탐지 조건에 해당 안 됨
→ 경고 없음쉽게 말하면:
HikariCP 리크 탐지 = "빌려간 물건을 안 돌려줄 때" 경고
우리 상황 = "빌리지도 못하고 대기만 하는 중"
→ 경고할 조건이 아님결국 커넥션 풀은 꽉 찼지만, Hikari의 모든 경고 조건을 비켜간 상태가 됐습니다.
해결 방법
1. 즉시 조치: 개발 환경 커넥션 풀 증가
# application-dev.yml
spring:
datasource:
hikari:
maximum-pool-size: 100 # 50 → 100당장 장애를 해결하기 위해 커넥션 풀을 늘렸습니다.
하지만 이건 임시방편일 뿐입니다.
2. 근본 해결: 병렬 처리 개선
병렬 검색 로직 자체를 수정했습니다.
Before (문제의 코드)
public SearchResult search(SearchRequest request) {
// 100개 Repository를 모두 병렬로
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<CompletableFuture<List<Item>>> futures = repositories.stream()
.map(repo -> CompletableFuture.supplyAsync(
() -> repo.search(request),
executor
))
.toList();
return new SearchResult(futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.toList());
}
}After (개선된 코드)
@Service
public class SearchService {
private static final int MAX_PARALLEL_SEARCHES = 10; // 동시 검색 제한
@Autowired
private List<SearchRepository> repositories;
public SearchResult search(SearchRequest request) {
List<Item> allResults = new ArrayList<>();
// 10개씩 묶어서 순차적으로 처리
for (int i = 0; i < repositories.size(); i += MAX_PARALLEL_SEARCHES) {
List<SearchRepository> batch = repositories.subList(
i,
Math.min(i + MAX_PARALLEL_SEARCHES, repositories.size())
);
// 배치 단위로 병렬 처리
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<CompletableFuture<List<Item>>> futures = batch.stream()
.map(repo -> CompletableFuture.supplyAsync(
() -> repo.search(request),
executor
))
.toList();
List<Item> batchResults = futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.toList();
allResults.addAll(batchResults);
}
}
return new SearchResult(allResults);
}
}변경 내용:
- 한 번에 100개 → 10개씩 배치 처리
- 10개씩 묶어서 순차적으로 진행 (총 10번 반복)
- 요청당 최대 10개 커넥션만 사용
- 응답 시간은 약간 느려졌지만, 안정성 확보
3. 모니터링 개선
# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 100
connection-timeout: 10000 # 30초 → 10초로 단축
leak-detection-threshold: 30000 # 리크 탐지 활성화 (30초)
management:
metrics:
enable:
hikari: true # HikariCP 메트릭 활성화DataDog 대시보드 추가:
- HikariCP Active Connections
- HikariCP Idle Connections
- HikariCP Pending Threads
- Connection Acquire Time (P95, P99)
교훈
1. Virtual Thread의 함정
Virtual Thread가 가볍다고 해서 무한정 만들어도 된다는 뜻이 아닙니다.
Virtual Thread는 많이 만들 수 있지만, DB 커넥션 같은 리소스는 여전히 제한적입니다.
// Bad: 리소스 제약 무시
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
futures = repositories.stream() // 100개
.map(repo -> CompletableFuture.supplyAsync(...))
.toList();
// → 커넥션 풀 고갈
}
// Good: 리소스 제약 고려
Semaphore semaphore = new Semaphore(10); // 최대 10개로 제한
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
futures = repositories.stream()
.map(repo -> CompletableFuture.supplyAsync(() -> {
semaphore.acquire();
try {
return repo.search(request);
} finally {
semaphore.release();
}
}))
.toList();
}2. 조용한 장애가 가장 위험하다
로그 한 줄 없이 서버가 멈추는 장애가 가장 디버깅하기 어렵습니다.
Virtual Thread + 커넥션 풀 고갈 조합은 특히 조용합니다:
- HikariCP 타임아웃: 작동 안 함 (Carrier Thread가 없음)
- 리크 탐지: 작동 안 함 (커넥션 획득 실패)
- 에러 로그: 없음
필수 모니터링 항목:
- HikariCP Active/Idle Connections
- Pending Threads
- Connection Acquire Time (P95, P99)
3. 개발 환경은 운영의 축소판이 아니다
개발 환경에서만 터지는 버그가 있습니다.
운영 환경: 커넥션 풀 255개 → 100개 병렬 처리 OK
개발 환경: 커넥션 풀 50개 → 100개 병렬 처리 💥리소스가 적은 개발 환경에서 먼저 터지는 건 오히려 다행입니다.
운영에서 터지면 훨씬 큰 문제니까요.
4. 병렬 처리 최적값은 측정으로 찾아라
병렬 처리 개수는 환경마다 다릅니다.
100개 병렬 → 응답 빠름, 리소스 고갈 위험
10개 배치 → 응답 느림, 안정성 확보저희는 부하 테스트를 통해 10개 배치가 적절하다고 판단했습니다.
여러분의 환경은 다를 수 있으니, 직접 측정해보세요.
결론
이번 장애는 제게 두 가지 교훈을 남겼습니다.
첫째, “최신 기술”이라는 말에 속지 말자.
Virtual Thread가 나왔을 때 정말 흥분했습니다.
“드디어 스레드 걱정 없이 마음껏 병렬 처리를 할 수 있겠구나!”
하지만 현실은 달랐습니다.
아무리 가벼운 스레드라도, 결국 DB 커넥션이라는 병목 앞에서는 무력했습니다.
팀원이 “100개 테이블을 동시에 검색”이라는 말을 들었을 때, 식은땀이 났던 이유입니다.
새로운 기술이 기존 문제를 해결해줄 순 있지만, 새로운 함정을 만들기도 합니다.
둘째, 장애는 가장 예상치 못한 곳에서 터진다.
운영 환경은 괜찮았습니다. 커넥션 풀이 255개니까요.
문제는 아무도 신경 쓰지 않던 개발 환경이었습니다.
만약 제가 그날 검색 버튼을 누르지 않았다면?
이 코드는 운영에 배포됐을 겁니다.
그리고 어느 날, 트래픽이 조금만 몰렸을 때 똑같이 터졌겠죠.
개발 환경에서 먼저 터진 건 불행이 아니라 행운이었습니다.
지금 돌아보면, 이 장애를 겪은 게 잘한 일이라고 생각합니다.
Virtual Thread에 대한 환상도 깨졌고, 모니터링의 중요성도 다시 깨달았으니까요.
여러분도 Virtual Thread를 쓰신다면, 한 번쯤 의심해보세요.
“이게 정말 무한 동시성을 보장할까?”
“리소스 제약은 없을까?”
“개발 환경에서도 괜찮을까?”
그 의심이 여러분을 이런 장애로부터 지켜줄 겁니다.
참고 자료
https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html
> https://github.com/brettwooldridge/HikariCP
> https://spring.io/blog/2022/10/11/embracing-virtual-threads
Java 동시성 프로그래밍 (브라이언 게츠)
