WEB BE/JAVA

스레드 풀 생성 전략과 작업 거절 정책

조금씩 차근차근 2025. 3. 9. 22:30

스레드 풀은 시스템 자원을 효율적으로 사용하고, 안정적인 작업 처리를 위해 필수적인 요소다. 다양한 상황에 맞춰 적절한 풀 생성 전략과 작업 거절 정책을 설정하면, 트래픽 급증 시에도 시스템이 예측 가능한 성능을 유지할 수 있다. 아래는 대표적인 Executor 기반 스레드 풀 생성 전략과 거절 정책에 대한 정리다.

Executor 전략

Executor 스레드 풀 관리

  • 스레드 관리 속성
    • corePoolSize
      기본적으로 유지되는 스레드 수다. 이 수만큼의 스레드를 초기화해두고, 작업이 들어오면 먼저 활용한다.
    • maximumPoolSize
      생성될 수 있는 스레드의 최대치다. corePoolSize 이상의 스레드는 필요한 경우에만 만들고, 더 이상 새 스레드를 만들 수 없으면 작업을 거절한다.
    • keepAliveTime, timeUnit
      corePoolSize를 초과해 생성된 스레드(초과 스레드)가 대기할 수 있는 시간이다. 이 시간 동안 추가 작업이 없으면 초과 스레드를 제거한다.
    • BlockingQueue workQueue
      작업이 들어오면 우선적으로 스레드를 할당받아 실행하지만, 스레드가 모두 바쁘면 이 큐에 작업을 쌓아둔다.
  • 초과 스레드의 생성 시점
    더 이상 사용 가능한 스레드가 없고, 큐에도 작업이 들어갈 공간이 없을 때 초과 스레드가 생성된다. 단, 이는 maximumPoolSize에 도달하기 전까지 가능하다. "초과 스레드"란, 정말 어쩔 수 없이 생성되는 스레드를 의미한다.
  • 초과 스레드 생성이 불가능한 상황
    이미 maximumPoolSize에 도달했음에도 불구하고, 큐가 가득 차 있으면 더 이상 스레드를 생성하지 못하고 작업을 거절하게 된다.
  • 스레드를 미리 생성하는 경우
    서버 응답 시간이 중요한 상황에서는 스레드를 미리 생성해두고 싶을 수 있다. 이때는 ThreadPoolExecutorprestartAllCoreThreads() 메서드를 호출해 모든 corePoolSize 스레드를 미리 시작할 수 있다. 단, ExecutorService 인터페이스에는 해당 메서드가 없으므로, ThreadPoolExecutor로 다운캐스팅해야 한다.

Executor 전략 - 단일 풀 전략

newSingleThreadExecutor()

실제 생성되는 형태

  • 기본 스레드를 1개만 사용하는 풀이다.
  • 큐에는 제한이 없으며, 들어온 작업을 순차적으로 처리한다.
  • 주로 간단한 테스트나 순차적 처리가 필요한 경우에 사용된다.

Executor 전략 - 고정 풀 전략

newFixedThreadPool(nThreads)

실제 생성되는 형태

  • 일반적인 상황에서 많이 사용되며, 트래픽이 비교적 예측 가능한 경우에 적합하다.
  • 스레드 풀에 nThreads 개의 스레드를 고정적으로 생성한다.
  • 큐로는 무제한 크기의 LinkedBlockingQueue를 사용해, 작업이 들어오면 스레드가 사용 가능할 때까지 대기시킨다.
  • 스레드 수가 고정되어 있어 CPU 및 메모리 사용량을 예측 가능하게 유지하지만, 트래픽이 점차 증가하거나 실시간 이벤트로 급증하는 경우 병목이 발생할 수 있다.
    • CPU/메모리 리소스는 여유로운데, 사용자는 느려지는 문제가 발생하는 것이다.
    • 언젠가 처리되겠지만, 언제 처리될지는 모르는 불쾌한 UX를 사용자에게 경험시키게 된다. 

Executor 전략 - 캐시 풀 전략

newCachedThreadPool()

실제 생성되는 형태

  • 기본 스레드를 0개로 시작하며, 필요 시 즉시 스레드를 생성한다.
  • 큐로 SynchronousQueue를 사용하여 작업이 들어오면 즉시 처리할 수 있는 스레드를 할당받는다.
  • 트래픽이 증가하면 빠르게 스레드를 늘릴 수 있으며, 일정 시간(keepAliveTime) 동안 추가 작업이 없으면 생성된 스레드를 정리한다.
  • 모든 작업이 대기 없이 바로 실행되지만, 트래픽 폭주 시 메모리 사용량이 급증할 수 있는 위험이 있다.
    • CPU/메모리 리소스가 매우 부족해지고,
    • 병목 현상이 발생함으로써, 살짝의 변화에도 매우 민감하게 반응하게 된다.
    • 컨텍스트 스위칭 비용이 지나치게 높아지고
    • 메모리 제한을 넘어갈 경우, 서비스가 다운되는 문제가 발생할 수 있다.

