CodeGym /행동 /JAVA 25 SELF /ExecutorService, Callable, Future: 작업 실행

ExecutorService, Callable, Future: 작업 실행

JAVA 25 SELF
레벨 54, 레슨 1
사용 가능

1. ExecutorService: 스레드를 프로답게 관리하기

왜 단순히 new Thread로 스레드를 만들면 안 되는가

처음에는 멀티스레딩이 아주 간단해 보입니다:

Thread t = new Thread(() -> {
    // 무언가 작업 수행
});
t.start();

이 방법도 동작하지만, 작업이 많아지면 금세 부담이 됩니다. new Thread()를 호출할 때마다 새 스레드가 생성되고, 수십·수백 개의 스레드는 시스템에 과부하를 줄 수 있습니다. 게다가 관리도 불편합니다. 언제 끝나는지 추적하고, 오류가 나면 무엇을 할지, 어떻게 중지하고 재사용할지 모두 신경 써야 합니다.

이럴 때 등장하는 것이 ExecutorService — 똑똑한 스레드 디스패처입니다. 작업만 넘기면 어떤 스레드로 언제 실행할지 스스로 결정합니다. 결과적으로 더 빠르고, 더 안정적이며, 골치 아픈 일도 줄어듭니다.

ExecutorService의 동작 방식

ExecutorService는 단순하지만 효과적인 원리로 동작합니다.

  • 내부에 스레드 풀이 있습니다 — 미리 생성된 워커 스레드 집합(고정 또는 동적).
  • 작업은 큐에 쌓이고, 여유 있는 스레드가 가져가 실행합니다.
  • 서비스가 생명주기를 관리합니다: 완료를 기다리고, 풀을 정상 종료하며, 자원을 해제할 수 있습니다.

ExecutorService 생성

가장 흔한 방법은 Executors 클래스의 팩터리 메서드를 사용하는 것입니다:

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

ExecutorService executor = Executors.newFixedThreadPool(4); // 스레드 4개
  • newFixedThreadPool(N) — N개의 스레드로 구성된 풀(대부분의 작업에 적합).
  • newCachedThreadPool() — 동적 풀, 필요 시 스레드를 생성(주의: 작업이 폭주하면 메모리 한계에 부딪힐 수 있음).
  • newSingleThreadExecutor() — 단일 스레드(순차 실행).

예시: Runnable을 ExecutorService로 실행

executor.submit(() -> {
    System.out.println("스레드 풀에서 인사합니다!");
});

ExecutorService 사용을 마쳤다면 반드시 정상적으로 종료해야 합니다:

executor.shutdown(); // 새 작업 제출을 금지하고, 현재 작업이 끝날 때까지 대기

중요: shutdown()을 호출하지 않으면 프로그램이 종료되지 않을 수 있습니다 — 풀의 스레드가 새 작업을 기다리기 때문입니다.

2. Runnable vs Callable: 작업에는 여러 종류가 있다

Java 5 이전에는 스레드에서 무엇인가를 실행하려면 Runnable 인터페이스 구현을 작성했습니다. 이는 아무 것도 반환하지 않고 검사 예외를 던지지 않는 작업입니다.

Runnable task = () -> {
    System.out.println("그냥 작업만 하고 아무 것도 반환하지 않아요!");
};
executor.submit(task);

Callable: 결과를 반환하는 작업(그리고 예외)

때로는 작업이 단순히 실행만 하는 것이 아니라 결과를 반환하길 원합니다 — 예를 들어 합계, 계산 결과, 서버의 데이터 등. 이를 위해 Callable<T> 인터페이스가 있습니다.

import java.util.concurrent.Callable;

Callable<Integer> sumTask = () -> {
    int sum = 0;
    for (int i = 1; i <= 100; i++) sum += i;
    return sum;
};
  • call() 메서드는 T 타입의 결과를 반환합니다.
  • call() 메서드는 검사 예외를 던질 수 있습니다.

