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 된 것처럼 보일 수 있습니다.
Read Next
- 실제 문제 가 executor saturation 이라면 Java ExecutorService Tasks Stuck 를 이어서 보세요.
- blocked stage 뒤에 starved fork-join pool 이 있다면 Java ForkJoinPool Starvation 도 같이 보세요.
- queued async work 가 계속 쌓인다면 Java Thread Pool Queue Keeps Growing 를 확인해 보세요.
- 전체 Java 운영 이슈 지도는 Java Troubleshooting Guide 에서 이어서 볼 수 있습니다.
Related Posts
- Java ExecutorService Tasks Stuck
- Java Thread Pool Queue Keeps Growing
- Java ForkJoinPool Starvation
- Java Troubleshooting Guide
Sources:
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.
먼저 읽어볼 가이드
검색 유입이 많은 핵심 글부터 이어서 보세요.
- 미들웨어 트러블슈팅 가이드: 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를 푸는 실전 가이드입니다.
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.