Java/JAVA

ThreadPoolTaskExecutor 설정 고민해보기 (feat. 외부 연동 서비스 API 비동기 호출)

JEONG_AMATEUR 2022. 10. 17. 08:08
반응형

ThreadPoolTaskExecutor 설정 어떻게 하고 있었나

이 포스트를 읽는 독자들에게 질문 하나를 던져보고 싶다.

“외부에 있는 다른 서비스, 예를들면 결제 시스템에 결제 API 요청을 하고 응답을 받아야 하는 상황에서 ThreadPoolTaskExecutor의 설정은 어떻게 하는 것이 적절할까?

기본적으로 외부 API 요청은 비교적 시간이 오래 걸리는 작업으로 동기 처리를 하지 않을 것이고 동기 처리하지 않는다는 것은 다른 Thread에 작업을 위임할 것이고 그것은 곧 ThreadPool을 관리해야한다는 것이다.

@EnableAsync
@Configuration
public class AsyncConfiguration implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setThreadNamePrefix("async-thread-");
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(10000);
        executor.initialize();
        return executor;
    }
}

위와 같은 설정은 적절한가?

위의 설정에 대해서 이상함을 느끼지 못했다면 이 포스트는 읽어봄직하다.


무엇이 문제인가?

사실 적절하고 적절하지 않은지 판단하기 위해서는 API를 제공하는 외부 서비스의 스펙부터 알아야 한다.

그렇기에 만약 위에서 설정이 적절한가에 대한 질문에서 “API 스펙에 대해서 더 질문을 해야겠다”고 생각했다면 반은 성공이다.

API 스펙을 질문하기에 앞서 무엇이 문제인지 파악하려면 기본적인 동작 방식에 대해서 알아야한다.

ThreadPoolTaskExecutor 동작 방식

위에 있던 코드를 기준으로 설명을 해본다.

  1. ThreadPool에 작업(task)을 등록하면 설정한 corePoolSize 만큼 ThreadPool에 Thread가 있는지 확인한다. (corePoolSize를 설정하지 않으면 기본값은 1이다.)
  2. corePoolSize 보다 작은 수 만큼의 Thread가 ThreadPool에 존재한다면 새로운 Thread를 ThreadPool에 생성하고 작업을 할당한다.
    • 설령 ThreadPool 안에 Thread가 대기(idle)상태로 존재해도 Thread 숫자가 적으면 새로운 Thread를 생성하고 작업을 할당한다.
    • 만약 corePoolSize 이상의 Thread가 존재하면 ThreadPool안에 대기 상태인 Thread에게 작업을 할당한다.
  3. ThreadPool에 존재하는 모든 Thread가 작업 중(=대기 상태의 Thread가 하나도 없는 상태)이라면 BlockingQueue에 작업(task)을 넣어 작업을 대기시킨다.
  4. 작업 중인 Thread가 작업을 마치면 BlockingQueue에 처리해야할 작업(task)가 있는지 확인한다. 큐가 비어있지 않으면(=처리해야할 작업이 있으면) 큐로 부터 작업을 가져와서 다시 작업을 수행하는 것을 반복한다.
  5. 작업이 급격하게 많이 발생해서 설정한 큐의 크기 만큼 큐에 작업이 가득 차게되었고 가득 차있음에도 불구하고 더 작업이 요청되면 ThreadPool에 Thread가 1개 더 생성된다.
    • 현재 ThreadPool의 Thread 수가 maxPoolSize보다 작은 경우에는 Thread를 추가하지만, 현재 ThreadPool의 Thread 수가 maxPoolSize에 도달한 경우에는 더 이상 Thread를 생성할 수 없고 큐에 대기시킬 수도 없기 때문에 TaskRejectedException(RejectedExecutionException을 상속한 예외)이 발생한다.
    • 예외가 발생하는것이 기본 설정이고 다른 정책을 지정해줄 수 있다. ex) CallerRunsPolicy : ThreadPool에 작업을 넣으려고 한 Thread에서 직접 실행하는 전략
  6. 예외까지 발생할 정도는 아니고 Thread가 corePoolSize 보다 큰 개수 만큼 Thread가 생성된 상태에서 큐에 쌓인 작업을 잘 소화하여 큐를 비울 수 있게 되었다고 하면, keepAliveTime(기본 값 60초)동안 corePoolSize 수보다 초과하여 생긴 Thread들은 대기 상태로 대기하다가 ThreadPool에서 제거된다.