비유: Runnable — “가서 설거지해”(결과가 중요하지 않음), Callable — “가서 차를 가져오고 온도가 어떤지 알려줘”(결과가 중요함).

Callable 실행: 결과를 받으려면 executor.submit(...)을 사용하세요. 그러면 Future<T> 객체를 반환합니다.

3. Future: 결과에 대한 “약속”

Future는 미래에 결과를 돌려주겠다는 “약속”입니다. 작업을 ExecutorService에 보낼 때 Future를 받게 되며, 나중에 그 결과를 가져오거나, 작업 완료 여부를 확인하거나, 작업을 취소할 수 있습니다.

Future의 주요 메서드

  • T get() — 결과를 가져옴(작업이 끝날 때까지 대기).
  • boolean isDone() — 작업이 완료되었는지 확인.
  • boolean cancel(boolean mayInterruptIfRunning) — 작업 취소 시도.
  • boolean isCancelled() — 작업이 취소되었는지 확인.

예시: Callable 실행과 결과 받기

import java.util.concurrent.*;

public class ParallelSumApp {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(2);

        Callable<Integer> sumTask = () -> {
            int sum = 0;
            for (int i = 1; i <= 100; i++) sum += i;
            return sum;
        };

        Future<Integer> future = executor.submit(sumTask);

        System.out.println("작업이 시작되었습니다. 그동안 다른 일을 해도 됩니다...");

        // 결과를 받습니다(작업이 아직 끝나지 않았다면 이 메서드는 스레드를 블로킹합니다)
        Integer result = future.get();
        System.out.println("계산 결과: " + result);

        executor.shutdown();
    }
}
  • 작업이 스레드 풀에 제출됩니다.
  • 작업이 진행되는 동안 메인 스레드는 다른 일을 할 수 있습니다.
  • 결과가 필요할 때 future.get()을 호출하면 — 작업이 아직 진행 중이면 스레드가 기다립니다.
  • 작업이 완료되면 결과가 반환됩니다.

4. 실전: 여러 작업 실행과 완료 대기

여러 작업을 한꺼번에 실행하고 모두 끝날 때까지 기다려야 할 때가 많습니다. 예를 들어 데이터 배열을 분할하고 각 부분의 합을 별도의 작업으로 계산하는 경우입니다.

예시: 배열 원소의 합을 부분으로 나누어 계산

import java.util.*;
import java.util.concurrent.*;

public class ParallelArraySum {
    public static void main(String[] args) throws Exception {
        int[] array = new int[1000];
        Arrays.setAll(array, i -> i + 1); // 1부터 1000까지 숫자로 채움

        ExecutorService executor = Executors.newFixedThreadPool(4);

        int chunkSize = array.length / 4;
        List<Future<Integer>> futures = new ArrayList<>();

        for (int i = 0; i < 4; i++) {
            int from = i * chunkSize;
            int to = (i == 3) ? array.length : (i + 1) * chunkSize;

            Callable<Integer> sumTask = () -> {
                int sum = 0;
                for (int j = from; j < to; j++) sum += array[j];
                System.out.println("합계: " + from + "부터 " + (to - 1) + "까지 = " + sum);
                return sum;
            };

            futures.add(executor.submit(sumTask));
        }

        int totalSum = 0;
        for (Future<Integer> f : futures) {
            totalSum += f.get(); // 각 작업을 순서대로 기다림
        }

        System.out.println("총합: " + totalSum);

        executor.shutdown();
    }
}

여기서는 배열을 4개 부분으로 나눕니다. 각 부분에 대해 합을 계산하는 작업(Callable)을 생성합니다. 모든 작업을 ExecutorService에 제출하면 Future가 반환됩니다. 마지막에 모든 작업의 결과를 모아 합칩니다.

실무에서는 invokeAll을 사용해 모든 작업이 한 번에 끝날 때까지 기다리는 것이 편리합니다.

5. Future 사용 시 오류 처리

