멀티 스레드 - 5. 스레드 풀 (Thread Pool)

revo·2026년 4월 21일

자바

목록 보기
30/30
post-thumbnail

왜 스레드 풀이 필요한가?

매번 new Thread()로 스레드를 생성하면 두 가지 문제가 있다.

문제 1: 스레드 생성/소멸 비용

요청 올 때마다:
new Thread() → OS에 스레드 생성 요청 → 작업 완료 → 스레드 소멸

스레드 생성/소멸은 OS 레벨 작업이라 비용이 크다.

문제 2: 스레드 수 제어 불가

요청 1 → new Thread()
요청 2 → new Thread()
...
요청 10000 → new Thread() → 메모리 고갈 → 서버 다운

요청이 폭발하면 스레드가 무한정 생성돼서 서버가 죽는다.

스레드 풀은 이 두 문제를 해결한다.

미리 스레드를 N개 만들어두고
작업이 오면 놀고 있는 스레드에 할당
작업 끝나면 스레드를 소멸시키지 않고 재사용
스레드가 다 바쁘면 작업을 큐에서 대기

ExecutorService

Java에서 스레드 풀을 다루는 인터페이스다. Executors 클래스의 팩토리 메서드로 생성한다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolTest {

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3); // 스레드 3개 고정

        for (int i = 0; i < 10; i++) {
            int taskNum = i;
            executor.submit(() -> {
                System.out.println("작업 " + taskNum + " 실행: " + Thread.currentThread().getName());
            });
        }

        executor.shutdown();
    }
}

실행 결과:

작업 1 실행: pool-1-thread-2
작업 2 실행: pool-1-thread-3
작업 0 실행: pool-1-thread-1
작업 3 실행: pool-1-thread-2
작업 4 실행: pool-1-thread-3
...

스레드 이름이 pool-1-thread-1, pool-1-thread-2, pool-1-thread-3 딱 3종류다.
10개의 작업을 3개의 스레드가 나눠서 재사용하며 처리했다.


Runnable vs Callable

submit()에 넘길 수 있는 작업 단위는 두 가지다.

RunnableCallable
반환값없음 (void)있음 (제네릭 타입)
예외checked 예외 선언 불가checked 예외 throws 가능
결과 수령불가Future로 수령
// Runnable — 반환값 없음
executor.submit(() -> {
    System.out.println("작업 실행");
    // return 없음
});

// Callable — 반환값 있음
Future<String> future = executor.submit(() -> {
    Thread.sleep(1000); // checked 예외를 throws 없이 사용 가능
    return "작업 결과"; // 반환값 있음
});

String result = future.get(); // 작업이 완료될 때까지 블로킹 대기
System.out.println(result);   // "작업 결과"

submit()의 시그니처:

Future<?> submit(Runnable task)         // Runnable — 반환값 없음
<T> Future<T> submit(Callable<T> task)  // Callable — 반환값 있음

Lambda를 넘길 때 컴파일러는 반환값 여부로 어떤 인터페이스인지 추론한다.

// 반환값 없음 → Runnable로 추론
executor.submit(() -> System.out.println("실행"));

// 반환값 있음 → Callable<String>으로 추론
executor.submit(() -> "결과값");

Future.get()

Future<Integer> future = executor.submit(() -> {
    Thread.sleep(2000);
    return 42;
});

// 다른 작업 수행 가능 (비동기)
System.out.println("다른 작업 중...");

Integer result = future.get(); // 2초 후 결과 반환 (블로킹)
System.out.println("결과: " + result); // 결과: 42

future.get()은 작업이 완료될 때까지 호출한 스레드를 블로킹한다.
타임아웃도 지정할 수 있다.

Integer result = future.get(3, TimeUnit.SECONDS); // 3초 안에 결과 없으면 TimeoutException

Executors 팩토리 메서드 — 스레드 수 상세

각 팩토리 메서드는 내부적으로 ThreadPoolExecutor를 다른 파라미터로 생성한다.

ThreadPoolExecutor의 핵심 파라미터:

corePoolSize  : 기본적으로 유지할 스레드 수 (작업이 없어도 유지)
maximumPoolSize : 최대로 생성할 수 있는 스레드 수
keepAliveTime : corePoolSize 초과 스레드가 idle 상태일 때 살아있는 시간

newFixedThreadPool(int nThreads)

