Executor 프레임워크, Future, Callable

서버란·2024년 9월 19일

자바 궁금증

목록 보기
29/35

스레드 풀과 관련된 개념을 기반으로, Executor 프레임워크, Future, Callable, 그리고 스레드 풀 설정에 대한 내용을 정리하면서 자세히 설명해 드리겠습니다.

1. 스레드 재사용과 스레드 풀의 개념

스레드 풀(Thread Pool)은 효율적인 스레드 관리를 위한 기법입니다. 새로운 작업이 발생할 때마다 새로운 스레드를 생성하면, 스레드 생성과 소멸 과정에서 발생하는 오버헤드가 성능에 악영향을 줄 수 있습니다. 이를 해결하기 위해 스레드 풀은 스레드를 미리 만들어 놓고 재사용합니다.

  • 작업을 완료한 스레드는 종료되지 않고 풀에 반납되어, 새로운 작업을 처리할 수 있는 상태로 대기합니다.
  • 스레드 풀을 통해 스레드 생성 비용을 줄이고, 자원을 효율적으로 관리할 수 있습니다.

자바에서는 스레드 풀을 편리하게 관리할 수 있도록 Executor 프레임워크를 제공합니다.

2. ExecutorService와 ThreadPoolExecutor

ExecutorService는 자바에서 제공하는 인터페이스로, 스레드 풀을 관리하고 작업을 할당하는 역할을 합니다. 이를 구현한 클래스가 ThreadPoolExecutor이며, 다양한 설정을 통해 스레드 풀의 동작을 제어할 수 있습니다.

// 기본적인 ThreadPoolExecutor 설정 예시
ExecutorService es = new ThreadPoolExecutor(
    1,                    // corePoolSize: 기본 스레드 수
    1,                    // maximumPoolSize: 최대 스레드 수
    0, TimeUnit.MILLISECONDS, // idle 스레드가 풀에서 제거되기 전 대기 시간
    new LinkedBlockingQueue<>() // 작업 큐
);

이 코드는 스레드 풀을 직접 설정하는 방법입니다. 그러나 자바에서 더 간편하게 스레드 풀을 생성하는 유틸리티 메서드도 제공합니다.

  • Executors.newFixedThreadPool(): 고정된 스레드 수를 가진 스레드 풀을 생성합니다. 스레드 수가 고정되어 있어, 스레드 수가 초과되면 작업이 큐에 저장되며, 작업이 완료되면 스레드가 큐에서 다음 작업을 처리합니다.
ExecutorService es = Executors.newFixedThreadPool(1);

3. Callable과 Future

Callable은 값을 반환하는 비동기 작업을 정의할 때 사용하는 인터페이스입니다. Runnable과 달리, Callable은 작업 완료 시 결과를 반환할 수 있으며, 이를 통해 작업의 결과나 상태를 관리할 수 있습니다.

  • submit() 메서드: ExecutorService는 Callable 객체를 submit()을 통해 제출할 수 있으며, 작업을 수행하고 Future 객체를 반환합니다.
  • Future는 비동기 작업의 결과를 나타내며, 작업이 완료되면 결과를 받아올 수 있는 객체입니다. 작업이 완료되지 않았다면 Future.get() 메서드를 호출할 때까지 블로킹되어 기다리게 됩니다. 스레드가 완료가 되면 future에 결과를 담고 상태를 완료로 변경후에 요청스레드를 깨운다
// Callable을 사용한 예시
Future<Integer> future = Executors.newFixedThreadPool(1).submit(new Callable<Integer>() {
    @Override
    public Integer call() throws Exception {
        // 작업 수행
        return 123;
    }
});

Integer result = future.get();  // 작업 완료를 기다리고 결과를 반환

4. Future의 필요성

Future가 중요한 이유는 비동기 작업의 결과를 동기적으로 기다릴 수 있다는 점입니다. 예를 들어, 작업 하나가 2초가 걸린다고 가정했을 때, 두 개의 작업을 처리하는 방법은 다음과 같습니다.

  • 잘못된 활용 예시: 각 작업의 결과를 기다린 후, 다음 작업을 요청하는 경우, 모든 작업이 순차적으로 실행되어 4초가 소요됩니다. 사실상 싱글 스레드처럼 동작하게 되는 셈입니다.
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를 먼저 요청한 뒤, 작업이 모두 완료되면 get()으로 결과를 가져오는 방식으로 처리해야 합니다. 이 경우 두 작업이 동시에 처리되므로 총 2초가 소요됩니다.
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();

