🖥️ 컴퓨터 공부/주니어 백엔드 개발자가 반드시 알아야 할 실무 지식

ThreadPool(2)

le2donguk 2026. 1. 4. 01:02

들어가기..

이전에 ThreadPool을 선언하는 법 , Async와 Transaction , DB커넥션 풀과의 관계, 내부동작 등 여러 가지를 알아봤다 이제는 얼마 안남았다 2개 정도..?? 조금만 힘내서 끝까지 학습해 보자!!

 

RejectedExecutionHandler

이전에 ThreadPool을 설정할 때 우리가 한 가지 안 다룬 코드가 한 줄 있다 바로..!

executor.setRejectedExecutionHandler(
                new ThreadPoolExecutor.CallerRunsPolicy()
        );

 

여기서 RejectedExecutionHandler 란?? 

서버가 더 이상 못 받는 순간에 선택할 정책 을 결정한다

 

즉 서버가 더 이상 요청을 못받는 상황에서 선택할 정책을 결정한다 

 

정책의 종류와 장단점

종류

정책은 크게 4가지가 있다

  1. Abort Policy
  2. CallerRunsPolicy
  3. Discard Policy
  4. DiscardOldestPolicy

장단점

① AbortPolicy 

throw new RejectedExecutionException();

 

결과

  • 요청 즉시 실패
  • 예외 전파

언제 쓰나?

  • 테스트
  • 실패가 곧 오류여야 하는 내부 시스템

실서비스 거의 안 씀

 


 

② CallerRunsPolicy (⭐)

 

동작

작업을 "요청한 스레드"가 직접 실행

 

장점

  • 자연스러운 백프레셔
  • 서버가 스스로 속도 제한
  • 작업 유실 ❌

단점

  • 응답 시간 증가
  • 타임아웃 위험

언제 쓰나?

👉 비동기 작업 대부분


 

③ DiscardPolicy 

 

동작

아무 일도 안 하고 그냥 버림

 

특징

  • 예외 ❌
  • 로그 ❌

언제 쓰나?

  • 진짜 버려도 되는 작업
    • 통계 로그
    • 모니터링 이벤트

👉 핵심 비즈니스 로직에는 절대 금지


④ DiscardOldestPolicy

 

동작

큐에서 가장 오래된 작업 제거 → 새 작업 삽입

 

특징

  • 최신 작업 우선
  • 오래된 작업은 유실

언제 쓰나?

  • 실시간성 중요
    • 위치 업데이트
    • 상태 동기화

한눈에 보면..

정리)

Abort 즉시 실패 거의 ❌
CallerRuns 느려짐 ⭐⭐⭐
Discard 없음 극히 제한
DiscardOldest 없음 제한적

 

Usecase)

푸시 알림 CallerRuns
외부 API CallerRuns + Timeout
로그 수집 Discard
실시간 상태 DiscardOldest

 

 

 

궁금증 발생..!!

여기까지 공부하다가 하나의 궁금증이 발생했다.. CP 운용 방식과 비슷하게 callerRunspolicy 쓰면서 대기큐에서 기다리다가 Timeout만 잘 설정해주면 상관없는 거 아닌가..?

 

결론부터 말하면 

“CallerRunsPolicy + 큐 대기시간 설정”만으로는 해결 안 된다.
왜냐하면 큐 자체에는 ‘대기시간제한’이라는 개념이 없기 때문이다

 

ThreadPoolExecutor의 큐는 시간 개념이 없다

  • 큐는 단순히 들어온 순서대로 보관
  • “몇 초 이상 기다리면 버린다” 기능만 수행한다..

 

 

 

그럼 “대기시간제한”은 어떻게 구현하지?

실무에서 가장 많이 쓰이는 방식은..!! Semaphore + tryAcquire(timeout)이다

 

코드로 보자면..!!

if (!semaphore.tryAcquire(500, TimeUnit.MILLISECONDS)) {
    // 너무 밀렸음 → 포기
    return;
}
try {
    // 작업 실행
} finally {
    semaphore.release();
}

 

 

이제 진짜 다 학습했다 그러면 이제 까지 학습한 걸 토대로 전체 흐름을 생각해 보고 코드로 구현해보자!!

전체 흐름

Client
  ↓
OS (TCP accept queue)
  ↓
Tomcat Connector
  ↓
Tomcat Worker Thread (Thread Pool)
  ↓
Spring DispatcherServlet
  ↓
Controller → Service

 

주의할 것!!

Tomcat Thread Pool ≥ Async Thread Pool ≥ DB Connection Pool

 

마지막으로 코드로 한번 구현해 보고 글을 마무리해보쟈!

1️⃣ Async 스레드 풀 설정

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "asyncExecutor")
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");

        executor.setRejectedExecutionHandler(
                new ThreadPoolExecutor.CallerRunsPolicy()
        );

        executor.initialize();
        return executor;
    }
}

 

2️⃣ Semaphore Bean 정의

@Configuration
public class SemaphoreConfig {

    @Bean
    public Semaphore pushSemaphore() {
        return new Semaphore(5); // 동시에 5개만 실행
    }
}

 

3️⃣ @Async + tryAcquire 적용 

@Service
@RequiredArgsConstructor
public class PushService {

    private final Semaphore pushSemaphore;

    @Async("asyncExecutor")
    public void sendPush(Long userId) {

        boolean acquired = false;
        try {
            acquired = pushSemaphore.tryAcquire(300, TimeUnit.MILLISECONDS);

            if (!acquired) {
                // 너무 밀림 → 포기 or 로그
                log.warn("Push skipped due to high load. userId={}", userId);
                return;
            }

            // ===== 실제 비즈니스 로직 =====
            callExternalPushApi(userId);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (acquired) {
                pushSemaphore.release();
            }
        }
    }
}