한 줄 정의

스레드를 미리 생성해두고 작업 큐에서 태스크를 꺼내 실행하는 동시성 관리 패턴. 스레드 생성/소멸 비용을 제거하고, 동시에 실행되는 스레드 수를 제한하여 시스템 자원을 보호한다.

쉽게 말하면

콜센터의 상담원과 같습니다. 전화(요청)가 올 때마다 상담원(스레드)을 채용하고 해고하는 대신, 미리 고용해두고 재사용합니다. 상담원이 전부 통화 중이면 대기열(큐)에서 기다리고, 대기열마저 가득 차면 “나중에 다시 걸어주세요”(거부 정책)가 됩니다.

왜 이걸 알아야 하는가?

  • 서버 성능 튜닝의 핵심: Tomcat의 maxThreads, HikariCP의 maximumPoolSize, @Async의 스레드 풀 — 서버 애플리케이션의 동시성은 대부분 스레드 풀 설정으로 결정됩니다. 이 값을 근거 없이 설정하면 풀 고갈(요청 대기)이나 자원 낭비(불필요한 컨텍스트 스위칭)가 발생합니다.
  • 장애 분석의 기본: “응답이 갑자기 느려졌다”는 장애의 상당수가 스레드 풀 고갈입니다. thread dump에서 풀의 모든 스레드가 WAITING/BLOCKED 상태라면 풀 사이즈가 아니라 블로킹 지점이 문제라는 것을 판단할 수 있어야 합니다.
  • Virtual Thread 시대에도 여전히 필요: Java Virtual Thread가 등장했지만, 커넥션 풀(DB 연결 수 제한)이나 외부 API rate limit처럼 하위 자원에 상한이 있는 경우에는 여전히 동시성 제어가 필요합니다. “스레드를 무한히 만들 수 있다”는 것이 “무한히 만들어야 한다”는 뜻은 아닙니다.

왜 이렇게 설계했는가?

  • 해결하는 문제: 요청마다 스레드를 생성하면 발생하는 두 가지 비용을 제거합니다. 첫째, 스레드 생성/소멸 자체의 오버헤드(커널 오브젝트 할당, 스택 메모리 할당)이고, 둘째, 스레드 수가 무한히 늘어나면서 발생하는 메모리 고갈과 스케줄러 부하입니다.
  • 이게 없다면: 요청마다 new Thread()를 호출하는 방식이 됩니다. 트래픽이 급증하면 스레드가 수만 개 생성되고, 스레드당 1MB 스택 × 10,000개 = 10GB로 OOM이 발생합니다. 스레드 수가 많아지면 OS 스케줄러의 컨텍스트 스위칭 비용도 급격히 증가하여 실제 작업보다 스위칭에 CPU를 더 쓰게 됩니다.
  • 핵심 설계 원칙: 생성 비용 상각(amortize) — 스레드를 재사용하여 생성/소멸 비용을 요청 수만큼 나눕니다. 자원 상한(bounded) — 최대 스레드 수를 제한하여 시스템이 감당할 수 있는 범위 안에서 동작하도록 보장합니다.

어떻게 동작하는가?

기본 구조

스레드 풀은 세 가지 핵심 요소로 구성됩니다:

  1. Worker Thread: 미리 생성된 스레드들로, 작업이 없으면 큐에서 대기합니다.
  2. Task Queue (작업 큐): 실행할 작업(Runnable/Callable)이 대기하는 큐입니다. Worker가 모두 바쁘면 여기에 쌓입니다.
  3. 관리 정책: 스레드 수 조절, 큐가 가득 찼을 때의 거부 정책 등을 결정합니다.

Java의 ThreadPoolExecutor

Java에서 스레드 풀의 핵심 구현체는 ThreadPoolExecutor이며, 모든 Executors 팩토리 메서드가 내부적으로 이것을 사용합니다.

new ThreadPoolExecutor(
    corePoolSize,     // 기본 유지 스레드 수
    maximumPoolSize,  // 최대 스레드 수
    keepAliveTime,    // 초과 스레드의 유휴 생존 시간
    unit,             // keepAliveTime 단위
    workQueue,        // 작업 대기 큐
    threadFactory,    // 스레드 생성 방식
    handler           // 거부 정책
);

작업 제출 시 동작 흐름:

  1. 현재 스레드 수 < corePoolSize이면 새 스레드를 생성하여 작업을 실행합니다.
  2. 현재 스레드 수 ≥ corePoolSize이면 작업을 workQueue에 넣습니다.
  3. workQueue가 가득 찼고 현재 스레드 수 < maximumPoolSize이면 새 스레드를 생성합니다.
  4. workQueue가 가득 찼고 현재 스레드 수 = maximumPoolSize이면 RejectedExecutionHandler가 호출됩니다.

거부 정책 (RejectedExecutionHandler):

정책동작용도
AbortPolicy (기본)RejectedExecutionException 발생명시적 에러 처리
CallerRunsPolicy제출한 스레드가 직접 실행배압(backpressure) 효과
DiscardPolicy작업을 조용히 버림유실 허용 작업
DiscardOldestPolicy큐의 가장 오래된 작업을 버리고 새 작업 삽입최신 데이터 우선

Executors 팩토리 메서드

팩토리 메서드coremax특성
newFixedThreadPool(n)nnLinkedBlockingQueue (무한)스레드 수 고정, 큐가 무한히 쌓일 수 있음
newCachedThreadPool()0Integer.MAX_VALUESynchronousQueue유휴 60초 후 제거, 스레드 수 제한 없음
newSingleThreadExecutor()11LinkedBlockingQueue (무한)순서 보장, 단일 스레드
newScheduledThreadPool(n)nInteger.MAX_VALUEDelayedWorkQueue지연/주기 실행

