Java CompletableFuture Blocked: 무엇부터 확인할까
마지막 업데이트

Java CompletableFuture Blocked: 무엇부터 확인할까


Java CompletableFuture 체인이 막힌 것처럼 보일 때는 API 자체가 문제인 경우보다, 특정 stage 가 느린 dependency 를 기다리거나, 너무 이른 join() 또는 get() 이 async 흐름을 다시 sync wait 로 바꾸거나, 체인을 실행하는 executor 에 free worker 가 부족한 경우가 더 많습니다.

짧게 말하면 핵심은 이것입니다. 어느 stage 에서 forward progress 가 멈췄는지 정확히 찾는 것입니다. 그 stage 가 downstream I/O 를 기다리는지, 다른 future 를 기다리는지, saturated pool 위에 있는지, 혹은 예외가 가려졌는지만 알아도 디버깅 방향이 훨씬 선명해집니다.


stage 경계와 execution context 부터 본다

blocked future 는 체인을 하나의 검은 상자로 보지 않을 때 훨씬 풀기 쉽습니다.

문제를 이렇게 나눠 보세요.

  • 마지막으로 확실히 성공한 stage 는 어디인가
  • 완료되지 않은 stage 는 어디인가
  • 각 stage 는 어떤 executor 에서 실행됐는가
  • 중간에 synchronous wait 가 들어왔는가

이 구도가 중요한 이유는, “future 가 막혔다” 는 말이 실제로는 아주 특정한 지점에서 파이프라인이 앞으로 가지 못하고 있다는 뜻인 경우가 많기 때문입니다.


blocked chain 은 보통 어떻게 보이나

운영 환경에서는 다음처럼 보이는 경우가 흔합니다.

  • 요청이 join() 또는 get() 에서 오래 멈춘다
  • async 단계가 다음 stage 를 전혀 트리거하지 않는 것처럼 보인다
  • timeout handler 가 예상보다 훨씬 늦게 실행된다
  • thread dump 에서 pool worker 가 dependent task 를 기다린다
  • error handling 이 원래 실패 지점을 가려서 호출자는 그냥 “안 끝난다” 고 느낀다

이런 상황은 모두 “CompletableFuture 가 이상하다” 로 느껴질 수 있지만, 실제로는 stage 설계나 executor 선택의 문제인 경우가 많습니다.


흔한 원인

1. 하나의 stage 가 느린 I/O 를 기다린다

원격 dependency 하나가 전체 chain 을 멈춘 것처럼 보이게 만들 수 있습니다.

예를 들면:

  • HTTP client call
  • database query
  • backing store 로 내려가는 cache miss
  • file 또는 object storage access

future chain 이 그 stage 완료에 의존한다면, 실제 문제는 downstream latency 인데도 전체 pipeline 이 blocked 된 것처럼 보입니다.

2. join() 또는 get() 을 너무 일찍 쓴다

아주 흔한 실수입니다.

async flow 를 만들어 놓고 중간이나 caller 에서 바로 blocking wait 를 해 버리는 패턴입니다.

CompletableFuture<String> future =
    CompletableFuture.supplyAsync(this::remoteCall, pool);

String result = future.join();

remoteCall() 이 느리거나 executor 가 포화 상태라면, caller 가 여기서 멈추며 시스템 전체가 얼어붙은 것처럼 보일 수 있습니다.

특히 아래 위치의 blocking wait 는 더 위험합니다.

  • request handling code 안
  • 또 다른 async stage 안
  • 나머지 파이프라인도 의존하는 thread pool 안

3. dependent stage 가 굶주린 executor 를 공유한다

문제가 future chain 자체가 아니라 executor 인 경우도 많습니다.

같은 pool 안의 task 가 아래 상태라면:

  • I/O 대기
  • 다른 future 대기
  • long-running work 수행

그 뒤에 실행될 stage 는 worker 를 받지 못할 수 있습니다.

4. 예외가 가려진다

사실은 earlier failure 인데 blocked chain 처럼 보일 수 있습니다.

이런 경우가 그렇습니다.

  • exceptionally 안에서 예외를 삼킴
  • fallback 이 애매한 incomplete state 를 반환
  • 로그가 원래 실패 지점을 남기지 않음
  • 호출자는 최종 timeout 만 보게 됨

즉, 겉으로는 “안 끝난다” 지만 실제 사건은 “중간에 실패했고 정상 진행이 끊겼다” 일 수 있습니다.

5. async boundary 가 생각보다 비동기적이지 않다

코드베이스에 아래가 섞이면 자주 이런 일이 생깁니다.

  • synchronous service call
  • async wrapper
  • 즉시 실행되는 blocking join
  • nested future composition

그러면 구조상으로는 비동기처럼 보여도, 부하가 걸리면 사실상 동기적으로 행동하는 chain 이 됩니다.


