본문 바로가기

개발 언어 및 알고리즘 기초/JAVA 기초

[Java] Thread Pool (스레드 풀)

자바 네트워크 구현 관련된 공부 중 Thread Pool에 관한 내용이 다시 헷갈려 정리한 것에 대한 기록이다. 아래 책을 공부 중이다.

신용권, 임경균, 『이것이 자바다』, 한빛미디어(2023), p120-121.

 

 

 Thread Pool 이란? 

 Thread Pool(스레드 풀)이란 작업 처리에 사용되는 스레드를 제한된 개수만큼 정해 놓고 작업 queue에 들어오는 작업들을 스레드가 하나씩 맡아서 처리하는 방식이다. 이는 병렬 작업 처리가 많아지면 스레드 개수가 폭증하여 CPU가 바빠지고, 메모리 사용량이 증가하여 결국 애플리케이션의 성능까지 저하되는 현상을 방지하기 위해 사용된다. 

 Thread Pool이 작동하는 방식은 다음과 같다. 우선 애프리케이션에서 스레드풀에 작업 처리 요청을 한다. 그러면 스레드풀은 이를 작업 queue에 넣고 차례가 되면 각 스레드에서 이런 작업들을 처리하게 된다. 이후 다시 애플리케이션으로 결과를 전달한다. 

 

 

 

 Thread Pool 생성 

 Thread Pool을 생성하기 위해서 아래 메소드들을 이용하여 ExecutorService 인터페이스의 구현 객체를 만든다. 아래 메소드와 ExecutorService 인터페이스는 모두 java.util.concurrent 패키지에서 제공한다. 

메소드명 초기 생성 스레드 수 코어 수 (최소한 유지되는 스레드 수) 스레드의 최대 개수
newCachedThreadPool() 0 0 Integer.MAX_VALUE
newFixedThreadPool(int nThreads) 0 생성된 수 nThreads

 

 위 두 메소드의 차이는 매개변수로 스레드의 최대 개수를 받는지 여부도 있지만, 스레드 제거에 있어서도 차이를 보인다. newCachedThreaPool()의 경우 60초 동안 스레드가 아무 작업을 하지 않으면 스레드 풀에서 제거하지만 newFixedThreadPool()의 경우는 생성된 스레드를 제거하지 않는다. 

 

 위 메소드들을 이용하여 실제로 Thread Pool을 생성하는 코드는 아래와 같다.

ExecutorService executorService = Executors.newCachedThreadPool();
ExecutorService executorService = Executors.newFixedThreadPool(5);

 

 위 두 메소드를 사용하는 방식 외에도 직접 ThreadPoolExecutor로 스레드풀을 생성하는 방법도 있다. 위 두 메소드에서는 초기 생성 스레드 수, 코어수 등이 이미 설정되어 있지만 아래 코드와 같이 ThreadPoolExecutor를 이용하면 이를 커스텀하여 스레드풀을 생성할 수 있다. 

ExecutorService executorService = new ThreadPoolExecutor(
        3,                                 // 코어 스레드 개수
        100,                               // 최대 스레드 개수
        120L,                              // 이 시간 동안 동작이 없으면 해당 스레드 제거
        TimeUnit.SECONDS,                  // 위 시간 단위
        new SunchronousQueue<Runnable>()   // 작업 큐
    );

 

 

 

 Thread Pool  종료 

 스레드풀의 스레드는 데몬 스레드(Daemon Thread, 스레드의 작업을 돕는 보조적이 역할을 하는 스레드로, 주 스레드가 종료되면 자동으로 종료)가 아니기 때문에 스레드풀의 모든 스레드를 종료하려면 아래 메소드들 중 하나를 실행해주어야 한다. 

메소드명 설명
void shutdown() 현재 처리 중인 메소드와 작업 큐에 대기 중인 모든 작업을 처리한 후 스레드 풀 종료
List<Runnable> shutdownNow() 현재 처리 중인 스레드를 interrupt해서 중지시키고, 리턴 값으로 미처리된 작업의 목록을 리턴하며 종료

 

 

 

 작업 생성과 처리 요청 

 하나의 작업은 Runnable 또는 Callable 구현 객체로 표현한다. 둘의 차이점은 작업 처리 완료 후 리턴값의 존재 유무이다.

 

1. Runnable

 Runnable 구현 객체를 사용하여 작업 처리 요청을 할 때는 execute 메소드를 사용한다. 이 메소드는 Runnable 구현 객체를 매개변수로 받아서 이를 작업 큐에 저장한다. 그리고 작업 처리 결과를 리턴하지 않는다. 활용한 코드는 아래와 같다. 

ExecutorService executorService = Executors.newFixedThreadPool(5);

executorService.execute(new Runnable() {
    @Override
    public void run() {
    	// 스레드가 처리할 작업 내용
    }
});

 

 

2. Callable

 Callable 구현 객체를 사용하여 작업 처리 요청을 할 때는 submit 메소드를 사용한다. 이 메소드는 Callable 구현 객체를 매개변수로 받아서 이를 작업 큐에 저장하고, 작업 처리 결과를 얻을 수 있도록 Future<T> 타입으로 리턴한다. 활용한 코드는 아래와 같다. 

ExecutorService executorService = Executors.newFixedThreadPool(5);

Future<Integer> future = executorService.submit(new Callable<Integer>(){
    @Override
    public integer call() throws Exception {
    	// 처리할 내용
    }
});

// 이후 처리 결과 얻기
try {
    int result = future.get();
} catch (Exception e) {}

 

 

 

 서버 구현에서의 Thread Pool 사용 

 일반적으로 서버가 다수의 클라이언트와 통신을 하고, 클라이언트들로부터 동시에 요청을 받아서 처리하므로 먼저 연결한 클라이언트의 요청 처리 시간이 길어질수록 다음 클라이언트의 요청 처리 작업이 지연될 수 밖에 없다. 따라서 서버를 만들 때 accept()와 receive() 외의 요청 처리 코드를 별도의 스레드에서 작업해 주는 게 좋다. 

 하지만 이렇게 할 경우 클라이언트의 폭증으로 인해 서버가 과도하게 스레드를 생성할 가능성이 있다. 따라서 이 경우에 스레드풀을 사용하여 이를 방지해주는 것이다. 작업 큐의 대기 작업이 증가되어 클라이언트에서 응답을 늦게 받을 수도 있지만, 갑작스러운 클라이언트 폭증이 발생해도 크게 문제가 되지 않는다는 장점으로 인해 사용된다. 

 

 아래는 스레드 풀을 사용한 코드를 간단히 표현한 것이다. 

// TCP 서버
while (true) {
    Socket socket = serverSocket.accept();
    
    executorService.execute(() -> {
        // 연결된 클라이언트 정보 얻기
        // 데이터 받기
        // 데이터 보내기
        // 연결 끊기
    });
}

 

// UDP 서버
while (true) {
    DatagramPacket receivePacket = new DatagramPacket(new byte[1024], 1024);
    receive(receivePacket);
    
    executorService.execute(() -> {
        // 클라이언트 IP와 Port 얻기
        // 클라이언트에 정보 전송
    });
}
728x90