스레드 생성 비용 및 Future 활용에 관한 고찰

JunSuPark·2025년 3월 6일
post-thumbnail

최근 애플리케이션의 성능 최적화와 동시에 동시성 처리가 중요한 이슈로 떠오르고 있습니다.
이 글에서는 스레드 생성 비용, Runnable 인터페이스의 한계, 그리고 ThreadPoolExecutor와 Future를 활용해
어떻게 효율적인 비동기 작업 처리를 할 수 있는지에 대해 자세히 알아보겠습니다.


목차


1. 스레드 생성 비용과 성능 문제

스레드를 직접 생성할 경우,

  • 메모리 할당: 새로운 스레드를 위한 스택 메모리 할당
  • 운영체제 자원 사용: 스레드 관리를 위한 커널 자원 사용
  • 스케줄러 설정: OS의 스케줄러가 각 스레드를 관리하도록 해야 함

이런 작업들은 상당한 비용을 수반합니다. 특히, 많은 작업을 짧은 시간 내에 처리해야 하는 경우라면
스레드 생성 및 소멸에 드는 오버헤드가 전체 성능 저하로 이어질 수 있습니다.

예시:
다수의 작업을 순차적으로 처리하는 경우, 작업마다 새로운 스레드를 생성하면 매번 OS에 부담을 주게 됩니다.


2. Runnable 인터페이스의 불편함

자바에서 스레드를 구현할 때 흔히 사용되는 Runnable 인터페이스는 다음과 같은 한계가 있습니다.

  • 반환 값이 없음: Runnable의 run() 메서드는 실행 결과를 반환하지 않으므로, 작업 결과를 외부에서 직접 받아볼 수 없습니다.
  • 결과 전달 방식의 번거로움: 실행 결과를 별도의 멤버 변수에 저장한 후, join() 등을 통해 스레드가 종료되길 기다려야 합니다.

이러한 한계를 극복하기 위해 등장한 것이 Callable 인터페이스입니다.
Callable은 결과를 반환할 수 있으며 예외 처리도 보다 유연하게 할 수 있습니다.

Runnable 예시:

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 작업 수행
        System.out.println("Runnable 작업 수행");
    }
}

Callable 예시:

public class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() {
        // 작업 수행 후 결과 반환
        return 42;
    }
}

3. ThreadPoolExecutor와 Future의 등장

스레드 생성 비용 문제와 Runnable의 한계를 보완하기 위해 ThreadPoolExecutor를 사용합니다.
ThreadPoolExecutor는 요청이 들어올 때마다 새로운 스레드를 생성하는 대신, 미리 생성된 스레드 풀을 활용해 작업을 할당합니다.

  • 스레드 풀의 장점:
    • 스레드 생성 오버헤드 감소
    • 재사용 가능한 스레드로 효율적인 자원 관리
    • 작업 큐를 통한 작업 관리

또한, submit() 메서드를 사용하면 Future 객체가 즉시 반환되어 요청 스레드가 블로킹되지 않습니다.

예시:

ExecutorService executor = Executors.newFixedThreadPool(5);
Future<Integer> future = executor.submit(new MyCallable());
// 요청 스레드는 다른 작업을 수행할 수 있음.

4. Future.get() 호출과 Blocking/Non-Blocking

Future 객체는 제출된 작업의 미래 결과를 나타냅니다.
Future.get() 메서드를 호출하면 두 가지 상황이 발생할 수 있습니다:

  1. 작업이 완료된 경우
    • Future 내부에 작업 결과가 존재하므로, 즉시 반환됩니다.
  2. 작업이 아직 완료되지 않은 경우
    • Future는 결과가 준비될 때까지 요청 스레드를 블로킹 상태로 만듭니다.

이러한 동작 덕분에 요청 스레드는 작업 결과가 필요한 시점에만 기다리게 되어,
비동기적으로 작업을 수행하면서도 결과를 적시에 받을 수 있습니다.

예시:

// 비동기적으로 두 작업을 제출
Future<Integer> future1 = executor.submit(task1);
Future<Integer> future2 = executor.submit(task2);

// 필요 시 결과를 블로킹 호출로 받아올 수 있음
Integer result1 = future1.get();
Integer result2 = future2.get();

5. 작업 취소: cancel() 메서드의 활용

Future는 아직 완료되지 않은 작업을 취소할 수 있는 기능도 제공합니다.
cancel(boolean mayInterruptIfRunning) 메서드는 다음과 같이 동작합니다:

  • cancel(true):
    • Future를 취소 상태로 변경하고, 만약 작업이 실행 중이라면 Thread.interrupt()를 호출하여 중단시킵니다.
  • cancel(false):
    • Future를 취소 상태로 변경하지만, 실행 중인 작업은 중단시키지 않습니다.

이러한 기능을 통해 필요 없는 작업에 대한 자원 낭비를 방지할 수 있습니다.

예시:

// 작업 취소 예제
if (!future.isDone()) {
    future.cancel(true); // 실행 중인 작업이라도 중단을 시도
}

6. 추가적인 팁과 고려사항

  • 예외 처리:
    Future의 get() 메서드 호출 시 InterruptedExceptionExecutionException에 대한 처리가 필요합니다.
    예외 상황에 대비하여 적절한 로그 기록이나 리트라이 로직을 구현하세요.

  • 스레드 풀 사이즈:
    애플리케이션의 특성에 맞춰 스레드 풀의 크기를 조정하세요.
    너무 작은 풀은 작업 대기열이 쌓일 수 있고, 너무 큰 풀은 자원 낭비를 초래할 수 있습니다.

  • 자원 정리:
    ExecutorService 사용 후에는 shutdown() 또는 shutdownNow()를 호출해 스레드 풀을 적절히 종료하는 것이 좋습니다.

  • 실시간 모니터링:
    운영 중인 스레드 풀의 상태를 모니터링하여, 필요 시 동적으로 스레드 수를 조정하거나 문제를 사전에 파악할 수 있도록 하세요.


결론

스레드 생성 비용으로 인한 성능 문제와 Runnable 인터페이스의 한계를 극복하기 위해,
자바에서는 ThreadPoolExecutor와 Future를 통한 비동기 처리 기법을 제공합니다.
이를 통해 불필요한 스레드 생성 오버헤드를 줄이고,
요청 스레드가 필요할 때만 결과를 블로킹하여 받아올 수 있는 유연한 구조를 구현할 수 있습니다.

profile
배움을 추구하는 개발자

0개의 댓글