스레드 풀과 Executor 프레임워크

스레드를 직접 사용할 때의 문제점

실무에서 스레드를 직접 생성해서 사용하면 다음과 같은 세 가지 문제가 발생한다

스레드 생성 비용으로 인한 성능 문제

스레드는 단순한 자바 객체가 아니다. 스레드를 생성하고 시작(start()) 하는 과정은 다음과 같은 이유로 매우 무겁다

메모리 할당

  • 각 스레드는 독립적인 호출 스택(call stack)을 가져야 한다
  • 스레드 하나당 보통 1MB 이상의 메모리를 사용한다
  • 호출 스택을 위한 메모리를 별도로 할당해야 한다

운영체제 자원

  • 스레드 생성은 운영체제 커널 수준에서 이루어진다
  • 시스템 콜(system call)을 통해 처리되며 CPU와 메모리 리소스를 소모한다

운영체제 스케줄러 관리

  • 새로운 스레드가 생성되면 OS 스케줄러가 이를 관리하고 실행 순서를 조정해야 한다
  • 스케줄링 알고리즘에 따라 추가적인 오버헤드가 발생한다

예를 들어, 작업 하나를 수행할 때마다 스레드를 생성한다면 가벼운 작업의 경우 작업 수행 시간보다 스레드 생성 시간이 더 올래 걸리는 배보다 배꼽이 큰 상황이 발생할 수 있다

해결 방법

  • 생성한 스레드를 재사용하면 처음 생성할 때만 비용이 발생하고, 이후에는 이미 만들어진 스레드를 재사용하므로 생성 시간을 절약할 수 있다

스레드 관리 문제

서버의 CPU, 메모리 자원은 한정되어 있기 때문에 스레드를 무한정 생성할 수 없다

실무 시나리오 예시

사용자 주문을 처리하는 서비스에서 선착순 할인 이벤트를 진행한다고 가정

  • 평소에는 동시에 100개의 스레드로 충분
  • 이벤트가 시작되자 갑자기 10,000개의 스레드가 필요한 상황 발생
  • CPU, 메모리 자원이 버티지 못하고 시스템이 다운되는 최악의 결과

필요한 관리 기능

  • 시스템이 버틸 수 있는 최대 스레드 수까지만 생성하도록 제한
  • 애플리케이션 종료 시 안전한 스레드 정리
    • 시행 중인 스레드의 작업을 모두 완료한 후 종료
    • 또는 인터럽트 신호를 주어 강제 종료

이러한 관리 기능을 구현하려면 스레드를 어딘가에서 체계적으로 관리해야 한다

Runnable 인터페이스의 불편함

public interface Runnable {
    void run();
}

Runnable 인터페이스는 다음과 같은 한계가 있다

반환 값이 없음

  • run() 메서드는 void 타입으로 반환 값이 없다
  • 스레드의 실행 결과를 얻으려면 별도의 메커니즘이 필요하다
  • 예: 멤버 변수에 결과를 저장하고, join()으로 대기한 후 멤버 변수에서 값을 가져와야 한다

예외 처리 제약

  • run() 메서드는 체크 예외(checked exception)를 던질 수 없다
  • 모든 체크 예외는 메서드 내부에서 처리해야 한다
  • 스레드에서 발생한 예외를 호출자가 받아서 처리하기 어렵다

해결책 – 스레드 풀(Thread Pool)

앞서 설명한 문제를 해결하기 위해서는 스레드를 생성하고 관리하는 풀(Pool)이 필요하다

스레드 풀의 동작 방식

  • 스레드 풀 생성: 필요한 만큼의 스레드를 미리 생성하여 풀에 대기시킨다
  • 작업 요청: 작업 요청이 들어오면 풀에서 대기 중인 스레드를 하나 꺼낸다
  • 작업 처리: 꺼낸 스레드로 작업을 처리한다
  • 스레드 반납: 작업이 완료되면 스레드를 종료하지 않고 풀에 다시 반납한다
  • 재사용: 반납된 스레드는 이후 다른 작업에 재사용된다

스레드 풀의 장점

  • 생성 시간 절약: 이미 만들어진 스레드를 재사용하므로 생성 비용이 들지 않는다
  • 체계적 관리: 스레드가 풀에서 관리되므로 필요한 만큼만 생성하고 관리할 수 있다
  • 자원 제어: 최대 스레드 수를 제한하여 시스템 자원을 보호할 수 있다

생산자-소비자 패턴과의 관계

  • 생산자: 작업을 요청하는 스레드 (예: main 스레드)
  • 소비자: 스레드 풀에 있는 스레드들
  • 버퍼: BlockingQueue를 사용하여 작업을 보관

직접 구현하려면 복잡하지만, 다행히 자바가 이 모든 것을 해결해주는 Executor 프레임워크를 제공한다

Executor 프레임워크 소개

Executor 프레임워크는 스레드 풀, 스레드 관리, Runnable의 문제점, 생산자-소비자 문제를 한 번에 해결해주는 자바 멀티스레드의 핵심 도구이다.

💡 실무에서는 스레드를 직접 생성하는 일이 드물고, 대부분 Executor 프레임워크를 사용한다

주요 구성 요소

Executor 인터페이스

package java.util.concurrent;

public interface Executor {
    void execute(Runnable command);
}

가장 단순한 작업 실행 인터페이스로, execute() 메서드 하나만 가지고 있다

ExecutorService 인터페이스

public interface ExecutorService extends Executor, AutoCloseable {
    <T> Future<T> submit(Callable<T> task);
    
    @Override
    default void close() {...}
    
    // 그 외 많은 메서드들...
}

Executor 인터페이스를 확장하여 작업 제출과 제어 기능을 추가로 제공한다

주요 메서드
  • submit(): Callable 작업을 제출하고 Future를 반환
  • close(): ExecutorService를 종료 (Java 19부터 지원, 이전 버전은 shutdown() 사용)

실무에서는 주로 이 인터페이스를 사용한다

ThreadPoolExecutor 구현체

ExecutorService의 기본 구현체로, 실제 스레드 풀 기능을 제공한다

ExecutorService 실전 사용하기

로그 출력 유틸리티 만들기

스레드 풀의 상태를 확인하기 위한 유틸리티 클래스를 먼저 작성한다

public abstract class ExecutorUtils {
    public static void printState(ExecutorService executorService) {
        if (executorService instanceof ThreadPoolExecutor poolExecutor) {
            int pool = poolExecutor.getPoolSize();
            int active = poolExecutor.getActiveCount();
            int queuedTasks = poolExecutor.getQueue().size();
            long completedTask = poolExecutor.getCompletedTaskCount();
            
            log("[pool=" + pool + ", active=" + active + 
                ", queuedTasks=" + queuedTasks + 
                ", completedTasks=" + completedTask + "]");
        } else {
            log(executorService);
        }
    }
}
상태 정보 설명
  • pool: 스레드 풀에서 관리되는 전체 스레드 수
  • active: 현재 작업을 수행하고 있는 스레드 수
  • queuedTasks: 큐에 대기 중인 작업 수
  • completedTasks: 완료된 작업 수

간단한 작업 클래스 만들기

public class RunnableTask implements Runnable {
    private final String name;
    private int sleepMs = 1000;

    public RunnableTask(String name) {
        this.name = name;
    }

    public RunnableTask(String name, int sleepMs) {
        this.name = name;
        this.sleepMs = sleepMs;
    }

    @Override
    public void run() {
        log(name + " 시작");
        sleep(sleepMs); // 작업 시간 시뮬레이션
        log(name + " 완료");
    }
}

1초간 대기하는 간단한 작업으로 실제 작업 시간을 시뮬레이션한다

ExecutorService 사용 예제

public class ExecutorBasicMain {
    public static void main(String[] args) {
        ExecutorService es = new ThreadPoolExecutor(
            2,  // corePoolSize
            2,  // maximumPoolSize
            0, TimeUnit.MILLISECONDS,
            new LinkedBlockingQueue<>()
        );
        
        log("== 초기 상태 ==");
        printState(es);
        // [pool=0, active=0, queuedTasks=0, completedTasks=0]

        es.execute(new RunnableTask("taskA"));
        es.execute(new RunnableTask("taskB"));
        es.execute(new RunnableTask("taskC"));
        es.execute(new RunnableTask("taskD"));
        
        log("== 작업 수행 중 ==");
        printState(es);
        // [pool=2, active=2, queuedTasks=2, completedTasks=0]

        sleep(3000); // 모든 작업이 완료될 때까지 대기
        
        log("== 작업 수행 완료 ==");
        printState(es);
        // [pool=2, active=0, queuedTasks=0, completedTasks=4]

        es.close();
        
        log("== shutdown 완료 ==");
        printState(es);
        // [pool=0, active=0, queuedTasks=0, completedTasks=4]
    }
}
ThreadPoolExecutor의 주요 구성
  • Thread Pool: 스레드를 관리
  • BlockingQueue: 작업을 보관 (생성자-소비자 문제 해결)
