Executor 스레드 풀 전략

실무에서는 트래픽 패턴에 맞는 적절한 스레드 풀 전략을 선택해야 한다. Java는 Executors를 통해 세 가지 기본 전략을 제공하며, 각각 장단점이 명확하다

기본 전략 개요

단일 스레드 풀 (Single Thread Pool)

ExecutorService es = Executors.newSingleThreadPool();

// 내부 구현
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
    new LinkedBlockingQueue<Runnable>())

특징

  • 스레드 1개만 사용
  • 큐 사이즈 무제한 (LinkedBlockingQueue)
  • 간단한 테스트나 순차 처리용

고정 풀 전략 (Fixed Thread Pool)

ExecutorService es = Executors.newFixedThreadPool(2);

// 내부 구현
new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,
    new LinkedBlockingQueue<Runnable>())

핵심 특징

  • corePoolSize = maximumPoolSize (초과 스레드 없음)
  • 큐 사이즈 무제한
  • 스레드 수 고정 → CPU/메모리 사용량 예측 가능

동작 방식

ExecutorService es = Executors.newFixedThreadPool(2);

for (int i = 1; i <= 6; i++) {
    String taskName = "task" + i;
    es.execute(new RunnableTask(taskName));
    printState(es, taskName);
}

실행 흐름

task1 → [pool=1, active=1, queuedTasks=0] // 스레드1 생성
task2 → [pool=2, active=2, queuedTasks=0] // 스레드2 생성 (고정 수 도달)
task3 → [pool=2, active=2, queuedTasks=1] // 큐에 저장
task4 → [pool=2, active=2, queuedTasks=2] // 큐에 저장
task5 → [pool=2, active=2, queuedTasks=3] // 큐에 저장
task6 → [pool=2, active=2, queuedTasks=4] // 큐에 저장

장점 – 안정성

  • 리소스 사용량 예측 가능
  • 시스템 과부하 방지
  • 큐 사이즈 무제한으로 모든 요청 수용

단점과 위험 시나리오

상황 1 – 점진적 상용자 증가
  • 사용자 증가 → 큐에 작업 누적 → 응답 시간 증가
  • CPU/메모리는 여유 있지만 상용자는 느린 응답 경험
상황 2 – 급격한 트래픽 증가
  • 이벤트 성공 → 요청 폭증 → 큐에 수만 건 적체
  • 처리 시간: 큐에 10,000건 / 10개 스레드 * 1초 = 1,000초 대기
  • 마지막 사용자는 16분 이상 대기하는 상황 발생
문제의 핵심
  • 큐에 작업이 쌓이는 속도 > 처리 속도
  • 결과: 서버는 안정적이지만 사용자 경험 약화

캐시 풀 전략 (Cached Thread Pool)

ExecutorService es = Executors.newCachedThreadPool();

// 내부 구현
new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
    new SynchronousQueue<Runnable>())

핵심 특징

  • corePoolSize = 0 (기본 스레드 없음)
  • maximumPoolSize = Integer.MAX_VALUE (초과 스레드 무제한)
  • SynchronousQueue 사용 (버퍼 크기 0)

SynchronousQueue의 특별한 메커니즘

일반 큐 vs SynchronousQueue
  • 일반 큐: 생산자 → [버퍼에 저장] → 소비자가 꺼냄
  • SynchronousQueue: 생산자 → (대기) → 소비자 직접 전달 (직거래)
    • 버퍼 크기 = 0, 생산자-소비자 동기화

동작 원리

// 생산자(메인)가 작업 제출
es.execute(task1);
// → 큐에 넣을 수 없음 (크기 0)
// → 즉시 새 스레드 생성
// → 스레드가 task1 직접 받아서 실행

실행 예제

ThreadPoolExecutor es = new ThreadPoolExecutor(0, Integer.MAX_VALUE,
    3, TimeUnit.SECONDS, new SynchronousQueue<>());

for (int i = 1; i <= 4; i++) {
    es.execute(new RunnableTask("task" + i));
    printState(es, "task" + i);
}

실행 결과

task1 → [pool=1, active=1, queuedTasks=0] // 스레드1 생성
task2 → [pool=2, active=2, queuedTasks=0] // 스레드2 생성
task3 → [pool=3, active=3, queuedTasks=0] // 스레드3 생성
task4 → [pool=4, active=4, queuedTasks=0] // 스레드4 생성

// 3초 후 (keepAliveTime 초과)
[pool=0, active=0, queuedTasks=0, completedTasks=4]
// 모든 초과 스레드 제거

스레드 생성 메커니즘

왜 기본 스레드 없이 초과 스레드만 생성되나?
  • 작업 요청 → corePoolSize(0)까지 스레드 생성 → 0개이므로 스킵
  • core 초과 → 큐에 저장 시도 → SynchronousQueue는 버퍼 크기 0 → 큐에 넣기 실패
  • 큐 초과 → maximumPoolSize까지 초과 스레드 생성 → Integer.MAX_VALUE이므로 무제한 생성 가능
  • 결과: 모든 작업이 즉시 새 스레드 생성하여 처리

장점 – 빠른 응답

  • 큐 대기 없음 → 즉시 처리
  • 트래픽 변화에 유연하게 대응
  • CPU/메모리 최대 활용
  • 자동 확장 / 축소
    • 요청 증가 → 스레드 증가 (최대 활용)
    • 요청 감소 → 60초 후 스레드 제거 (자원 절약)

위험 시나리오

상황 1 – 점진적 증가
  • 사용자 증가 → 스레드 증가 → CPU/메모리 사용량 증가
  • 모니터링으로 감지 → 시스템 증설 (정상 대응)
