Java에서 쓰레드풀 관리하기: Executors와 ThreadPoolExecutor

서론: 쓰레드풀의 중요성과 Java의 접근 방식

Thread Pool
Thread Pool

 

멀티스레딩은 Java 프로그래밍에서 핵심적인 부분을 차지하며, Java 1.5 버전부터는 java.util.concurrent 패키지를 통해 개발자들이 쓰레드풀을 더욱 간편하고 효율적으로 관리할 수 있게 되었습니다. 이 글에서는 Executors 클래스ExecutorService 인터페이스를 사용하여 쓰레드풀을 생성하고 관리하는 방법에 대해 자세히 알아보겠습니다.

 

쓰레드풀 생성

쓰레드풀을 관리하는 주요 방법에는 ThreadPoolExecutor 객체를 직접 생성하는 방법과 Executors 클래스가 제공하는 정적 팩토리 메서드를 사용하는 방법이 있습니다.

1. ThreadPoolExecutor로 쓰레드풀 생성하기

ThreadPoolExecutor를 사용하여 쓰레드풀을 직접 생성할 때, 다음과 같은 파라미터들을 고려해야 합니다:

  • corePoolSize: 쓰레드풀의 기본 사이즈로, 최초에 생성되는 쓰레드의 수입니다. 이 값은 작업 부하와 자원에 따라 적절히 설정해야 합니다.
  • maximumPoolSize: 쓰레드풀이 수용할 수 있는 최대 쓰레드 수입니다. 이 또한 작업의 성격과 서버의 자원을 고려하여 결정해야 합니다.
  • keepAliveTime 및 TimeUnit: 사용되지 않는 쓰레드가 종료되기 전까지 유지되는 시간과 그 시간의 단위입니다.
  • BlockingQueue: 작업 큐로, corePoolSize를 초과하는 작업들이 이 큐에 저장됩니다.
ExecutorService executorService = new ThreadPoolExecutor(
    int corePoolSize, 
    int maximumPoolSize, 
    long keepAliveTime, 
    TimeUnit unit, 
    BlockingQueue<Runnable> workQueue);

corePoolSize를 초과하는 쓰레드가 필요할 경우, 작업은 먼저 큐에 저장되고, 큐가 가득 차면 maximumPoolSize까지 쓰레드가 증가합니다. keepAliveTime이 경과하면, 쓰레드 수는 다시 corePoolSize로 줄어듭니다.

 

2. Executors 클래스의 정적 팩토리 메서드 사용

Executors 클래스는 쓰레드풀을 쉽게 생성할 수 있는 여러 정적 팩토리 메서드를 제공합니다. 이러한 메서드들은 특정 상황에 최적화된 쓰레드풀을 생성할 수 있도록 도와줍니다:

  • newFixedThreadPool(int): 고정된 수의 쓰레드를 가지는 쓰레드풀을 생성합니다. 시스템의 CPU 코어 수에 맞추어 설정하면 최적의 성능을 낼 수 있습니다.
  • newCachedThreadPool(): 필요에 따라 쓰레드를 생성하고 재활용하는 쓰레드풀입니다. 작업이 없는 쓰레드는 60초 후에 자동으로 제거됩니다.
  • newScheduledThreadPool(int): 일정 시간 후에 실행되거나 주기적으로 실행되어야 하는 작업을 처리하는 데 적합한 쓰레드풀입니다.
  • newSingleThreadExecutor(): 단일 쓰레드에서 작업을 처리해야 할 때 사용합니다.
  • newWorkStealingPool(int): Java 8에서 도입된 메서드로, 필요에 따라 쓰레드 수를 동적으로 조정할 수 있는 쓰레드풀입니다.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class FixedThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(4);

        for (int i = 0; i < 10; i++) {
            int taskId = i;
            executorService.execute(() -> {
                System.out.println("Executing Task " + taskId + " Inside : " + Thread.currentThread().getName());
            });
        }

        executorService.shutdown();
    }
}

이 코드는 Executors.newFixedThreadPool(int) 메서드를 사용하여 고정된 수의 쓰레드를 가진 쓰레드풀을 생성하는 예시입니다. 이 예시에서는 4개의 쓰레드를 가진 풀을 생성하고, 각 쓰레드에 작업을 할당합니다.

 

ExecutorService를 통한 작업 관리

ExecutorService 인터페이스는 쓰레드풀에 작업을 할당하고 실행 결과를 관리하는 다양한 메서드를 제공합니다. 이를 통해 개발자는 작업의 실행을 세밀하게 제어할 수 있으며, 다음과 같은 방법으로 작업을 관리할 수 있습니다:

  • execute(Runnable): 작업을 즉시 실행합니다.
  • submit(Callable): 작업을 실행하고 Future 객체를 통해 결과를 반환받습니다.
  • invokeAny()와 invokeAll(): 여러 작업을 동시에 실행하고 결과를 관리합니다.
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class SubmitTaskExample {
    public static void main(String[] args) throws Exception {
        ExecutorService executorService = Executors.newCachedThreadPool();

        Callable<String> task = () -> {
            Thread.sleep(1000); // 시뮬레이션을 위한 1초 대기
            return "Task's execution";
        };

        Future<String> future = executorService.submit(task);

        System.out.println("Future done? " + future.isDone());

        String result = future.get();  // 블록킹 콜, 태스크의 완료를 기다립니다.

        System.out.println("Future done? " + future.isDone());
        System.out.println("Result: " + result);

        executorService.shutdown();
    }
}

이 코드는 ExecutorService.submit(Callable<T>) 메서드를 사용하여 작업을 제출하고 Future 객체를 통해 결과를 받는 예시입니다. 이 예시에서는 Callable 작업을 제출하고, 완료되면 결과를 출력합니다.

쓰레드풀 종료

작업 완료 후 쓰레드풀을 안전하게 종료하는 것은 자원을 해제하고, 시스템의 안정성을 유지하기 위해 중요합니다. shutdown() 또는 shutdownNow() 메서드를 사용하여 쓰레드풀을 종료할 수 있으며, 각각의 메서드는 쓰레드풀을 종료하는 방식에 차이가 있습니다.

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

public class ShutdownExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);

        executorService.execute(() -> {
            try {
                Thread.sleep(2000);
                System.out.println("Task 1 completed");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        executorService.execute(() -> {
            try {
                Thread.sleep(1000);
                System.out.println("Task 2 completed");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        executorService.shutdown();
        System.out.println("Executor service has been shut down.");
    }
}

이 코드는 ExecutorService.shutdown() 메서드를 사용하여 쓰레드풀을 안전하게 종료하는 예시입니다. 모든 작업이 완료되기를 기다린 후에 쓰레드풀을 종료합니다.

결론

Java의 ExecutorServiceExecutors 클래스를 활용하면 멀티스레딩 환경에서 작업 처리를 효율적으로 관리할 수 있습니다. 적절한 쓰레드풀 설정과 작업 관리 방법을 통해 애플리케이션의 성능을 최적화하고, 리소스를 효과적으로 활용할 수 있습니다.