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

Java ForkJoinPool Starvation: 무엇부터 확인할까


Java ForkJoinPool 이 굶주리는 것처럼 보일 때는 pool 자체가 고장 난 경우보다, pool 안에 들어간 작업의 성격이 ForkJoinPool 이 기대하는 형태와 맞지 않는 경우가 더 많습니다. 짧고 CPU 중심인 작업 대신, worker 가 I/O 를 기다리거나, future 를 대기하거나, 깊은 join chain 에 묶이거나, 너무 불균형한 작업을 처리하고 있는 식입니다.

짧게 말하면 핵심은 이것입니다. parallelism 값을 만지기 전에 worker 가 무엇을 기다리고 있는지부터 확인해야 합니다. worker 가 외부 API, DB, 파일 I/O, lock, nested join 을 기다리고 있다면, pool 크기를 키워도 근본 원인은 그대로 남습니다.


먼저 봐야 할 질문: worker 가 무엇 때문에 멈춰 있나

ForkJoinPool 은 아래 같은 작업에 잘 맞습니다.

  • 짧게 끝나는 작업
  • CPU 사용 비중이 큰 작업
  • 재귀적으로 잘게 나눌 수 있는 작업
  • 오래 block 되지 않는 작업

반대로 pool 안의 task 가 원격 호출, DB 조회, 파일 접근, sleep, queue wait, latch 대기 같은 일을 하고 있다면 starvation 이 훨씬 쉽게 생깁니다.

그래서 첫 질문은 “parallelism 이 충분한가” 보다 “worker 가 기다리지 말아야 할 것을 기다리고 있지 않은가” 입니다.


starvation 은 보통 어떻게 보이나

운영 환경에서는 다음 같은 모습으로 나타나는 경우가 많습니다.

  • 처리량이 갑자기 떨어지는데 CPU 는 꽉 차 있지 않다
  • CompletableFuture 체인이 중간에서 잘 진행되지 않는다
  • 비동기 작업이 몰릴 때 응답 지연이 크게 늘어난다
  • 애플리케이션 전체가 과부하처럼 보이지 않는데 queue 된 task 가 쌓인다
  • thread dump 에서 ForkJoinPool worker 들이 park, block, join wait 상태로 많이 보인다

이런 증상은 그냥 “느리다” 로만 보일 수 있지만, 실제로는 작업 모양과 executor 선택의 불일치에서 오는 경우가 많습니다.


흔한 원인

1. blocking work 가 pool 안에서 실행된다

가장 흔한 패턴입니다.

ForkJoinPool 은 worker 가 짧은 task 를 훔쳐 가며 빠르게 끝내는 상황을 기대합니다. 그런데 그 worker 가 I/O, 외부 호출, DB 응답 대기처럼 오래 기다리는 작업을 하면 실질적인 병렬성이 급격히 떨어집니다.

대표적인 예는 다음과 같습니다.

  • supplyAsync 안에서 HTTP 호출
  • common pool 에서 JDBC 조회
  • 파일 또는 object storage 접근
  • Thread.sleep
  • latch, semaphore, queue 대기
CompletableFuture<String> result =
    CompletableFuture.supplyAsync(() -> remoteClient.fetch(), ForkJoinPool.commonPool());

remoteClient.fetch() 가 느리거나 block 되는 작업이라면, common pool 은 fork-join 작업이 아니라 대기 작업으로 쉽게 가득 찰 수 있습니다.

2. commonPool() 이 너무 많은 용도로 공유된다

공용 pool 은 편하지만, 여러 기능이 동시에 기대기 시작하면 문제가 커집니다.

예를 들면:

  • 비즈니스 로직이 CompletableFuture 로 common pool 을 사용
  • 다른 곳에서 parallel stream 사용
  • 백그라운드 변환 작업도 같은 pool 사용

각각만 보면 괜찮아 보여도, 합쳐지면 어느 쪽이 starvation 을 만들었는지 추적하기 어려워집니다.

즉, starvation 이 한 task 의 문제라기보다 pool 소유권과 격리의 문제일 수도 있습니다.

3. join chain 이 너무 깊거나 의존성이 많다

task 가 child task 를 기다리고, 그 child 가 또 다른 작업을 기다리는 식으로 깊은 의존 관계가 생기면, worker 들이 실제 계산보다 기다리는 일에 더 많이 묶일 수 있습니다.

특히:

  • 한 task 가 여러 child 를 join 하고
  • child 도 추가적인 dependent work 를 만들고
  • parallelism 은 그 깊이에 비해 작을 때

이런 조합이면 pool 은 바쁜 것처럼 보여도 실제 진전은 느려집니다.

4. 작업 분할이 불균형하다

ForkJoinPool 은 작업이 고르게 나뉘어야 강합니다. 그런데 한 쪽 partition 만 유난히 무겁고 나머지는 빨리 끝나면, 일부 worker 는 놀고 한 worker 만 병목이 됩니다.

이런 상황은 아래처럼 생깁니다.

  • recursive split 이 너무 일찍 멈춘다
  • partition 별 데이터 편차가 크다
  • 특정 데이터 조각만 비용이 훨씬 크다

이 경우 starvation 처럼 보이지만, 실제 문제는 스레드 수보다 task granularity 입니다.

5. 숨겨진 lock 이 병렬성을 죽인다

계산 작업처럼 보여도 여러 task 가 같은 synchronized block, cache update, shared state 접근에 몰리면 effective concurrency 가 크게 떨어집니다.

