Thread starvation 해결하기

연유라떼·2024년 4월 4일

요청 시간이 굉장히 오래걸리는 요청에 대하여(외부 API 요청)
@Async 설정을 통해 비동기 처리를 하니
별도의 스레드로 설정되고 이에 대하여 응답 시간도 줄어들었다

그러나 이 Async 서비스를 여러번 호출하다보니 다음과 같은 에러가 떴다

Thread starvation or clock leap detected (housekeeper delta=1h54s12ms952µs400ns).

찾아보니 하나의 스레드가 점유하는 시간이 너무 길어서 이에 대하여 스레드 고갈 현상이 일어난 것이었다

이를 위하여 Thread에 대한 처리가 필요
직접 처리하는 것이 아닌 ThreadExecutor설정을 통하여 스레드에 대한 설정을 수정

ThreadExecutor 설정하기

Thread Executor는 자바에서 스레드 생성을 관리하고 실행하는 프레임워크다

스레드를 직접 생성하고 관리하는 방식보다 훨씬 효율적이고 안전하게 멀티스레딩 환경 구축에 도움이 된다

ThreadExecutor 특징

  • 스레드 풀(Thread Pool) 관리: Thread Executor는 스레드들을 미리 생성해놓은 스레드 풀을 사용한다. 새로운 작업이 들어오면, 풀에 있는 스레드 중 하나를 할당하여 작업을 처리
    작업이 완료되면 스레드는 파괴되지 않고 다시 풀로 돌아가 대기
    이렇게 하면 스레드 생성 및 파괴에 드는 오버헤드를 크게 줄일 수 있습니다.

  • 작업 제출 및 분리: 사용자는 Runnable 또는 Callable 인터페이스를 구현하여 작업을 정의하고 Executor에 제출하기만 하면 됨
    스레드 생성과 실행은 Executor가 알아서 처리하므로, 개발자는 복잡한 스레드 관리 로직에서 벗어나 비즈니스 로직에만 집중 가능

  • 다양한 종류의 Executor: 자바는 Executors 클래스를 통해 다양한 종류의 Executor를 제공

    • newFixedThreadPool(): 정해진 개수의 스레드를 가진 풀을 생성합니다.
    • newCachedThreadPool(): 필요에 따라 스레드를 생성하고, 사용하지 않는 스레드는 일정 시간 후 제거합니다.
    • newSingleThreadExecutor(): 하나의 스레드만 가진 Executor로, 작업들이 순차적으로 실행되도록 보장합니다.

Thread의 Pool 크기 및 용량 설정

private static final int CORE_POOL_SIZE = 10;
private static final int MAX_POOL_SIZE = 30;
private static final int QUEUE_CAPACITY = 100;

CORE_POOL_SIZE: 기본 스레드 개수입니다. 작업량이 적을 때 항상 유지되는 스레드 수입니다. 이 예제에서는 항상 10개의 스레드가 준비되어 있습니다.

MAX_POOL_SIZE: 최대 스레드 개수입니다. 작업량이 많아질 경우, 큐에 대기 중인 작업이 가득 찼을 때 이 한도까지 스레드를 추가로 생성합니다. 이 예제에서는 최대 30개까지 스레드를 늘릴 수 있습니다.

QUEUE_CAPACITY: 작업이 스레드를 할당받기 위해 대기하는 큐의 용량입니다. CORE_POOL_SIZE를 초과하는 작업은 이 큐에 쌓입니다. 이 예제에서는 최대 100개의 작업을 대기시킬 수 있습니다.

ThreadPoolTaskExecutor 설정

제공해주신 코드는 Spring Framework에서 ThreadPoolTaskExecutor를 사용하여 비동기 작업을 처리하는 Thread Pool을 설정하는 예제입니다. 이 코드를 통해 애플리케이션의 멀티스레딩 동작을 세밀하게 제어할 수 있습니다.

