본문 바로가기
Java

[JAVA] ExecutorService로 Thread Pool 구현하기

by teamnova 2025. 3. 20.
728x90

안녕하세요.

이전 글에서 자바에서 쓰레드를 사용하는 방법에 대해 이야기했습니다. 쓰레드는 다양한 작업을 동시에 처리할 수 있도록 해주는 강력한 도구입니다.

하지만 자바의 쓰레드는 JVM에서 관리되며, 실제로 운영체제의 네이티브 쓰레드와 매핑되어 실행됩니다. 이는 운영체제의 리소스를 직접적으로 사용하는 구조이기 때문에 관리와 사용에 주의가 필요합니다.

 

1. 쓰레드 사용 시 문제점

쓰레드를 잘못 관리할 경우 다음과 같은 문제가 발생할 수 있습니다.

  1. 메모리 누수
    생성한 쓰레드를 제대로 해제하지 않으면, JVM의 가비지 컬렉터(GC)가 쓰레드를 해제하지 못해 메모리가 계속 점유되는 문제가 발생할 수 있습니다.
  2. 컨텍스트 스위칭 오버헤드
    컨텍스트 스위칭은 CPU가 여러 쓰레드를 전환하며 발생하는 작업으로, 이 과정에서 레지스터, 메모리 상태 등을 저장하고 복원하는 데 시간이 소요됩니다. 쓰레드가 과도하게 생성되면 컨텍스트 스위칭 횟수가 증가하여 성능이 저하됩니다.
  3. 리소스 부족
    너무 많은 쓰레드를 생성하면 각 쓰레드가 메모리와 CPU를 점유하여 시스템 자원이 부족해지고, 이는 서버의 응답 속도 저하 또는 장애로 이어질 수 있습니다.

이러한 문제를 해결하기 위해 등장한 개념이 바로 쓰레드풀(Thread Pool)입니다.

2. Thread Pool

쓰레드풀은 말 그대로 "쓰레드를 모아놓은 풀(pool)"입니다. 

즉, 미리 여러 개의 쓰레드를 생성해두고 필요할 때 재사용하는 구조를 말합니다.

 

쓰레드풀의 동작 방식

이미지 출처 : https://www.baeldung.com/thread-pool-java-and-guava

1. 쓰레드풀은 미리 생성된 쓰레드들을 대기 상태로 유지합니다. 대기 상태란 쓰레드가 작업을 할당받지 않은 상태를 의미합니다.
2. 작업이 발생하면 대기 중인 쓰레드 중 하나가 선택되어 작업을 수행합니다. 만약 모든 쓰레드가 작업 중이라면, 새로운 작업은 임시로 Task Queue(작업 대기열)에 저장됩니다. Task Queue는 작업이 처리될 때까지 임시로 보관하는 역할을 합니다.
3. 작업이 완료되면 해당 쓰레드는 다시 대기 상태로 돌아가며, 새로운 작업을 할당받을 준비를 합니다.

 

쓰레드풀의 주요 이점

쓰레드풀은 이러한 동작 방식을 통해 쓰레드 관리의 복잡성을 줄이고 자원을 효율적으로 사용할 수 있도록 도와줍니다. 이를 통해 다음과 같은 이점을 얻을 수 있습니다.

  • 쓰레드 생성 및 소멸에 따른 오버헤드 감소
    작업마다 새로운 쓰레드를 생성하는 대신 기존의 쓰레드를 재사용하므로 쓰레드 생성 및 소멸에 소요되는 자원을 줄일 수 있습니다. 이는 특히 반복적으로 많은 작업을 처리해야 하는 환경에서 성능을 크게 향상시킵니다.
  • 동시에 실행 가능한 작업 수 제한
    쓰레드풀은 실행 가능한 쓰레드 수를 제한함으로써 시스템 자원을 효율적으로 관리합니다. 이를 통해 과도한 쓰레드 생성으로 인한 CPU 오버헤드와 메모리 부족 문제를 방지하고, 시스템의 안정성을 높일 수 있습니다.
  • 쓰레드 상태 확인 및 해제를 쉽게 할 수 있음
    쓰레드풀은 중앙에서 쓰레드를 관리하므로, 각 쓰레드의 상태(대기, 실행 등)를 쉽게 모니터링할 수 있습니다. 또한, 필요에 따라 쓰레드풀을 종료하거나 해제하는 작업도 간단히 수행할 수 있습니다.

