본문 바로가기
언어/Java

[Java] ThreadPool 비교: 어떤 쓰레드 풀이 가장 좋을까?

by Geunny 2025. 2. 4.
반응형

멀티스레딩을 다루다 보면, 쓰레드 관리를 어떻게 해야 할까? 라는 고민이 생깁니다.

쓰레드를 마구 생성하면 메모리가 터지고(OutOfMemoryError), 그렇다고 하나씩 돌리면 너무 느리죠.

 

그래서 Java에서는 ThreadPool(쓰레드 풀) 을 제공합니다.

그런데 이게 또 종류가 여러 가지라, “FixedThreadPool이 좋을까? CachedThreadPool이 좋을까?” 같은 고민이 생깁니다.

 

그래서 이번 글에서는 ThreadPool을 안 썼을 때와 썼을 때의 성능 차이를 직접 실험해보고, 어떤 경우에 어떤 쓰레드 풀을 써야 할지 정리해보겠습니다. 🚀

 

Thread 야 정신차려....

 

1. 실험 환경

10,000개의 작업을 처리해야 한다고 가정

각 작업은 단순히 숫자의 제곱을 계산하는 간단한 연산

여러 가지 ThreadPool을 사용해서 성능 비교

 

 

2. 테스트할 코드

 

숫자의 제곱을 계산하는 SquareNumber 클래스

public class SquareNumber implements Runnable {
    private final int number;

    private SquareNumber(int number) {
        this.number = number;
    }

    public static SquareNumber create(int number) {
        return new SquareNumber(number);
    }

    @Override
    public void run() {
        System.out.println("Square of " + number + " = " + (number * number));
    }
}

 

각 작업은 숫자의 제곱을 계산하는 간단한 연산을 수행합니다.

이제 이 작업을 여러 방식으로 실행해볼 겁니다.

 

3. 테스트 케이스별 성능 비교

 

① ThreadPool 없이 실행 (쓰레드 10,000개 생성)

 

@Test
@DisplayName("ThreadPool 미사용 테스트")
public void withoutThreadPool() throws InterruptedException {
    List<Thread> threads = new ArrayList<>();
    for (int number = 1; number <= 10_000; number++) {
        Thread thread = new Thread(SquareNumber.create(number));
        threads.add(thread);
        thread.start();
    }

    for (Thread thread : threads) {
        thread.join();
    }
}

 

🔹 쓰레드 생성 방식:

new Thread().start()를 10,000번 호출

즉, 10,000개의 개별적인 스레드가 생성

 

💡 문제점:

메모리 부족 위험 (OOM 발생 가능)

스레드 생성 비용이 너무 큼

CPU 문맥 전환 비용이 증가

 

🕐 평균 실행 시간: 1.4초

 

② Fixed ThreadPool (고정 크기 쓰레드 풀)

 

@Test
@DisplayName("Fixed ThreadPool 사용 테스트")
public void withThreadPool() throws InterruptedException {
    ExecutorService executor = Executors.newFixedThreadPool(10);

    for (int number = 1; number <= 10_000; number++) {
        executor.submit(SquareNumber.create(number));
    }

    executor.shutdown();
    executor.awaitTermination(1, TimeUnit.MINUTES);
}

 

🔹 쓰레드 생성 방식:

최대 10개의 스레드를 미리 생성

10개 이상의 작업이 들어오면 대기 큐(Queue)에 저장하고 기존 스레드가 완료되면 순차적으로 실행

 

💡 장점:

고정된 개수의 스레드만 유지 → 안정적

CPU & 메모리 사용량을 예측 가능

 

🕐 평균 실행 시간: ~400ms

 

③ newSingleThreadExecutor (싱글 스레드)

 

@Test
@DisplayName("newSingleThreadExecutor 사용 테스트")
public void withNewSingleThreadExecutor() throws InterruptedException {
    ExecutorService executor = Executors.newSingleThreadExecutor();

    for (int number = 1; number <= 10_000; number++) {
        executor.submit(SquareNumber.create(number));
    }

    executor.shutdown();
    executor.awaitTermination(1, TimeUnit.MINUTES);
}

 

🔹 쓰레드 생성 방식:

오직 1개의 스레드만 생성

모든 작업이 하나의 스레드에서 순차적으로 실행됨

 

💡 특징:

작업이 순차적으로 실행되므로 동기적인 처리 방식과 유사

스레드 생성/제거 비용이 거의 없음 → Context Switching 비용 최소화

