멀티스레딩을 다루다 보면, 쓰레드 관리를 어떻게 해야 할까? 라는 고민이 생깁니다.
쓰레드를 마구 생성하면 메모리가 터지고(OutOfMemoryError), 그렇다고 하나씩 돌리면 너무 느리죠.
그래서 Java에서는 ThreadPool(쓰레드 풀) 을 제공합니다.
그런데 이게 또 종류가 여러 가지라, “FixedThreadPool이 좋을까? CachedThreadPool이 좋을까?” 같은 고민이 생깁니다.
그래서 이번 글에서는 ThreadPool을 안 썼을 때와 썼을 때의 성능 차이를 직접 실험해보고, 어떤 경우에 어떤 쓰레드 풀을 써야 할지 정리해보겠습니다. 🚀
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 ❌ 쓰지 마세요
이제부터는 올바른 쓰레드 풀을 선택해서 더욱 효율적인 코드를 작성해 보세요! 🚀
'언어 > Java' 카테고리의 다른 글
[Java] 동기식 처리와 비동기식 처리 ( feat. 블록킹 논블록킹을 곁들인 ) (0) | 2025.01.11 |
---|---|
[Java] record type (0) | 2022.12.06 |
[Java] KST 시간변환 (0) | 2022.08.16 |
[JUnit5] 테스트 코드 시작해보기 - 2 (0) | 2022.07.15 |
[JUnit5] 테스트 코드 시작해보기 - 1 (0) | 2022.06.15 |
댓글