ExecutorService executor = Executors.newFixedThreadPool(3);
초기 스레드 수  : 0
코어 스레드 수  : nThreads (3)
최대 스레드 수  : nThreads (3)
keepAliveTime : 0ms
  • 처음에 스레드가 없다가 작업이 제출될 때마다 스레드가 생성됨 (최대 nThreads개까지)
  • 스레드 수가 고정이라 작업이 많으면 큐(LinkedBlockingQueue)에서 무기한 대기
  • 스레드 수가 일정하므로 리소스 예측이 쉽다
  • 작업량이 예측 가능한 서버 환경에 적합
작업 1~3 → 스레드 3개 생성되어 처리
작업 4~10 → 큐에 쌓여서 스레드가 빌 때까지 대기

newCachedThreadPool()

ExecutorService executor = Executors.newCachedThreadPool();
초기 스레드 수  : 0
코어 스레드 수  : 0
최대 스레드 수  : Integer.MAX_VALUE (사실상 무제한)
keepAliveTime : 60초
  • 놀고 있는 스레드가 있으면 재사용, 없으면 즉시 새 스레드 생성
  • 60초 동안 사용하지 않은 스레드는 자동 소멸
  • 코어가 0이라 작업이 없으면 스레드가 모두 사라짐 → 메모리 절약
  • 최대 수가 무제한이라 갑작스런 트래픽 폭발 시 스레드가 폭증할 위험이 있음
  • 짧고 가벼운 비동기 작업이 많은 환경에 적합
작업 1 → 스레드 없음 → 새 스레드 생성
작업 2 → 스레드1이 바쁨 → 새 스레드 생성
작업 1 완료 → 스레드1 idle 상태 (60초 유지)
작업 3 → idle 스레드1 재사용
스레드1 60초 idle → 소멸

newSingleThreadExecutor()

ExecutorService executor = Executors.newSingleThreadExecutor();
초기 스레드 수  : 0
코어 스레드 수  : 1
최대 스레드 수  : 1
keepAliveTime : 0ms
  • 스레드 1개, 작업을 순서대로(FIFO) 처리
  • 작업 간 순서가 보장되어야 할 때 사용

스레드 풀 종료

스레드 풀을 종료하지 않으면 JVM이 종료되지 않는다. 스레드 풀 내부의 스레드들이 일반 스레드(비데몬)이기 때문이다.

shutdown()

executor.shutdown();
  • 새 작업 제출을 차단 (submit() 호출 시 RejectedExecutionException)
  • 이미 제출된 작업(실행 중 + 큐 대기 중)은 전부 완료한 후 종료
  • 부드러운 종료(graceful shutdown)
executor.shutdown();
try {
    // 최대 10초 기다림
    if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
        executor.shutdownNow(); // 10초 안에 안 끝나면 강제 종료
    }
} catch (InterruptedException e) {
    executor.shutdownNow();
}

shutdownNow()

List<Runnable> notExecuted = executor.shutdownNow();
  • 즉시 종료 시도
  • 큐에서 대기 중인 작업은 취소하고 미실행 작업 목록을 반환
  • 실행 중인 스레드에 interrupt()를 호출해서 강제로 깨움
  • 단, 스레드가 InterruptedException을 무시하면 바로 종료되지 않을 수 있음
// shutdownNow()가 interrupt()를 보내도 이 스레드는 무시함
executor.submit(() -> {
    while (true) {
        // InterruptedException 안 나는 루프 → interrupt 무시됨
    }
});
executor.shutdownNow(); // 종료 시도하지만 위 스레드는 계속 실행됨

isShutdown() / isTerminated()

executor.isShutdown();    // shutdown() 또는 shutdownNow() 호출 여부
executor.isTerminated();  // 모든 스레드가 실제로 종료됐는지 여부
shutdown() 호출 직후:
  isShutdown()   → true
  isTerminated() → false (아직 작업 처리 중)

모든 작업 완료 후:
  isShutdown()   → true
  isTerminated() → true

요약 비교

newFixedThreadPoolnewCachedThreadPoolnewSingleThreadExecutor
코어 수nThreads01
최대 수nThreadsInteger.MAX_VALUE1
초기 수000
idle 소멸없음60초 후없음
무제한(LinkedBlocking)없음(SynchronousQueue)무제한(LinkedBlocking)
적합한 상황작업량 예측 가능짧고 가벼운 작업 다수순서 보장 필요
위험성큐 무한 증가스레드 폭증처리량 제한

0개의 댓글