간단하게 어떻게 동작하는지에 대해서 알아봤다.

더 구체적으로는 큐가 가득 차기 전에 Thread를 미리 생성하여 대기 시키는 기능, ThreadPool에 있는 Thread의 timeout을 지정하는 기능 등 옵션이 더 있지만 기본적인 맥락/흐름, 동작 방식은 설명이 되었다.

문제 1 : corePoolSize

무조건적인 기준은 없고 산정 기준이 명확하면 적절하다고 볼 수 있다. (=튜닝의 영역)

제일 앞에 보여줬던 설정에서는 corePoolSize를 8개로 정했다.

근거가 없다. 한 번 생성되기 시작하면 corePoolSize 이하로 내려가지 않고 idle 상태로 대기하기에 적절한 자원 관리가 필요하다.

외부 API가 얼마나 자주 호출되는 것인지, API 응답 시간이 최대 얼마나 걸리는지(timeout), 외부 서비스의 최대 처리량은 어느정돈지, … 등의 정보가 있어야 한다.

뿐만아니라 이 ThreadPool 말고 애플리케이션에서 또 다른 ThreadPool은 얼마나 있는지 애플리케이션 동작하는 환경(예를 들면 컴퓨터 스펙)은 어떤지도 고려 대상이다.

하다못해 산식(?)도 근거로 쓸 수 있다.

Thread 공식 = JVM에 할당된 코어 수 * (1 + API 평균 응답시간(ms) / 응답을 처리하는데 걸리는 평균 시간)

ex) JVM에 할당된 코어 = 3, API 평균 응답시간 = 100ms, 응답을 처리하는데 걸리는 평균 시간 = 25ms 라면 3 * (1 + 100/25) = 15개

이런 식으로 지정할 수도 있다. 결과적으로 근거가 있는 시간이어야 한다.

문제 2 : maxPoolSize와 queueCapacity의 trade off

큐 사이즈를 크게하고 maxPoolSize를 작게 하는 것은 자원(CPU 사용량, Thread를 생성 삭제하는 운영체제 자원, context switching 오버 헤드 비용)이 적게 사용되는 장점이 있지만 낮은 처리량을 보일 수 밖에 없다.

그리고 요청이 오래걸리는 경우 Thread가 충분하지 않아 대기하는 작업들이 많아져서 문제를 일으킬 수 있다.

위의 예시에서 API 1개를 처리하는데 100ms가 걸린다고 가정하면 최대 10개의 Thread가 작업을 처리하기에 1초에는 100건을 처리할 수 있을 것으로 처리량을 예상할 수 있다.

그러면 큐에 1,000번째에 있는 작업은 앞으로 10초 후에 외부 API를 요청하고 완료될텐데 큐 사이즈 10,000개라니… 과연 이게 적절한가에 대해서는 다시 생각해봐야한다. (대부분의 서비스(결제, 조회, …)를 대입해봐도 이 값은 적절한 값이 아니다.)

반대로 큐 사이즈를 작게 잡으면 어떨까? 큐 사이즈를 작게 설정하려면 통상 Thread를 충분히 늘릴 수 있도록 해야할 것이다. (예외를 자주 맞이하려는게 아니라면)

ThreadPool에 있는 Thread 수를 많이 가지는 대신 자원적으로 손해(CPU 사용량이 많아지고 context switching 오버헤드 비용 과도하게 발생)를 많이 본다. 결과적으로 처리량을 원하는 만큼 늘릴 수 없어지는 문제가 발생한다.

즉 적절히 조율된 의미있는 값을 설정해야한다.

