ExecutorService 에 제출한 task 가 멈춘 것처럼 보일 때는 executor 자체보다 더 깊은 문제가 드러나는 경우가 많습니다. 실제로는 task 가 완전히 얼어붙은 것이 아니라, saturated worker 뒤에서 queue 에 쌓여 있거나, 느린 downstream dependency 를 기다리거나, 같은 pool 안의 다른 task 를 기다리거나, backpressure 가 없는 시스템 안에서 overload 가 누적되는 경우가 많습니다.
짧게 말하면 핵심은 이것입니다. pool size 를 바꾸기 전에 queued task, running task, blocked task 를 먼저 구분해야 합니다. 이 셋은 서로 다른 문제를 뜻하고, 전부를 뭉뚱그려 “executor 문제” 로 보면 시간을 많이 낭비하게 됩니다.
executor 크기보다 task 상태부터 본다
pool 을 키운다고 stuck work 가 자동으로 풀리지는 않습니다.
task 가 잘못된 대상을 기다리고 있다면, thread 를 늘려도 아래만 커질 수 있습니다.
- contention
- memory usage
- queue churn
- downstream pressure
그래서 첫 구분은 이게 중요합니다.
- worker 를 아직 받지 못한 task
- 실행 중이지만 block 된 task
- 실행 중이지만 dependency 가 느려서 오래 걸리는 task
이 구분이 끝난 뒤에야 pool sizing 이 의미를 갖습니다.
”tasks stuck” 은 보통 어떻게 보이나
운영 환경에서는 다음처럼 보이는 경우가 흔합니다.
- future 가 제시간에 반환되지 않는다
- queue depth 는 커지는데 completion rate 는 떨어진다
- 모든 worker 가 바쁜데도 진전이 거의 없어 보인다
- caller 는 timeout 되는데 thread pool 은 계속 active 하다
- 운영자는 deadlock 인지 고민하지만 시스템은 아주 조금씩은 움직이고 있다
이 중 많은 경우는 true deadlock 이 아니라 saturation 이나 dependency delay 입니다.
흔한 원인
1. task 가 느린 downstream dependency 를 기다린다
가장 흔한 패턴 중 하나입니다.
executor thread 는 active 이지만 가장 도움이 안 되는 방식으로 active 입니다. 예를 들면:
- database call 대기
- HTTP client 응답 대기
- cache backfill 대기
- file 또는 object storage 대기
- RPC retry 대기
즉, executor 가 멈춘 것처럼 보여도 실제 원인은 downstream latency 입니다.
2. pool 이 포화되었다
active worker 가 현실적으로 처리할 수 있는 양보다 더 많은 작업이 쌓인 상태일 수 있습니다.
신호는 보통 이렇습니다.
- queue depth 가 계속 증가
- active thread count 가 상한 근처에 고정
- task age 증가
- burst 이후 latency 가 회복되지 않음
3. task 가 같은 pool 안의 다른 task 를 기다린다
진행을 멈추게 만드는 대표 패턴입니다.
예를 들면:
- nested
Future.get() - 같은 executor 에 child task 를 제출하고 바로 기다리기
- async work 가 다시 같은 saturated pool 로 들어가기
모든 worker 가 같은 pool 의 다른 작업을 기다리면, formal deadlock 이 아니어도 executor 는 멈춘 것처럼 보일 수 있습니다.
4. backpressure 가 없다
queue depth 와 latency 가 커져도 executor 가 계속 task 를 받아들이는 상황입니다.
그러면 overload 는 조용히 퍼지다가 나중에야 이런 증상으로 드러납니다.
- queue 안에서 오래된 task
- 긴 completion time
- queued work 로 인한 memory growth
5. pool sizing 이 workload 와 맞지 않는다
고정 크기 pool 이 어떤 workload 에는 적절해도 다른 workload 에는 재앙이 될 수 있습니다.
task duration 이 길고 dependency-heavy 하면, pool sizing 만으로는 해결이 안 됩니다. 그래도 blocking pattern 을 이해한 뒤에는 여전히 중요한 조정 포인트입니다.
실전 점검 순서
1. queue depth, active threads, completion rate 를 같이 본다
하나의 metric 만 보면 오해하기 쉽습니다.
알고 싶은 것은:
- 작업이 쌓이고 있는가
- worker 가 꽉 차 있는가
- 실제로 완료되는 작업이 있는가
이 세 가지를 같이 봐야 queuing, blocking, overload 중 무엇이 중심 문제인지 보입니다.
2. stuck task 가 queued 인지, running 인지, blocked 인지 구분한다
이 구분이 incident 의 핵심입니다.
- queued task 는 saturation 또는 backpressure 부재를 시사
- running 이지만 blocked 이면 dependency wait 를 시사
- running 이고 느리면 expensive work 또는 pool sizing 문제를 시사
3. nested wait 를 찾는다
아래 패턴을 우선 찾으세요.
Future.get()CompletableFuture.join()- task 안에서 새 task 를 제출하고 바로 기다리는 코드
같은 executor 안에서 이런 패턴이 반복되면 진행이 의외로 빨리 막힙니다.
4. pool 크기보다 dependency latency 를 먼저 본다
모든 task 가 느린 DB 나 remote service 를 기다리고 있다면, thread 를 늘려도 blocked call 과 downstream pressure 만 늘어날 수 있습니다.
5. backpressure 와 admission 정책을 본다
질문은 이렇습니다.
- queue 가 사실상 무한정 늘어날 수 있는가
- overload 때 시스템이 작업을 밀어내거나 늦추는가
- caller 가 적절히 reject 또는 slowdown 되는가
backpressure 가 없으면 executor 는 overload 가 숨어드는 장소가 됩니다.
예시: 같은 pool 안의 dependency wait
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<String> future = pool.submit(() -> {
Future<String> child = pool.submit(this::remoteCall);
return child.get();
});
간단해 보이지만 parent task 가 worker 하나를 점유한 채, 같은 pool 의 child work 를 기다립니다. 이런 패턴이 병렬 부하와 만나면 executor 전체가 얼어붙은 것처럼 보일 수 있습니다.
더 안전한 방향은 보통 이렇습니다.
- same-pool nested wait 피하기
- blocking 대신 async composition 사용
- 필요하면 blocking workload 분리
패턴을 찾은 뒤 무엇을 바꾸면 좋나
same-pool blocking wait 를 제거한다
가장 빠르게 체감되는 개선으로 이어지는 경우가 많습니다.
backpressure 를 추가하거나 강화한다
시스템이 무한정 work 를 받아들이면 overload 는 너무 늦게 보입니다.
workload 종류를 분리한다
blocking remote call 과 짧은 CPU task 는 서로 다른 executor 가 더 잘 맞는 경우가 많습니다.
queue 정책과 pool sizing 을 다시 본다
workload 모양을 이해한 뒤에 capacity 를 조정해야 의미가 있습니다.
task visibility 를 높인다
incident 때 queue time, execution time, task age, downstream wait time 이 보여야 합니다.
장애 중에 던져볼 질문
이 질문이 꽤 유용합니다.
task 가 정말 stuck 인가, 아니면 처리할 수 있는 양보다 더 많은 work 를 받아들인 시스템 안에서 기다리고 있을 뿐인가?
이 구분 하나로 fix 방향이 완전히 달라질 수 있습니다.
FAQ
Q. “tasks stuck” 이면 항상 deadlock 인가
아닙니다. saturation, blocked dependency, same-pool waiting 인 경우가 훨씬 흔합니다.
Q. thread count 를 늘리는 게 첫 대응인가
보통은 아닙니다. queueing, blocking, dependency latency 중 무엇인지 먼저 알아야 합니다.
Q. 가장 빠른 첫 단계는 무엇인가
task 가 queued 인지, running 인지, blocked 인지 구분하는 것입니다.
Q. queue 자체가 핵심 문제일 수도 있나
그렇습니다. backpressure 가 약하면 queue growth 가 overload 를 오래 숨길 수 있습니다.
Read Next
- 개별 stuck task 보다 queue backlog 가 더 큰 증상이라면 Java Thread Pool Queue Keeps Growing 를 이어서 보세요.
- async chain 이 pool saturation 때문에 멈춘다면 Java CompletableFuture Blocked 와 같이 보세요.
- 같은 workload 가 CPU pressure 도 만든다면 Java JVM CPU High 를 확인해 보세요.
- 전체 Java 운영 이슈 지도는 Java Troubleshooting Guide 에서 이어서 볼 수 있습니다.
Related Posts
- Java Thread Pool Queue Keeps Growing
- Java CompletableFuture Blocked
- Java JVM CPU High
- Java Troubleshooting Guide
Sources:
- https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html
- https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ThreadPoolExecutor.html
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.
먼저 읽어볼 가이드
검색 유입이 많은 핵심 글부터 이어서 보세요.
- 미들웨어 트러블슈팅 가이드: Redis vs RabbitMQ vs Kafka 개발자를 위한 미들웨어 트러블슈팅 허브 글입니다. Redis, RabbitMQ, Kafka 중 어떤 증상부터 먼저 봐야 하는지와 어떤 문제 패턴이 각 시스템에 가까운지 정리합니다.
- Kubernetes CrashLoopBackOff: 먼저 볼 것들 startup failure, probe, config, resource limit 관점에서 CrashLoopBackOff를 어떻게 나눠서 봐야 하는지 정리한 가이드입니다.
- Kafka consumer lag가 계속 늘 때: 트러블슈팅 가이드 Kafka consumer lag가 계속 늘어날 때 무엇부터 봐야 하는지 정리합니다. poll 주기, 처리 속도, rebalance, consumer 설정까지 실전 기준으로 다룹니다.
- Kafka Rebalancing Too Often 가이드 Kafka consumer group에서 rebalance가 너무 자주 일어날 때 membership flapping, poll timing, protocol, assignment churn을 어떤 순서로 봐야 하는지 설명하는 실전 가이드입니다.
- Docker container가 계속 재시작될 때: 먼저 확인할 것들 exit code, command failure, environment mistake, health check 관점에서 Docker restart loop를 푸는 실전 가이드입니다.
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.