worker 가 살아 있어도 서로 기다리기만 한다면 결과적으로 pool 은 굶주린 것처럼 보입니다.


실전 점검 순서

ForkJoinPool starvation 이 의심될 때는 아래 순서가 대체로 빠릅니다.

1. 먼저 thread dump 를 본다

가장 먼저 알고 싶은 것은 ForkJoinPool worker 가 실제로 무엇을 하고 있는지입니다.

확인 포인트는 이렇습니다.

  • CPU 작업을 하는가
  • I/O 에 막혀 있는가
  • join 을 기다리는가
  • lock 을 잡으려 대기하는가

여러 worker 가 같은 외부 의존성 때문에 막혀 있다면, pool 튜닝보다 외부 대기 제거가 먼저입니다.

2. blocking task 와 compute task 를 분리한다

pool 에 들어가는 코드 경로를 나열해서 성격을 나눠 보세요.

DB 조회, HTTP 호출, 파일 읽기, 긴 sleep, queue wait 가 조금이라도 섞여 있다면, 그 작업은 fork-join pool 에서 먼저 빼는 편이 좋습니다.

3. commonPool() 사용 지점을 찾는다

코드베이스에서 아래를 검색해 보세요.

  • ForkJoinPool.commonPool()
  • CompletableFuture.supplyAsync(...)
  • parallelStream()

이 과정을 하면 특정 한 모듈이 아니라 여러 모듈의 합산 부하 때문에 starvation 이 생기는 경우를 빨리 찾을 수 있습니다.

4. parallelism 과 의존 깊이를 같이 본다

task 가 child 또는 sibling stage 를 오래 기다리는 구조라면, 낮은 parallelism 이 증상을 악화시킬 수는 있습니다.

하지만 blocking misuse 와 작업 구조를 먼저 확인하지 않고 스레드 수만 늘리는 것은 대개 효과가 약합니다.

5. task granularity 를 점검한다

task 가 너무 크면 로드 밸런싱이 깨지고, 너무 작으면 스케줄링 오버헤드가 커집니다.

좋은 fork-join 작업은 적당히 쪼개져서 work stealing 이 의미 있게 작동하는 형태입니다.


예시: common pool 에서 blocking service call 실행

CompletableFuture<String> profile =
    CompletableFuture.supplyAsync(() -> userService.fetchProfile(userId));

CompletableFuture<List<Order>> orders =
    CompletableFuture.supplyAsync(() -> orderService.fetchOrders(userId));

겉으로는 비동기처럼 보이지만, 두 메서드가 모두 원격 서비스 호출이라면 기본 common pool worker 가 네트워크 응답을 기다리는 데 묶입니다. 요청이 몰릴 때는 이 작업들 때문에 다른 async 흐름까지 함께 느려질 수 있습니다.

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

  • blocking service call 은 dedicated executor 로 분리
  • ForkJoinPool 은 CPU 중심 병렬 작업에 한정
  • 서로 다른 성격의 workload 는 같은 common pool 에 섞지 않기

원인을 찾은 뒤 어떻게 바꾸면 좋나

blocking work 를 dedicated executor 로 옮긴다

대부분 가장 효과가 큰 수정입니다.

외부 시스템을 기다리는 workload 라면, 지연 특성에 맞는 별도 executor 로 분리하는 편이 낫습니다.

ForkJoinPool 은 진짜 fork-join 작업에만 쓴다

다음 특성이 맞을 때 강합니다.

  • 재귀적으로 분할 가능
  • CPU 중심
  • 짧게 끝남
  • work stealing 이 실질적으로 도움이 됨

이 모양이 아니라면 다른 executor 모델이 더 적합한 경우가 많습니다.

common pool 의 우발적 공유를 줄인다

여러 모듈이 암묵적으로 common pool 을 쓰고 있다면, 중요한 경로는 명시적인 executor 로 분리해서 서로 영향을 덜 주게 만드는 편이 좋습니다.

의존 체인을 단순화한다

task 가 계속 child 나 nested stage 를 기다리는 구조라면, worker 가 대기 조정에 쓰이는 시간을 줄이도록 흐름을 다시 설계하는 것이 도움이 됩니다.

partitioning 로직을 다시 본다

특정 partition 이 유난히 무겁다면, 스레드 수보다 분할 전략을 먼저 손보는 편이 맞습니다.


장애 중에 던져볼 질문

이 질문이 꽤 유용합니다.

지금 parallelism 을 두 배로 늘리면 문제가 정말 사라질까, 아니면 block 된 worker 만 더 늘어날까?

답이 “block 된 worker 만 더 늘 것 같다” 라면, 문제는 pool 크기보다 task 설계에 있습니다.


FAQ

Q. ForkJoinPool 은 blocking work 에 적합한가

보통은 아닙니다. 짧고 CPU 중심이며 잘게 나눌 수 있는 작업에 더 적합합니다.

Q. starvation 이면 무조건 parallelism 이 낮은 건가

아닙니다. 낮은 parallelism 이 증상을 악화시킬 수는 있지만, blocking work, deep join, shared pool 남용이 더 큰 원인인 경우가 많습니다.

Q. CompletableFuture 자체가 문제인가

그 자체보다는 어떤 executor 위에서 어떤 성격의 task 가 실행되는지가 더 중요합니다.

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

thread dump 를 보고 worker 가 무엇을 기다리는지 확인하는 것입니다.


Sources:

먼저 읽어볼 가이드

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