생성자 파라미터 설명
new ThreadPoolExecutor(
    2,  // corePoolSize: 기본 스레드 수
    2,  // maximumPoolSize: 최대 스레드 수  
    0, TimeUnit.MILLISECONDS,  // keepAliveTime: 초과 스레드의 생존 시간
    new LinkedBlockingQueue<>()  // workQueue: 작업 보관 큐
);
  • corePoolSize: 스레드 풀에서 관리되는 기본 스레드 수
  • maximumPoolSize: 스레드 풀에서 관리되는 최대 스레드 수
  • keepAliveTime, TimeUnit: 초과 스레드가 생존할 수 있는 대기 시간 (이 시간 동안 작업이 없으면 초과 스레드 제거)
  • BlockingQueue: 작업을 보관할 블로킹 큐

위 예제에서는 corePoolSize = 2, maximumPoolSize = 2로 설정하여 스레드를 2개로 고정. LinkedBlockingQueue는 작업을 무한대로 저장(메모리 제약은 있다)할 수 있는 큐이다

실행 과정 분석

초기 상태

[pool = 0, active = 0, queuedTasks = 0, completedTasks = 0]

ThreadPoolExecutor를 생성한 시점에는 스레드를 미리 만들어두지 않는다

작업 제출

es.execute(new RunnableTask("taskA"));
es.execute(new RunnableTask("taskB"));
es.execute(new RunnableTask("taskC"));
es.execute(new RunnableTask("taskD"));

중요: main 스레드는 작업을 전달하고 기다리지 않는다. 작업을 BlockingQueue에 넣고 바로 다음 코드를 실행한다

스레드 생성 시점
  • 최초 작업이 들어올 때 스레드를 생성한다
  • corePoolSize(2) 까지 스레드를 생성한다
  • taskA 요청 시 → 스레드 1 생성
  • taskB 요청 시 → 스레드 2 생성
  • taskC, taskD 요청 시 → 기존 스레드 재사용 (큐에 대기)

작업 수행 중

[pool=2, active=2, queuedTasks=2, completedTasks=0]
  • taskA, taskB 완료 후 스레드 1, 스레드 2가 풀에 반납
  • 반납된 스레드가 taskC, taskD를 처리
  • 모든 작업 완료 후 스레드들은 WAITING 상태로 풀에서 대기
중요 개념
  • 스레드가 “꺼내진다”는 것은 개념적인 표현이고, 실제로는 스레드의 상태가 WAITING → RUNNABLE로 변경되는 것이다. 따라서 pool=2는 계속 유지된다

shoudown 완료

[pool=0, active=0, queuedTasks=0, completedTasks=4]

close()를 호출하면 ThreadPoolExecutor가 종료되고, 스레드 풀의 모든 스레드가 제거된다

Java 버전별 참고사항

  • Java 19 이상: close() 사용
  • Java 19 미만: shutdown() 사용

Runnable의 불편함

앞서 설명한 Runnable의 문제점을 실제 코드로 확인해보자

Runnable로 값 반환받기

public class RunnableMain {
    public static void main(String[] args) throws InterruptedException {
        MyRunnable task = new MyRunnable();
        Thread thread = new Thread(task, "Thread-1");
        thread.start();
        thread.join();  // 작업 완료까지 대기
        
        int result = task.value;  // 멤버 변수에서 값 가져오기
        log("result value = " + result);
    }

    static class MyRunnable implements Runnable {
        int value;  // 결과를 저장할 멤버 변수

        @Override
        public void run() {
            log("Runnable 시작");
            sleep(2000);
            value = new Random().nextInt(10);  // 0~9 사이 난수
            log("create value = " + value);
            log("Runnable 완료");
        }
    }
}

실행 결과

14:22:06.675 [ Thread-1] Runnable 시작
14:22:08.685 [ Thread-1] create value = 1
14:22:08.686 [ Thread-1] Runnable 완료
14:22:08.686 [ main] result value = 1

문제점 분석

별도의 스레드에서 생성한 난수 하나를 받아오는 과정이 복잡하다

작업 스레드 (Thread-1)
  • 값을 어딘가에 보관해야 함 →멤버 변수 사용
요청 스레드 (main)
  • 작업이 끝날 때까지 join()으로 대기
  • 어딘가에 보관된 값을 찾아서 꺼내야 함

이상적으로 작업 스레드가 return을 통해 값을 반환하고, 요청 스레드가 그 값을 바로 받을 수 있다면 훨씬 간결할 것이다

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