상황 2 – 급격한 폭증 (치명적)
  • 이벤트 → 요청 폭증 → 스레드 수천 개 생성 → CPU 100%, 메모리 고갈 → 컨텍스트 스위칭 오버헤드 증가 → 시스템 다운
  • 메모리 계산
    • 스레드 1개 = 최소 1MB 메모리
    • 1,000개 스레드 = 1GB 이상 메모리 → 힙 메모리 압박 + GC 부하 증가

사용자 풀 전략

전략 설계 – 3단계 대응 시스템

  • 일반 상황: 고정 스레드로 안정적 운영
  • 긴급 상황: 초과 스레드 투입으로 빠른 처리
  • 거절 상황: 요청 거절로 시스템 보호
ExecutorService es = new ThreadPoolExecutor(
    100,                        // corePoolSize: 기본 100개
    200,                        // maximumPoolSize: 최대 200개
    60, TimeUnit.SECONDS,       // keepAliveTime: 초과 스레드 60초 생존
    new ArrayBlockingQueue<>(1000)  // 큐 크기 1,000개 (중요!)
);

파라미터 의미

  • 기본 스레드: 100개 (일반 상황 처리)
  • 초과 스레드: 100개 (긴급 상황 추가 투입)
  • 큐 크기: 1,000개 (버퍼)

처리 가능 작업

  • 최소: 100개 (기본 스레드만)
  • 일반: 1,100개 (기본 100 + 큐 1,000)
  • 긴급: 1,200개 (기본 100 + 큐 1,000 + 초과 100)
  • 초과: 거절 (RejectedExecutionException)

시나리오별 동작

일반 상황 (1,000개 이하)
static final int TASK_SIZE = 1100;

// 실행 결과
[pool=100, active=100, queuedTasks=1000, completedTasks=0]
time: 11초 (1,100 ÷ 100 스레드)
동작
  • 처음 100개 작업 → 스레드 100개 생성
  • 다음 1,000개 작업 → 큐에 저장
  • 스레드 100개 안정적으로 처리
긴급 상황 (1,101 ~ 1,200개)
static final int TASK_SIZE = 1200;

// 실행 결과
[pool=200, active=200, queuedTasks=1000, completedTasks=0]
time: 6초 (1,200 ÷ 200 스레드)
동작
  • 기본 스레드 100개 생성 → 작업 실행
  • 큐 1,000개 가득 참
  • 1,101째 작업 도착 → 큐 가득 참 = 긴급 상황 → 초과 스레드 생성 시작
  • 최종 200개 스레드로 처리 (2배 빠름)
task1100 → [pool=100, active=100, queuedTasks=1000]
task1101 → [pool=101, active=101, queuedTasks=1000] // 초과 스레드 시작!
task1102 → [pool=102, active=102, queuedTasks=1000]

task1200 → [pool=200, active=200, queuedTasks=1000]

거절 상황 (1,201개 이상)

static final int TASK_SIZE = 1201;

// 실행 결과
task1200 → [pool=200, active=200, queuedTasks=1000]
task1201 → RejectedExecutionException
거절 이유
  • 처리 중: 200개 (최대 스레드)
  • 큐 대기: 1,000개 (최대 큐)
  • 총 처리 기능: 1,200개 → 1,201번째부터 거절

실무 팁

성능 테스트로 값 결정
// CPU 사용량 목표 설정
일반: CPU 50% → corePoolSize = 100
긴급: CPU 80% → maximumPoolSize = 200

// 부하 테스트
- 평상시 트래픽: 100 TPS → core = 100
- 이벤트 피크: 200 TPS → max = 200
- 큐 크기: 평균 응답시간 × TPS = 큐 크기
거절 처리
try {
    es.execute(task);
} catch (RejectedExecutionException e) {
    // 사용자에게 알림
    return "서버가 혼잡합니다. 잠시 후 다시 시도해주세요.";
}

실무에서 자주 하는 실수

치명적 실수 – 무제한 큐 + 최대 스레드 설정

// 잘못된 설정
ExecutorService es = new ThreadPoolExecutor(
    100, 
    200,  // 절대 도달하지 않음!
    60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>()  // 무제한 큐
);

문제점 – 작업 요청 흐름

  • 100개까지 → 스레드 생성
  • 101개부터 → 큐에 무제한 저장
  • 큐가 절대 가득 차지 않음
  • maximumPoolSize 200은 의미 없음
  • 결과: 100개 스레드로만 모든 작업 처리

왜 초과 스레드가 생성 안 되나?

초과 스레드 생성 조건

  • 큐가 가득 참 → LinkedBlockingQueue() → 무제한 큐 → 큐가 절대 가득 차지 않음 → 초과 스레드 생성 조건 충족 불가

올바른 설정

// 올바른 설정
new ArrayBlockingQueue<>(1000)  // 명시적 크기 제한

전략 비교 및 선택 가이드

전략corePoolSizemaximumPoolSize적합한 상황
고정 풀NN무제한안정적, 예측 가능한 트래픽
캐시 풀0무제한0짧고 가벼운 작업, 변동 큰 트래픽
사용자 정의NM (>N)제한실무 대부분 (권장)

선택 기준

// 백그라운드 배치: 고정 풀
Executors.newFixedThreadPool(10);

// API 서버 (트래픽 변동): 사용자 정의
new ThreadPoolExecutor(100, 200, 60, SECONDS, 
    new ArrayBlockingQueue<>(1000));

// 짧은 비동기 작업: 캐시 풀 (주의)
Executors.newCachedThreadPool();

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