코드 분석 및 주요 설정
이 코드는 크게 세 부분으로 나눌 수 있습니다. 스레드 풀의 기본 크기 및 용량을 정의하는 상수, ThreadPoolTaskExecutor를 설정하는 taskExecutor 메서드, 그리고 작업 거부 시 동작을 정의하는 rejectedExecutionHandler 메서드입니다.

  1. 스레드 풀의 크기 및 용량 설정
    Java

private static final int CORE_POOL_SIZE = 10;
private static final int MAX_POOL_SIZE = 30;
private static final int QUEUE_CAPACITY = 100;
CORE_POOL_SIZE: 기본 스레드 개수입니다. 작업량이 적을 때 항상 유지되는 스레드 수입니다. 이 예제에서는 항상 10개의 스레드가 준비되어 있습니다.

MAX_POOL_SIZE: 최대 스레드 개수입니다. 작업량이 많아질 경우, 큐에 대기 중인 작업이 가득 찼을 때 이 한도까지 스레드를 추가로 생성합니다. 이 예제에서는 최대 30개까지 스레드를 늘릴 수 있습니다.

QUEUE_CAPACITY: 작업이 스레드를 할당받기 위해 대기하는 큐의 용량입니다. CORE_POOL_SIZE를 초과하는 작업은 이 큐에 쌓입니다. 이 예제에서는 최대 100개의 작업을 대기시킬 수 있습니다.

  1. ThreadPoolTaskExecutor 설정 (taskExecutor 메서드)
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    // ... 설정 코드 ...
    executor.initialize();
    return executor;
}

@Bean(name = "taskExecutor"): 이 메서드가 생성하는 객체(Executor)를 스프링 컨테이너에 taskExecutor라는 이름의 빈(Bean)으로 등록합니다.

setThreadNamePrefix("Async-"): 생성되는 스레드의 이름에 "Async-"라는 접두사를 붙입니다. 이를 통해 로그를 분석할 때 어떤 스레드가 비동기 작업을 처리하는지 쉽게 식별할 수 있습니다. 예를 들어, 스레드 이름은 "Async-1", "Async-2"와 같이 지정됩니다.

setCorePoolSize, setMaxPoolSize, setQueueCapacity: 위에서 정의한 상수를 사용하여 스레드 풀의 크기와 큐 용량을 설정합니다.

setRejectedExecutionHandler: 큐가 가득 차고 MAX_POOL_SIZE마저 도달하여 더 이상 작업을 처리할 수 없을 때, 거부된 작업을 어떻게 처리할지 정의하는 핸들러를 설정합니다.

작업 거부 핸들러(Queue에 쌓인 요청 초과)

private RejectedExecutionHandler rejectedExecutionHandler() {
    return (runnable, executor) -> {
        throw CustomException.of(ErrorCode.REJECTED_QUEUE);
    };
}

RejectedExecutionHandler: ThreadPoolExecutor에 정의된 인터페이스로, 스레드 풀이 더 이상 작업을 받아들일 수 없을 때 호출

이 코드에서는 작업이 거부되면 CustomException을 발생시켜 예외 처리를 하도록 구현되어 있습니다. 이는 작업을 조용히 무시하는 대신, 시스템에 문제가 있음을 명확히 알리고 적절히 대응하도록 하는 안전한 방식입니다.

큐 용량 초과: 큐가 가득 차고(100개) 모든 코어 스레드(10개)가 바쁠 때, 스레드 풀은 MAX_POOL_SIZE 한도(30개) 내에서 새로운 스레드를 추가로 생성하여 작업을 처리합니다.

모든 자원 소진: 큐도 가득 차고(100개), 최대 스레드 수(30개)까지 도달하여 더 이상 작업을 처리할 수 없을 경우, RejectedExecutionHandler가 호출됩니다. 이 예제에서는 REJECTED_QUEUE 에러와 함께 CustomException이 발생하게 됩니다.

profile
일단 공부해보겠습니다..

0개의 댓글