앞선 예시에서 queueCapacity를 10,000으로 잡은 것은 maxPoolSize만큼 Thread가 성장하는데 시간도 오래걸리고 적절하지 않으므로 적당히 1,000개 까지 줄인다고 치고 corePoolSize와 maxPoolSize를 각각 25개, 50개씩 설정한다고 치고 계산을 다시해보자.

하나의 API를 요청하고 처리하는데 시간이 100ms라고 가정하면 25개 쓰레드니까 오버헤드 비용 등은 생략하면 1초에 250번의 요청을 처리할 수 있다.

1초 당 평균 400개의 작업(task)이 쌓이게 되면 어떻게 될까? 1초에 250개 처리량이기에 큐에 쌓이다가 Thread 수가 maxPoolSize까지 늘어나면 1초에 500개까지 처리할 수 있으므로 Thread가 증가했다가 다시 idle 상태인 것들을 제거하면서 작업 중인 ThreadPool내 Thread 수가 조정되면서 처리가 될 것이다.

통계를 기준으로하는 튜닝의 영역으로 근거를 잘 찾으면 되겠다.

문제 3 : 예외 처리 부재

우리는 위에서 부터 글을 읽어왔기 때문에 작업(task)이 큐에 가득 쌓였음에도 불구하고 계속 작업을 큐에 넣으려고하면 무슨 예외가 발생하는지 알고 있다. (RejectedExecutionException)

(주변 동료에게 어떤 예외가 발생하게 될 것 같냐고 물어보는 것도 좋은 영감을 줄 수 있을 것이다. 필자도 알아보기 전에는 stackoverflow error 같은거 아닐까 했었다.)

@EnableAsync
@Configuration
public class AsyncConfiguration implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setThreadNamePrefix("async-thread-");
        executor.setCorePoolSize(25);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(500);
        executor.setKeepAliveSeconds(30);
        executor.setRejectedExecutionHandler(rejectedExecutionHandler());
        executor.initialize();
        return executor;
    }

    private RejectedExecutionHandler rejectedExecutionHandler() {
        return (runnable, executor) -> {
            throw new BusinessException(ErrorCode.SEARCH_THROUGHPUT_EXCEEDED_EXCEPTION);
        };
    }

}

사실 위와 같이 처리하는 것은 하나의 예일 뿐이다.

rejectedExecutionHandler를 지정하는 것은 하나의 정책을 정하는 것일 뿐 예외를 해결하는 것은 아니다.

경우에 따라서 기존에 제공하는 정책을 지정해도 좋다만 이제 위와 같이 애플리케이션에서 제공하는 비즈니스 예외로 변환해서 던지게 되면 ControllerAdvice 같은 곳에서 공통으로 예외처리 할 수 있지 않을까 한다.

편의에 따라 미리 제공하는 rejectExecutionHandler를 지정할 수 있다.

  • DiscardOldestPolicy : 큐에서 가장 오래된 요청을 버리고 이번에 발생한 요청은 넣어주는 정책
  • DiscardPolicy : 큐 사이즈 이상의 요청 자체를 버리는 정책
  • AbortPolicy : RejectedExecutionException 예외 발생 (기본 정책)
  • CallerRunsPolicy : ThreadPool에 작업을 요청한 Thread(Caller)가 대신 작업을 처리하는 정책

개인적인 추천은 애플리케이션 공통 Exception으로 변환해서 던지거나, 기본 정책대로 RejectedExecutionException이 발생하면 스프링에서 wrapping 해주는 TaskRejectedException을 공통 처리해도 좋을 것 같다.

끝으로…

이런 것을 고민해보는 포스트는 아마 많지는 않을 것 같다.

누군가는 쓸데없는 고민이라고 할 수 있겠지만 인터넷에 굴러다니는 설정을 대충 복사해서 사용해보기 보다는 우리가 개발하고 있는 서비스에 적절한 설정이 무엇인지 한 번 생각해보고 적용해보는 것도 좋을 것 같다.

반응형