future.get()을 호출했을 때, 작업이 예외로 끝났다면 그 예외는 ExecutionException으로 감싸져 전달됩니다. 중요 포인트는, 작업에서 문제가 발생해도 get()을 호출할 때에야 알 수 있다는 것입니다.

예시: 예외 처리

Callable<Integer> errorTask = () -> {
    throw new IllegalArgumentException("무언가 잘못되었습니다!");
};

Future<Integer> badFuture = executor.submit(errorTask);

try {
    badFuture.get();
} catch (ExecutionException e) {
    System.out.println("작업이 오류로 종료되었습니다: " + e.getCause());
}
  • 작업 내부에서 예외가 발생합니다.
  • get()을 호출하면 예외가 ExecutionException으로 “포장”되어 전달됩니다.
  • 실제 원인은 getCause()로 가져올 수 있습니다.

6. 유용한 팁

작업을 취소하는 방법

Future<?> f = executor.submit(() -> {
    while (true) {
        // 무한 작업
        if (Thread.currentThread().isInterrupted()) {
            System.out.println("종료하라는 요청을 받았습니다!");
            break;
        }
    }
});

Thread.sleep(100); // 잠깐 대기
f.cancel(true); // 작업 취소 시도
  • cancel(true)는 작업이 아직 끝나지 않았다면 인터럽트를 걸어 취소를 시도합니다.
  • 작업 내부에서는 Thread.currentThread().isInterrupted()를 주기적으로 확인하고 정상적으로 종료하는 것이 좋습니다.

shutdown vs shutdownNow

shutdown() — 부드러운 종료: 새 작업 제출을 막고 현재 작업이 평온하게 끝나도록 합니다. 가장 자주 사용됩니다.

shutdownNow() — 강제 종료: 실행 중인 스레드를 인터럽트하려 시도하고, 시작하지 못한 작업 목록을 반환합니다. 주의해서 사용하세요.

invokeAll과 invokeAny

invokeAll(Collection<Callable<T>> tasks)는 전달된 모든 작업을 실행하고 모두 끝날 때까지 기다립니다. Future 목록을 반환합니다.

invokeAny(Collection<Callable<T>> tasks)는 최초로 성공한 작업 하나만 기다리고 그 결과를 반환한 뒤 나머지는 취소합니다. 가장 빠른 성공 응답이 중요할 때 유용합니다.

7. ExecutorService, Callable, Future 사용 시 흔한 실수

실수 1: ExecutorService를 닫지 않음. shutdown() 호출을 잊으면 main이 끝난 뒤에도 프로그램이 멈춘 것처럼 보일 수 있습니다. 풀의 스레드가 새 작업을 기다리기 때문입니다.

실수 2: 작업 제출 직후 결과를 기다림. submit() 직후 get()을 호출하면 비동기의 이점을 얻지 못합니다 — 어차피 스레드가 기다리게 됩니다. 병렬로 유용한 일을 하고, 정말 필요할 때 결과를 요청하세요.

실수 3: 작업 내부 예외를 무시함. get() 호출 시 ExecutionException을 처리하지 않으면, 작업에서 발생한 중요한 오류를 놓칠 수 있습니다.

실수 4: 동기화 없이 공유 가변 변수를 사용. 여러 작업이 같은 데이터를 다룬다면 동기화나 스레드 안전한 컬렉션이 필요합니다.

실수 5: 스레드를 과도하게 많이 생성. CPU 코어 수를 훨씬 초과하는 스레드 수로 풀을 구성하는 것은 오히려 성능을 떨어뜨릴 수 있습니다.

실수 6: 작업 취소를 잊음. 더 이상 필요하지 않은 작업은 cancel()로 취소하여 자원을 낭비하지 마세요.

1
과제
JAVA 25 SELF, 레벨 54, 레슨 1
잠금
드론으로 긴급 소포 발송 📦
드론으로 긴급 소포 발송 📦
1
과제
JAVA 25 SELF, 레벨 54, 레슨 1
잠금
고대 예언 해독 🔮
고대 예언 해독 🔮
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION