스레드를 직접 사용할 때의 문제점
실무에서 스레드를 직접 생성해서 사용하면 다음과 같은 세 가지 문제가 발생한다
스레드 생성 비용으로 인한 성능 문제
스레드는 단순한 자바 객체가 아니다. 스레드를 생성하고 시작(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을 통해 값을 반환하고, 요청 스레드가 그 값을 바로 받을 수 있다면 훨씬 간결할 것이다