Executor 예외 정책

실무에서 스레드 풀이 포화 상태가 되면 어떻게 대응할지 정책을 정해야 한다. ThreadPoolExecutor는 4가지 기본 거절 정책을 제공하며, 커스텀 정책도 구현 가능하다

거절 정책이 필요한 이유

거절 상황

// 큐도 가득 참 + 최대 스레드 수 도달 = 거절
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    1,                      // core
    1,                      // max
    0, TimeUnit.SECONDS,
    new SynchronousQueue<>()  // 버퍼 크기 0
);

executor.submit(task1);  ✅ 처리
executor.submit(task2);  ❌ 거절 (RejectedExecutionException)

거절 발생 조건

  • 스레드 풀이 최대 크기에 도달
  • 작업 큐가 가득 참
  • 더 이상 작업을 받을 수 없음

대응 필요성

  • 개발자 관점: 로그 기록, 모니터링, 알람
  • 사용자 관점: 적절한 에러 메시지, 재시도 유도
  • 시스템 관점: 시스템 다운 방지

RejectedExecutionHandler 인터페이스

모든 거절 정책의 기반

public interface RejectedExecutionHandler {
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}

동작 방식

작업 거절 발생 → ThreadPoolExecutorrejectedExecution() 호출 → 설정된 정책에 따라 처리

AbortPolicy (기본 정책)

예외를 발생시켜 거절을 명시적으로 알림

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    1, 1, 0, TimeUnit.SECONDS,
    new SynchronousQueue<>(),
    new ThreadPoolExecutor.AbortPolicy()  // 기본값 (생략 가능)
);

executor.submit(new RunnableTask("task1"));

try {
    executor.submit(new RunnableTask("task2"));
} catch (RejectedExecutionException e) {
    log("요청 초과 -> " + e);
    // 포기, 재시도, 사용자 알림 등 처리
}

실행 결과

[pool-1-thread-1] task1 시작
[ main] 요청 초과
[ main] RejectedExecutionException: Task ... rejected from 
        ThreadPoolExecutor[Running, pool size = 1, active threads = 1, ...]

내부 구현

public static class AbortPolicy implements RejectedExecutionHandler {
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        throw new RejectedExecutionException(
            "Task " + r.toString() + " rejected from " + e.toString()
        );
    }
}

활용

try {
    executor.submit(task);
} catch (RejectedExecutionException e) {
    // 1. 사용자에게 알림
    return "서버 혼잡. 잠시 후 다시 시도해주세요.";
    
    // 2. 재시도 큐에 저장
    retryQueue.offer(task);
    
    // 3. 대체 서버로 전달
    backupExecutor.submit(task);
    
    // 4. 로그 및 모니터링
    log.error("Task rejected", e);
    metrics.incrementRejectionCount();
}

DiscardPolicy (조용히 버림)

예외 없이 조용히 작업을 버림

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    1, 1, 0, TimeUnit.SECONDS,
    new SynchronousQueue<>(),
    new ThreadPoolExecutor.DiscardPolicy()
);

executor.submit(new RunnableTask("task1"));
executor.submit(new RunnableTask("task2"));  // 조용히 버려짐
executor.submit(new RunnableTask("task3"));  // 조용히 버려짐

실행 결과

[pool-1-thread-1] task1 시작
[pool-1-thread-1] task1 완료
// task2, task3는 아무 흔적 없이 사라짐

내부 구현

public static class DiscardPolicy implements RejectedExecutionHandler {
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        // 비어 있음 - 아무것도 하지 않음
    }
}

사용 시나리오

// 실시간 로그 수집 (일부 손실 허용)
ExecutorService logCollector = new ThreadPoolExecutor(
    10, 10, 0L, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(1000),
    new ThreadPoolExecutor.DiscardPolicy()
);

// 실시간 모니터링 데이터 (최신 데이터만 중요)
ExecutorService metricsCollector = new ThreadPoolExecutor(
    5, 5, 0L, TimeUnit.SECONDS,
    new SynchronousQueue<>(),
    new ThreadPoolExecutor.DiscardPolicy()
);

주의 사항

  • 데이터 손실 가능
  • 거절 사실 인지 불가
  • 중요한 작업에는 부족함