하지만 병렬 처리 불가능 (속도가 중요할 경우 비효율적)

 

🕐 평균 실행 시간: ~58ms

 

👉 그런데 FixedThreadPool(10)보다 빠른 이유는?

싱글 스레드라서 문맥 전환(Context Switching) 비용이 적기 때문!

 

④ Cached ThreadPool (무제한 스레드)

 

@Test
@DisplayName("Cached ThreadPool 사용 테스트")
public void withCachedThreadPool() throws InterruptedException {
    ExecutorService executor = Executors.newCachedThreadPool();

    for (int number = 1; number <= 10_000; number++) {
        executor.submit(SquareNumber.create(number));
    }

    executor.shutdown();
    executor.awaitTermination(1, TimeUnit.MINUTES);
}

 

🔹 쓰레드 생성 방식:

필요할 때마다 새로운 스레드를 생성

사용되지 않은 스레드는 60초 후 자동 제거

최대 스레드 개수 제한이 없음 → 많을수록 무제한으로 스레드가 증가

 

💡 문제점:

초기에는 빠르게 실행되지만, 너무 많은 스레드가 생성되어 메모리 부족(OOM) 발생 가능

작업이 많을 경우 CPU와 메모리를 과도하게 사용

실제 테스트에서는 실행되지 않고 OOM 발생 🚨

 

🕐 결과: 실행 불가 (OOM 발생) 😵

 

이유: 10,000개의 작업을 처리하는 동안 스레드를 무제한으로 생성하려고 시도 → 메모리 부족으로 충돌

 

⑤ ThreadPoolExecutor (커스텀 설정)

@Test
@DisplayName("ThreadPoolExecutor 사용 테스트")
public void withThreadPoolExecutor() throws InterruptedException {
    ThreadPoolExecutor executor = new ThreadPoolExecutor(
        2, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000)
    );
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

    for (int number = 1; number <= 10_000; number++) {
        executor.submit(SquareNumber.create(number));
    }

    executor.shutdown();
    executor.awaitTermination(1, TimeUnit.MINUTES);
}

 

🔹 쓰레드 생성 방식:

최소 2개, 최대 5개의 스레드를 생성하여 관리

작업이 5개 이상 들어오면 최대 1,000개의 작업을 큐(Queue)에 저장

큐가 가득 차면 CallerRunsPolicy를 사용하여 직접 실행

 

💡 장점:

불필요한 스레드 생성을 막고 메모리를 효율적으로 사용

최대 스레드 개수를 제한하여 OOM 발생 방지

큐를 활용하여 너무 많은 작업이 몰리는 걸 방지

 

🕐 평균 실행 시간: ~100ms

가장 효율적인 방식! 🚀

 

4. 최종 성능 비교

 

실행 방식 쓰레드 생성 방식
평균 실행  시간 문제점
Thread 미사용 개별 Thread() 10,000개 생성 1.4초 과도한 스레드 생성 (OOM 위험)
FixedThreadPool(N) N개 스레드 유지 400ms 안정적
SingleThreadExecutor 단일 스레드 58ms 병렬 처리가 불가능
CachedThreadPool 필요할 때마다 무제한 스레드 생성 실행 불가 스레드 과다 생성 (OOM 발생)
ThreadPoolExecutor 최소 2개, 최대 5개, 큐(1000개) 사용 (소스예시) 100ms 직접 설정 가능, 가장 효율적

 

5. 결론: 언제 어떤 ThreadPool을 써야 할까?

 

빠르고 안정적인 병렬 처리FixedThreadPool(N)

작업을 하나씩 순차 실행newSingleThreadExecutor()

커스텀 설정이 필요ThreadPoolExecutor()

CachedThreadPool은 대량 작업에 부적합! (OOM 위험)

 

결국 ThreadPool을 사용할 때는 작업량과 환경에 맞게 신중하게 선택하는 것이 중요합니다.

무작정 newCachedThreadPool()을 쓰면 큰일 날 수도 있어요! 😅

 

 한 줄 요약

 

작업이 많다면FixedThreadPool 추천

작업이 순차적이면newSingleThreadExecutor 추천

최적화가 필요하면ThreadPoolExecutor 추천

메모리가 부족할 수 있다면CachedThreadPool쓰지 마세요

 

 

이제부터는 올바른 쓰레드 풀을 선택해서 더욱 효율적인 코드를 작성해 보세요! 🚀

 

댓글