즉, 비동기 작업을 동시에 던지고, 나중에 결과를 모아받는 것이 중요한 사용 방법입니다. 이렇게 하면 스레드 풀을 최대로 활용하여 병렬 처리가 가능해집니다.

5. ThreadPoolExecutor의 스레드 관리

ThreadPoolExecutor는 스레드 풀의 크기를 조절하며, 다음과 같은 파라미터들을 사용합니다:

  • corePoolSize: 기본적으로 유지되는 스레드 수.
  • maximumPoolSize: 작업이 많을 때 증가할 수 있는 최대 스레드 수.
  • keepAliveTime: corePoolSize를 초과한 스레드가 작업을 완료한 후 대기하는 시간.
  • workQueue: 작업이 대기하는 큐. 스레드가 부족할 때 작업이 대기할 곳입니다.
ExecutorService es = new ThreadPoolExecutor(
    2,                       // corePoolSize: 기본 스레드 수
    4,                       // maximumPoolSize: 최대 스레드 수
    3000, TimeUnit.MILLISECONDS, // idle 스레드 대기 시간
    new LinkedBlockingQueue<>()  // 작업 큐
);

이 설정에서, 스레드가 두 개가 사용 중일 때 작업이 추가로 들어오면 큐에 넣습니다. 큐가 가득 차면 최대 스레드 수인 4개까지 늘어나며, 초과된 작업이 들어오면 오류가 발생할 수 있습니다.

6. 대용량 트래픽 관리 및 사용자 정의 풀 전략

대용량 트래픽 상황에서는 스레드 풀 설정이 매우 중요합니다. 스레드 풀을 잘못 설정하면 작업이 쌓이면서 성능이 급격히 저하될 수 있습니다. 이때 자주 실수하는 부분은 LinkedBlockingQueue를 사용하는 경우입니다.

  • LinkedBlockingQueue는 큐의 크기가 무한정 늘어날 수 있기 때문에, 작업이 계속 쌓여도 스레드 풀 크기가 증가하지 않아 작업 처리 속도가 느려질 수 있습니다.

  • ArrayBlockingQueue처럼 큐의 크기를 제한하면, 작업이 일정 크기에 도달하면 스레드 풀의 크기를 늘려 작업을 병렬 처리할 수 있어 성능을 높일 수 있습니다.

// 큐 크기를 제한한 사용자 정의 스레드 풀
ExecutorService es = new ThreadPoolExecutor(
    100,                        // corePoolSize
    200,                        // maximumPoolSize
    60, TimeUnit.SECONDS,        // idle 스레드 대기 시간
    new ArrayBlockingQueue<>(1000) // 큐 크기 제한
);

이 경우 100개의 기본 스레드로 작업을 처리하고, 작업이 큐에 쌓이면 최대 200개까지 스레드가 증가합니다. 하지만 큐 크기를 넘는 작업이 발생하면 오류가 발생할 수 있으므로 큐 크기를 잘 관리하는 것이 중요합니다.

정리

  • 스레드 풀은 작업을 효율적으로 처리하기 위해 스레드를 미리 생성해두고 재사용하는 방식입니다.
  • ExecutorService와 ThreadPoolExecutor는 자바에서 스레드 풀을 관리하는 대표적인 도구입니다.
  • Callable과 Future를 통해 비동기 작업의 결과를 동기적으로 받을 수 있으며, 올바르게 사용하면 병렬 처리 성능을 극대화할 수 있습니다.
  • 스레드 풀과 큐의 크기를 적절히 설정하는 것이 성능 최적화에 중요합니다. 특히 대용량 트래픽 환경에서는 ArrayBlockingQueue 등을 사용해 큐의 크기를 제어하는 것이 효과적입니다.