실전 점검 순서

1. 마지막으로 확실히 끝난 stage 를 찾는다

로그, trace, metric 을 충분히 넣어서 아래를 답할 수 있어야 합니다.

  • 어느 stage 가 시작됐는가
  • 어느 stage 가 끝났는가
  • 어느 stage 가 completion 을 내지 않았는가

이게 incident 범위를 가장 빨리 줄여 줍니다.

2. join()get() 사용 지점을 검색한다

예상보다 이른 시점에 sync wait 가 들어오는지 확인하세요.

특히 아래 위치를 우선 보세요.

  • pool thread 안
  • controller code 안
  • non-blocking 이어야 하는 callback 안

3. 각 stage 뒤의 executor 를 확인한다

모든 stage 가 내가 생각한 곳에서 실행된다고 가정하면 안 됩니다.

확인할 것은:

  • 명시적인 custom executor
  • default common pool 사용 여부
  • 서로 다른 파이프라인이 같은 pool 을 공유하는지

executor 가 이미 굶주린 상태라면 stage logic 만 손봐서는 문제가 안 풀릴 수 있습니다.

4. downstream latency 와 timeout 동작을 본다

특정 stage 가 느린 dependency 를 호출한다면, future chain 은 그 느림을 드러내는 것뿐일 수 있습니다.

질문은 이렇습니다.

  • dependency 가 실제로 느린가
  • timeout 이 있는가
  • retry 가 wait 를 불필요하게 늘리는가

5. 예외를 분명하게 드러나게 만든다

earlier failure 가 나중 timeout 이나 vague fallback 에 묻히지 않게 해야 blocked-chain 분석이 쉬워집니다.


예시: async 모양인데 동기적으로 행동하는 흐름

CompletableFuture<User> userFuture =
    CompletableFuture.supplyAsync(() -> userService.fetch(userId), pool);

CompletableFuture<Account> accountFuture =
    CompletableFuture.supplyAsync(() -> accountService.fetch(userId), pool);

User user = userFuture.join();
Account account = accountFuture.join();

겉으로는 병렬처럼 보이지만 두 service call 이 모두 느리고, 같은 pool 을 많은 요청이 공유한다면 많은 thread 가 join() 에서 대기하고 executor 는 진전을 내기 어려워질 수 있습니다.

더 안전한 방향은 보통 이렇습니다.

  • busy request thread 에서 blocking wait 를 피하기
  • blocking service call 은 shared async pool 과 분리하기
  • stage composition 으로 dependency 를 명시적으로 드러내기

stuck point 를 찾은 뒤 무엇을 바꾸면 좋나

불필요한 blocking wait 를 제거한다

편의를 위해 넣은 join() 또는 get() 이라면, 흐름을 다시 구성하는 것만으로 병렬 진행이 살아나는 경우가 많습니다.

workload 에 맞는 executor 를 쓴다

CPU 중심 async 작업과 blocking remote call 은 같은 executor 전략을 쓰지 않는 편이 보통 낫습니다.

failure 를 잘 보이게 만든다

명확한 로그와 trace 경계가 있으면 blocked-chain 장애가 감 guessing 으로 흐르지 않습니다.

timeout 을 올바른 계층에 둔다

원격 dependency 가 멈췄을 때 future chain 도 예측 가능하게 실패해야지, 끝없이 기다리게 두면 안 됩니다.

nested future dependency 를 줄인다

stage 가 계속 다른 stage 를 기다리는 구조라면, 의존 깊이를 줄이는 쪽이 도움이 됩니다.


장애 중에 던져볼 질문

이 질문이 가장 유용한 경우가 많습니다.

정확히 어느 stage 가 끝나지 않고 있고, 그 stage 는 지금 무엇을 기다리고 있는가?

이 질문이 CompletableFuture API 자체를 의심하는 것보다 훨씬 빨리 답을 줍니다.


FAQ

Q. CompletableFuture 자체가 문제인가

대개는 아닙니다. 어디서 chain 이 막히는지, 그리고 어떤 executor 에서 실행되는지가 더 중요합니다.

Q. join() 은 항상 나쁜가

항상은 아니지만, 너무 이르게 쓰거나 busy server thread 안에서 쓰거나, 같은 async 파이프라인이 의존하는 executor 안에서 쓰면 위험해집니다.

Q. 가장 빠른 첫 단계는 무엇인가

progress 가 멈춘 정확한 stage 를 찾고, 그 stage 가 sync wait 인지, I/O block 인지, executor starvation 인지 확인하는 것입니다.

Q. 사실은 executor starvation 일 수도 있나

그렇습니다. 나중 stage 가 worker 를 받지 못하면 future chain 자체가 blocked 된 것처럼 보일 수 있습니다.


Sources:

먼저 읽어볼 가이드

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