Java ExecutorService Tasks Stuck: 무엇부터 확인할까
마지막 업데이트

Java ExecutorService Tasks Stuck: 무엇부터 확인할까


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 를 오래 숨길 수 있습니다.


Sources:

먼저 읽어볼 가이드

검색 유입이 많은 핵심 글부터 이어서 보세요.