Q1: Callable과 Runnable의 차이는 무엇이며, 각각의 사용 시나리오는 무엇인가요?

  • Runnable: 반환 값 없이, 단순히 작업을 실행하기 위한 인터페이스입니다. Runnable.run()은 어떤 값을 반환하지 않으며, 예외를 던지지 않습니다.

  • 사용 시나리오: 스레드에서 별도로 실행할 작업이 있지만, 그 작업의 결과나 상태가 필요하지 않을 때 사용합니다. 예를 들어, 단순한 백그라운드 작업이나 이벤트 처리 등입니다.

  • Callable: 값을 반환하고, 예외를 던질 수 있는 인터페이스입니다. Callable.call() 메서드는 값을 반환할 수 있으며, 예외도 발생시킬 수 있습니다.

  • 사용 시나리오: 작업이 완료되면 결과가 필요하거나, 작업 중 예외 처리가 필요할 때 사용합니다. 예를 들어, 계산 작업이나 데이터베이스 조회 작업과 같은 비동기 작업을 수행한 후 결과를 처리하는 경우에 유용합니다.

주요 차이점:

  • Runnable은 반환 값이 없고, 예외를 던지지 않습니다.
  • Callable은 반환 값을 가지며, 예외를 던질 수 있습니다.

Q2: ThreadPoolExecutor에서 corePoolSize와 maximumPoolSize 사이의 스레드 관리는 어떻게 이루어지나요?

  • corePoolSize: 기본적으로 생성되고 유지되는 스레드의 수입니다. 스레드 풀이 처음 작업을 처리할 때는 corePoolSize만큼 스레드가 실행됩니다.
  • maximumPoolSize: 큐에 대기 중인 작업이 많을 때, corePoolSize를 초과하여 생성될 수 있는 최대 스레드 수입니다.

관리 방식:

  1. 스레드 풀에 새로운 작업이 들어오면, corePoolSize 이하의 스레드에서 먼저 작업을 처리합니다.
  2. 작업이 많아져서 corePoolSize의 스레드들이 모두 바쁘게 되면, 새로운 작업은 작업 큐에 들어갑니다.
  3. 큐가 가득 차면, 그때서야 corePoolSize를 초과하는 스레드를 생성하여 작업을 처리합니다. 이 스레드는 maximumPoolSize까지 증가할 수 있습니다.
  4. 만약 maximumPoolSize를 초과하는 작업이 들어오면, 추가적인 작업은 거부되거나 예외를 발생시킵니다.
  5. keepAliveTime 동안 초과된 스레드가 대기 상태에 있으면, 시간이 지나면 스레드가 제거되어 다시 corePoolSize로 돌아갑니다.

Q3: 대규모 트래픽을 처리하는 상황에서, ThreadPoolExecutor의 큐 설정에 따른 성능 차이는 무엇인가요?

대규모 트래픽을 처리하는 상황에서는 작업 큐의 설정이 매우 중요합니다. 큐 설정에 따라 시스템 성능이 크게 달라질 수 있습니다.

  • LinkedBlockingQueue: 기본적으로 무한대 크기의 큐입니다. 작업이 계속 쌓여도 큐가 늘어날 수 있기 때문에, 스레드 풀 크기가 증가하지 않고, 큐에 작업이 쌓이기만 합니다. 이로 인해 응답 속도가 느려질 수 있으며, 대기 중인 작업이 계속 증가하여 메모리 부족 문제가 발생할 수 있습니다.

  • ArrayBlockingQueue: 고정 크기의 큐입니다. 큐에 들어갈 수 있는 작업의 최대 개수를 제한하기 때문에, 작업이 일정 수를 초과하면 새로운 스레드를 생성하여 처리할 수 있습니다. 이 방식은 큐에 작업이 너무 많이 쌓이지 않도록 하고, 최대 스레드 수를 넘지 않도록 조절합니다. 하지만, 큐와 스레드 풀이 모두 가득 차면 작업이 거부되거나 오류가 발생할 수 있습니다.

성능 차이:

  • LinkedBlockingQueue는 작업이 계속 쌓여 스레드 풀이 효율적으로 증가하지 않기 때문에, 대기 시간이 길어질 수 있고 성능이 저하될 가능성이 높습니다.
  • ArrayBlockingQueue는 제한된 큐 크기와 스레드 수를 사용하여 작업을 병렬로 처리하기 때문에 성능을 최적화할 수 있습니다. 다만, 큐와 스레드가 모두 가득 찰 경우 오류가 발생할 위험이 있습니다.

따라서, 대규모 트래픽을 관리하려면 큐 크기와 스레드 풀의 최대 수를 적절히 조절해야 하며, 필요에 따라 사용자 정의 전략을 사용하는 것이 좋습니다.

profile
백엔드에서 서버엔지니어가 된 사람

0개의 댓글