Executor 전략 - 사용자 정의 풀 전략

직접 ThreadPoolExecutor 객체를 생성하여 설정한다.

ThreadPoolExecutor 를 사용자가 직접 정의하는 것.

  • 예를 들어, 1100개의 작업은 corePoolSize로 처리하고, 그 이후 100개의 작업은 초과 스레드를 생성하여 처리하도록 설정할 수 있다.
  • 큐의 자료구조와 RejectedExecutionHandler 구현 방식을 개발자가 직접 정의할 수 있어, 트래픽 변화에 유연하게 대응 가능하다.
  • 이는 트래픽이 점차 증가하거나 실시간 이벤트로 폭주하는 상황에서 유용하다.

Executor 예외 정책 - 작업 거절 정책

스레드 풀이 처리할 수 있는 한계를 넘어서는 작업이 들어오면, 아래와 같은 작업 거절 정책이 적용된다. 기본 정책 외에도 개발자가 원하는 방식으로 사용자 정의 거절 정책을 구현할 수 있다.

AbortPolicy

  • 정책 개요
    기본 설정 정책이며, 작업 거절 시 RejectedExecutionException을 발생시킨다.
  • 설정 방식

스레드 풀 생성 시 AbortPolicy() 를 적용한 모습.

  • 처리 방식

RejectedExecutionExecption 을 위와 같이 적절하게 처리할 수 있음을 보여주는 구현 예시

  • 클래스 설명
    AbortPolicyRejectedExecutionHandler 인터페이스를 구현한 대표적인 정책으로, 스레드 풀이 처리할 수 없는 작업이 들어올 경우 예외를 던진다.

실제 내부 구현의 동작 방식

DiscardPolicy

  • 정책 개요
    새로운 작업을 아무런 예외 없이 조용히 버린다.
  • 설정 방식

스레드 풀 생성 시 DiscardPolicy() 를 적용한 모습.

  • 처리 방식
    추가 조치 없이 작업을 무시한다.
  • 클래스 설명
    DiscardPolicy는 별도의 로직 없이, 작업을 버리는 방식으로 처리한다.

실제 내부 구현의 동작 방식

CallerRunsPolicy

  • 정책 개요
    작업을 넘기려던 생산자 스레드가 직접 해당 작업을 수행하게 한다.
  • 설정 방식

스레드 풀 생성 시 CallerRunsPolicy() 를 적용한 모습.

  • 처리 방식
    이 방식은 생산자 스레드가 작업을 처리함으로써, 전체적인 생산 속도를 늦추어 과부하를 방지하는 효과가 있다.

1초 걸리는 작업 때문에, main 스레드가 직접 작업을 시작한 모습

  • 클래스 설명
    CallerRunsPolicy는 내부적으로 run() 메서드를 직접 호출하는 방식으로 작업을 처리한다.

CallerRunsPolicy의 내부 구현 예시. 호출한 스레드가 Runnable 을 직접 실행하는 것을 볼 수 있다.

사용자 정의 (RejectedExecutionHandler)

  • 정책 개요
    개발자가 직접 원하는 방식으로 작업 거절 정책을 정의할 수 있다.
  • 설정 방식

커스텀 작업 거절 정책을 주입해주는 예시.

  • 사용자 정의 구현
    개발자가 필요에 따라 로그 기록, 모니터링, 특정 알림 전송 등의 추가 로직을 포함할 수 있다.

커스텀 작업 거절 정책을 구현한 예시. RejectedExecutionHandler 인터페이스를 구현한다.

  • 실제 출력된 결과값

위에 작성한 코드대로, 정상적으로 작업이 처리되는 것을 볼 수 있다.

스레드 풀 구성과 작업 거절 정책 설정은 애플리케이션의 성능과 안정성에 큰 영향을 미친다. 트래픽 상황과 시스템 자원 특성을 면밀히 분석해, 필요 이상으로 스레드를 남발하지 않으면서도 적절한 처리량을 유지할 수 있는 구성을 선택해야 한다. 이러한 결정이 안정적인 시스템 운영의 핵심이 된다.

 

출처: 김영한 Java 고급 - 멀티쓰레드와 동시성