스레드 풀과 관련된 개념을 기반으로, Executor 프레임워크, Future, Callable, 그리고 스레드 풀 설정에 대한 내용을 정리하면서 자세히 설명해 드리겠습니다.
스레드 풀(Thread Pool)은 효율적인 스레드 관리를 위한 기법입니다. 새로운 작업이 발생할 때마다 새로운 스레드를 생성하면, 스레드 생성과 소멸 과정에서 발생하는 오버헤드가 성능에 악영향을 줄 수 있습니다. 이를 해결하기 위해 스레드 풀은 스레드를 미리 만들어 놓고 재사용합니다.
자바에서는 스레드 풀을 편리하게 관리할 수 있도록 Executor 프레임워크를 제공합니다.
ExecutorService는 자바에서 제공하는 인터페이스로, 스레드 풀을 관리하고 작업을 할당하는 역할을 합니다. 이를 구현한 클래스가 ThreadPoolExecutor이며, 다양한 설정을 통해 스레드 풀의 동작을 제어할 수 있습니다.
// 기본적인 ThreadPoolExecutor 설정 예시
ExecutorService es = new ThreadPoolExecutor(
1, // corePoolSize: 기본 스레드 수
1, // maximumPoolSize: 최대 스레드 수
0, TimeUnit.MILLISECONDS, // idle 스레드가 풀에서 제거되기 전 대기 시간
new LinkedBlockingQueue<>() // 작업 큐
);
이 코드는 스레드 풀을 직접 설정하는 방법입니다. 그러나 자바에서 더 간편하게 스레드 풀을 생성하는 유틸리티 메서드도 제공합니다.
ExecutorService es = Executors.newFixedThreadPool(1);
Callable은 값을 반환하는 비동기 작업을 정의할 때 사용하는 인터페이스입니다. Runnable과 달리, Callable은 작업 완료 시 결과를 반환할 수 있으며, 이를 통해 작업의 결과나 상태를 관리할 수 있습니다.
// Callable을 사용한 예시
Future<Integer> future = Executors.newFixedThreadPool(1).submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
// 작업 수행
return 123;
}
});
Integer result = future.get(); // 작업 완료를 기다리고 결과를 반환
Future가 중요한 이유는 비동기 작업의 결과를 동기적으로 기다릴 수 있다는 점입니다. 예를 들어, 작업 하나가 2초가 걸린다고 가정했을 때, 두 개의 작업을 처리하는 방법은 다음과 같습니다.
Future<Integer> future1 = Executors.newFixedThreadPool(1).submit(new Callable<Integer>());
Integer result1 = future1.get(); // 첫 번째 작업을 기다림
Future<Integer> future2 = Executors.newFixedThreadPool(1).submit(new Callable<Integer>());
Integer result2 = future2.get(); // 두 번째 작업을 기다림
Future<Integer> future1 = Executors.newFixedThreadPool(1).submit(new Callable<Integer>());
Future<Integer> future2 = Executors.newFixedThreadPool(1).submit(new Callable<Integer>());
// 작업 완료 후 결과를 한꺼번에 받음
Integer result1 = future1.get();
Integer result2 = future2.get();
즉, 비동기 작업을 동시에 던지고, 나중에 결과를 모아받는 것이 중요한 사용 방법입니다. 이렇게 하면 스레드 풀을 최대로 활용하여 병렬 처리가 가능해집니다.
ThreadPoolExecutor는 스레드 풀의 크기를 조절하며, 다음과 같은 파라미터들을 사용합니다:
ExecutorService es = new ThreadPoolExecutor(
2, // corePoolSize: 기본 스레드 수
4, // maximumPoolSize: 최대 스레드 수
3000, TimeUnit.MILLISECONDS, // idle 스레드 대기 시간
new LinkedBlockingQueue<>() // 작업 큐
);
이 설정에서, 스레드가 두 개가 사용 중일 때 작업이 추가로 들어오면 큐에 넣습니다. 큐가 가득 차면 최대 스레드 수인 4개까지 늘어나며, 초과된 작업이 들어오면 오류가 발생할 수 있습니다.
대용량 트래픽 상황에서는 스레드 풀 설정이 매우 중요합니다. 스레드 풀을 잘못 설정하면 작업이 쌓이면서 성능이 급격히 저하될 수 있습니다. 이때 자주 실수하는 부분은 LinkedBlockingQueue를 사용하는 경우입니다.
LinkedBlockingQueue는 큐의 크기가 무한정 늘어날 수 있기 때문에, 작업이 계속 쌓여도 스레드 풀 크기가 증가하지 않아 작업 처리 속도가 느려질 수 있습니다.
ArrayBlockingQueue처럼 큐의 크기를 제한하면, 작업이 일정 크기에 도달하면 스레드 풀의 크기를 늘려 작업을 병렬 처리할 수 있어 성능을 높일 수 있습니다.
// 큐 크기를 제한한 사용자 정의 스레드 풀
ExecutorService es = new ThreadPoolExecutor(
100, // corePoolSize
200, // maximumPoolSize
60, TimeUnit.SECONDS, // idle 스레드 대기 시간
new ArrayBlockingQueue<>(1000) // 큐 크기 제한
);
이 경우 100개의 기본 스레드로 작업을 처리하고, 작업이 큐에 쌓이면 최대 200개까지 스레드가 증가합니다. 하지만 큐 크기를 넘는 작업이 발생하면 오류가 발생할 수 있으므로 큐 크기를 잘 관리하는 것이 중요합니다.
Runnable: 반환 값 없이, 단순히 작업을 실행하기 위한 인터페이스입니다. Runnable.run()은 어떤 값을 반환하지 않으며, 예외를 던지지 않습니다.
사용 시나리오: 스레드에서 별도로 실행할 작업이 있지만, 그 작업의 결과나 상태가 필요하지 않을 때 사용합니다. 예를 들어, 단순한 백그라운드 작업이나 이벤트 처리 등입니다.
Callable: 값을 반환하고, 예외를 던질 수 있는 인터페이스입니다. Callable.call() 메서드는 값을 반환할 수 있으며, 예외도 발생시킬 수 있습니다.
사용 시나리오: 작업이 완료되면 결과가 필요하거나, 작업 중 예외 처리가 필요할 때 사용합니다. 예를 들어, 계산 작업이나 데이터베이스 조회 작업과 같은 비동기 작업을 수행한 후 결과를 처리하는 경우에 유용합니다.
주요 차이점:
관리 방식:
대규모 트래픽을 처리하는 상황에서는 작업 큐의 설정이 매우 중요합니다. 큐 설정에 따라 시스템 성능이 크게 달라질 수 있습니다.
LinkedBlockingQueue: 기본적으로 무한대 크기의 큐입니다. 작업이 계속 쌓여도 큐가 늘어날 수 있기 때문에, 스레드 풀 크기가 증가하지 않고, 큐에 작업이 쌓이기만 합니다. 이로 인해 응답 속도가 느려질 수 있으며, 대기 중인 작업이 계속 증가하여 메모리 부족 문제가 발생할 수 있습니다.
ArrayBlockingQueue: 고정 크기의 큐입니다. 큐에 들어갈 수 있는 작업의 최대 개수를 제한하기 때문에, 작업이 일정 수를 초과하면 새로운 스레드를 생성하여 처리할 수 있습니다. 이 방식은 큐에 작업이 너무 많이 쌓이지 않도록 하고, 최대 스레드 수를 넘지 않도록 조절합니다. 하지만, 큐와 스레드 풀이 모두 가득 차면 작업이 거부되거나 오류가 발생할 수 있습니다.
성능 차이:
따라서, 대규모 트래픽을 관리하려면 큐 크기와 스레드 풀의 최대 수를 적절히 조절해야 하며, 필요에 따라 사용자 정의 전략을 사용하는 것이 좋습니다.