CallerRunsPolicy (호출자가 실행)

거절 시 작업을 요청한 스레드가 직접 실행

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    1, 1, 0, TimeUnit.SECONDS,
    new SynchronousQueue<>(),
    new ThreadPoolExecutor.CallerRunsPolicy()
);

executor.submit(new RunnableTask("task1"));
executor.submit(new RunnableTask("task2"));  // main이 실행!
executor.submit(new RunnableTask("task3"));  // main이 실행!
executor.submit(new RunnableTask("task4"));

실행 결과

[pool-1-thread-1] task1 시작
[ main] task2 시작              // main 스레드가 실행!
[ main] task2 완료
[pool-1-thread-1] task1 완료
[ main] task3 시작              // main 스레드가 실행!
[ main] task3 완료
[pool-1-thread-1] task4 시작
[pool-1-thread-1] task4 완료

내부 구현

public static class CallerRunsPolicy implements RejectedExecutionHandler {
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        if (!e.isShutdown()) {
            r.run();  // 별도 스레드 생성 없이 직접 실행
        }
    }
}

핵심

r.run();  // 새 스레드 생성 X, 현재 스레드에서 실행
// vs
new Thread(r).start();  // 새 스레드 생성

생산 속도 조절 메커니즘

일반적인 흐름 (예외 발생)

main: task1 제출 (0초) → task2 제출 (0초) → task3 제출 (0초) → task4 제출 (0초)
총 소요 시간: 0초 (즉시 반환)

CallerRunsPolicy 적용

main: task1 제출 (0초)
    → task2 제출 (거절) 
    → main이 직접 실행 (1초 소요)
    → task3 제출 (0초)
    → task3 제출 (거절)
    → main이 직접 실행 (1초 소요)
    → task4 제출 (0초)

총 소요 시간: 2초 (생산 속도가 느려짐)

효과

  • 생산자(main) 속도 저하 → 작업 제출 속도 감소 → 소비자(스레드 풀)가 따라잡을 시간 확보 → 시스템 과부하 방지

실무 활용

// API 요청 처리 (백프레셔 적용)
ExecutorService apiExecutor = new ThreadPoolExecutor(
    50, 100, 60L, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(500),
    new ThreadPoolExecutor.CallerRunsPolicy()
);

HTTP 요청 스레드가 직접 처리 → 새 요청 받는 속도 자동 조절 → 시스템 안정성 향상

장점

  • 작업 손실 없음 (모든 작업 실행)
  • 자동 백프레셔 (back pressure)
  • 생산자 – 소비자 균형 유지

단점

  • 생산자 스레드 블로킹
  • 응답 시간 증가 가능

사용자 정의 정책

public class RejectMainV4 {
    public static void main(String[] args) {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
            1, 1, 0, TimeUnit.SECONDS,
            new SynchronousQueue<>(),
            new MyRejectedExecutionHandler()  // 커스텀 핸들러
        );

        executor.submit(new RunnableTask("task1"));
        executor.submit(new RunnableTask("task2"));
        executor.submit(new RunnableTask("task3"));

        executor.close();
    }

    static class MyRejectedExecutionHandler 
            implements RejectedExecutionHandler {
        
        static AtomicInteger count = new AtomicInteger(0);

        @Override
        public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
            int rejectedCount = count.incrementAndGet();
            log("[경고] 거절된 누적 작업 수: " + rejectedCount);
        }
    }
}

실행 결과

[pool-1-thread-1] task1 시작
[ main] [경고] 거절된 누적 작업 수: 1
[ main] [경고] 거절된 누적 작업 수: 2
[pool-1-thread-1] task1 완료

추신 – 과도한 최적화 지양

  • 초기 서비스: 고정 풀 전략으로 충분
  • 성장 단계: 모니터링 후 필요시 조정
  • 대규모: 사용자 정의 전략 + 세밀한 튜닝
  • 핵심: 현재 상황에 맞는 최적화

“가장 좋은 최적화는 최적화하지 않는 것이다. 현재 상황에 맞는 전략을 선택하고, 모니터링을 통해 필요할 때 개선하라”

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 1편, 멀티스레드와 동시성