주의: newFixedThreadPoolnewCachedThreadPool은 각각 큐 무한 적재와 스레드 무한 생성의 위험이 있으므로, 운영 환경에서는 ThreadPoolExecutor를 직접 생성하여 큐 크기와 최대 스레드 수를 명시적으로 설정하는 것이 안전합니다.

스레드 풀 사이징 (Sizing)

스레드 풀의 최적 크기는 워크로드 특성에 따라 완전히 달라집니다.

CPU-bound 작업: 스레드가 대부분의 시간을 연산에 사용하므로, 코어 수보다 많은 스레드는 컨텍스트 스위칭만 유발합니다. 최적 스레드 수 ≈ CPU 코어 수 (또는 코어 수 + 1, 페이지 폴트 등 우발적 블로킹 대비).

I/O-bound 작업: 스레드가 대부분의 시간을 I/O 대기에 쓰므로, 대기 시간 동안 다른 스레드가 CPU를 사용할 수 있습니다. 최적 스레드 수 ≈ 코어 수 × (1 + 대기 시간 / 연산 시간).

Little’s Law를 활용한 사이징: 시스템이 안정 상태일 때 동시 요청 수(L) = 처리량(λ) × 평균 응답 시간(W)이 성립합니다. 목표 처리량이 초당 1,000건이고 평균 응답 시간이 200ms라면, 필요한 동시 스레드 수 = 1,000 × 0.2 = 200개입니다. 다만 이 공식은 이상적인 조건을 가정하므로, 실제로는 부하 테스트를 통해 검증하고 여유를 두는 것이 필수입니다.

사이징에서 흔한 실수:

  • “스레드를 많이 만들면 빨라진다”는 착각입니다. CPU-bound 작업에서 코어 수의 10배 스레드를 만들면 오히려 컨텍스트 스위칭으로 성능이 떨어집니다.
  • DB 커넥션 풀 크기를 무시하고 스레드 풀만 늘리면, 스레드 200개가 커넥션 10개를 놓고 경쟁하며 대부분의 시간을 lock 대기에 씁니다.

시각화

스레드 풀 동작 구조

graph LR
    subgraph Clients["요청"]
        R1["Task 1"]
        R2["Task 2"]
        R3["Task 3"]
        R4["Task 4"]
    end
    subgraph Queue["작업 큐"]
        Q["Task 3 → Task 4"]
    end
    subgraph Pool["스레드 풀 (max=2)"]
        T1["Worker 1: Task 1 실행 중"]
        T2["Worker 2: Task 2 실행 중"]
    end

    R1 --> T1
    R2 --> T2
    R3 --> Q
    R4 --> Q
    Q --> T1
    Q --> T2

ThreadPoolExecutor 작업 제출 흐름

flowchart TD
    Submit["작업 제출"] --> CheckCore{"스레드 수 < corePoolSize?"}
    CheckCore -->|Yes| NewCore["새 스레드 생성 → 실행"]
    CheckCore -->|No| CheckQueue{"큐에 공간 있음?"}
    CheckQueue -->|Yes| Enqueue["큐에 작업 추가"]
    CheckQueue -->|No| CheckMax{"스레드 수 < maximumPoolSize?"}
    CheckMax -->|Yes| NewMax["새 스레드 생성 → 실행"]
    CheckMax -->|No| Reject["거부 정책 실행"]

정리

기준FixedThreadPoolCachedThreadPoolVirtual Thread Executor
스레드 수고정무제한 (자동 확장/축소)작업당 1개 (자동 생성)
무한 LinkedBlockingQueueSynchronousQueue (큐 없음)없음 (즉시 실행)
위험큐 무한 적재 → 메모리스레드 무한 생성 → OOM하위 자원 고갈 (DB 커넥션 등)
적합한 상황예측 가능한 부하짧은 비동기 작업I/O-bound 대량 동시성
스레드 비용높음 (1MB/스레드)높음 (1MB/스레드)낮음 (수 KB/스레드)

실무에서 만난 사례

  • Tomcat 스레드 풀: 기본 maxThreads=200으로, 요청마다 풀에서 스레드 하나를 꺼내 할당하는 thread-per-request 모델입니다. DB 쿼리가 느려져서 스레드 200개가 전부 JDBC 응답 대기 중이면 201번째 요청부터 큐에 쌓이고, 이것이 “스레드 풀 고갈” 장애의 전형적인 패턴입니다.
  • HikariCP 커넥션 풀과의 관계: Tomcat 스레드 200개인데 HikariCP maximumPoolSize=10이면, 동시에 DB를 쓸 수 있는 스레드는 10개뿐이고 나머지 190개는 커넥션 획득 대기에 묶입니다. 스레드 풀과 커넥션 풀의 크기는 함께 조율해야 합니다.
  • Spring @Async의 기본 풀: @Async의 기본 executor는 SimpleAsyncTaskExecutor로, 호출마다 새 스레드를 생성하여 풀이 아닙니다. 운영 환경에서는 반드시 ThreadPoolTaskExecutor를 빈으로 등록하고 풀 크기를 설정해야 합니다.

관련 개념

출처

  • Brian Goetz, “Java Concurrency in Practice” Chapter 8: Applying Thread Pools
  • Doug Lea, “A Java Fork/Join Framework”
  • HikariCP Wiki: About Pool Sizing