2. Executor Service

ExecutorService는 자바에서 제공하는 인터페이스로, 쓰레드풀을 보다 쉽게 관리하고 활용할 수 있도록 도와주는 기능을 제공합니다.

ExecutorService를 사용하면 직접 쓰레드를 생성하거나 관리하지 않아도 되고, 쓰레드풀의 크기, 작업 대기열 등을 설정하여 효율적으로 작업을 처리할 수 있습니다.

 

주요 메서드

1. submit(Runnable task) 또는 submit(Callable<V> task)

작업(Task)을 쓰레드풀에 제출합니다.

Runnable은 반환값이 없는 작업을 실행하고, Callable은 반환값이 있는 작업을 실행합니다.

 

2. execute(Runnable task)

작업을 제출하지만 반환값이 없으며, 단순히 작업을 실행하기만 합니다.
submit()과 달리 Future 객체를 반환하지 않습니다.

 

3. shutdown()

더 이상 새로운 작업을 받지 않고, 현재 대기열에 있는 작업들을 모두 처리한 후 종료합니다.

 

4. shutdownNow()

대기 중인 작업을 중단하고, 실행 중인 작업을 강제로 종료합니다.
실행 중인 작업이 즉시 종료되지 않을 수도 있으므로 주의가 필요합니다.

 

5. awaitTermination(long timeout, TimeUnit unit)

shutdown() 호출 후, 모든 작업이 완료되기를 지정된 시간 동안 기다립니다.

 

3. Thread Pool 생성 방법

자바에서는 Executor 유틸리티 클래스의 정적 메서드를 사용하여 다양한 유형의 쓰레드풀을 쉽게 생성할 수 있습니다. 대표적인 쓰레드풀 생성 방법은 다음과 같습니다:

1) newFixedThreadPool(int nThreads)
고정된 개수의 쓰레드를 가진 쓰레드풀을 생성합니다.
작업이 많아도 쓰레드 수는 고정되며, 초과 작업은 대기열에 저장됩니다.

- 사용 사례: 데이터베이스 연결 관리, 고정된 작업량 처리.

ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5);

 

2) newCachedThreadPool()
필요할 때 쓰레드를 생성하고, 사용하지 않는 쓰레드는 일정 시간 후 제거하는 쓰레드풀입니다.
작업량이 많을 때 유연하게 쓰레드 수를 늘릴 수 있습니다.

- 사용 사례: 요청 처리량이 불규칙한 웹 서버.

ExecutorService cachedThreadPool = Executors.newCachedThreadPool();

 

3) newSingleThreadExecutor()
단일 쓰레드로 작업을 처리하는 쓰레드풀입니다.
작업이 순차적으로 실행되며, 하나의 쓰레드만 사용합니다.

- 사용 사례: 로그 파일 기록.

ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();

 

4) newScheduledThreadPool(int corePoolSize)
일정 시간 간격으로 작업을 실행하거나, 지연된 작업을 처리할 수 있는 쓰레드풀입니다.

- 사용 사례: 백업 작업, 모니터링.

ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(3);

 

4. ExecutorService를 활용한 예제

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

// 작업 클래스: Runnable 인터페이스를 구현
class Task implements Runnable {
    private final String taskName;

    // ANSI 색상 코드 정의
    private static final String RESET = "\u001B[0m";
    private static final String GREEN = "\u001B[32m"; // 작업 시작 색상
    private static final String BLUE = "\u001B[34m";  // 작업 완료 색상

    public Task(String taskName) {
        this.taskName = taskName;
    }

    @Override
    public void run() {
        System.out.println(GREEN + Thread.currentThread().getName() + " - 작업 시작: " + taskName + RESET);
        try {
            // 작업 처리 시뮬레이션 (1초 대기)
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(BLUE + Thread.currentThread().getName() + " - 작업 완료: " + taskName + RESET);
    }
}

public class Main {
    public static void main(String[] args) {
        // 고정 크기 스레드 풀 생성 (스레드 5개)
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        // 10개의 작업 생성 및 제출
        for (int i = 1; i <= 20; i++) {
            String taskName = "작업" + i;
            Task task = new Task(taskName);
            executorService.submit(task); // 작업 제출
            try {
                // 작업 시작 간에 0.05초 대기
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 스레드 풀 종료 요청
        executorService.shutdown();
    }
}

 

실행결과