본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
15장 : 동시성

Executor로 작업 수명 관리하기

스레드를 직접 만들면 작업 제출과 실행 자원 관리가 한 코드에 섞입니다.

ExecutorService는 Runnable과 Callable 작업을 받아 스레드 생성, 재사용, 종료 정책을 분리합니다.

작업을 제출한 뒤 서비스 종료를 잊으면 비데몬 스레드가 남아 프로그램이 끝나지 않을 수 있습니다.

Java 19 이후 ExecutorService는 AutoCloseable이라 try-with-resources로 범위를 닫을 수 있습니다.

이 절에서는 작업 수와 스레드 수를 같은 것으로 보지 않습니다.

Executor에 제출한 순서는 실행 시작·완료 순서를 보장하지 않습니다. 실행기는 작업을 큐와 스레드에 배치하고, 호출자는 Future를 통해 개별 완료를 관찰합니다. 순서가 업무 계약이라면 결과를 어떤 순서로 기다리고 조립할지 별도로 설계해야 합니다.


Runnable과 Callable이 작업을 표현한다

Runnable은 결과가 없고 Callable은 값 반환과 checked 예외를 허용합니다.

작업 자체는 어느 스레드에서 실행될지 결정하지 않습니다.


Executor가 작업을 실행 자원에 배치한다

고정 풀은 제한된 플랫폼 스레드를 재사용하고 virtual thread executor는 작업마다 가상 스레드를 만듭니다.

작업 특성에 따라 선택합니다.

세 작업은 두 플랫폼 스레드에서 경쟁해 완료 순서가 달라질 수 있지만, 호출자는 제출 순서대로 보관한 Future를 차례로 읽습니다.

ExecutorLifecycle.java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class ExecutorLifecycle {
    public static void main(String[] args) throws Exception {
        try (var executor = Executors.newFixedThreadPool(2)) {
            List<Future<Integer>> results = new ArrayList<>();
            for (int value : List.of(2, 3, 4)) {
                results.add(executor.submit(() -> value * value));
            }
            for (Future<Integer> result : results) {
                System.out.println(result.get());
            }
        }
    }
}

출력이 항상 4, 9, 16인 것은 작업이 그 순서로 끝났기 때문이 아니라 Future 목록을 그 순서로 get했기 때문입니다. 첫 작업이 느리면 뒤 작업이 이미 끝났어도 첫 get에서 기다리는 head-of-line 대기가 생길 수 있습니다.

4
9
16

세 작업은 두 스레드에 배치되지만 Future 목록 순서로 결과를 기다려 4, 9, 16을 출력합니다.


Future가 완료와 결과 경계를 제공한다

get은 완료까지 기다리고 결과나 감싼 예외를 반환합니다.

타임아웃과 취소 정책을 정하지 않으면 무기한 대기할 수 있습니다.

timeout이 나도 작업이 자동 취소되는 것은 아닙니다. cancel(true) 역시 실행 스레드에 인터럽트를 요청할 뿐 강제 종료하지 않으므로 작업 코드가 인터럽트를 무시하면 계속 실행될 수 있습니다.

비교 1
try (var executor = Executors.newSingleThreadExecutor()) {
    Future<String> future = executor.submit(() -> "done");
    System.out.println(future.get(1, java.util.concurrent.TimeUnit.SECONDS));
    future.cancel(true); // 아직 실행 중이면 인터럽트 요청
}
비교 2
// ExecutorService를 닫지 않으면 비데몬 플랫폼 스레드가 프로그램 종료를 막을 수 있음
// 작업 100개와 스레드 100개는 같은 개념이 아님

종료를 자원 수명에 포함한다

try-with-resources나 shutdown/awaitTermination으로 새 작업 수락 중단과 기존 작업 완료를 관리합니다.

첫 작업에만 짧은 sleep을 넣고 각 작업 안에서도 완료 값을 출력해 보세요. 작업 완료 로그와 Future 결과 출력의 순서가 다를 수 있습니다. 결과가 완료되는 즉시 처리해야 한다면 제출 순서의 get 반복 대신 완료 큐나 비동기 조합이 필요한 이유입니다.

고정 풀의 크기는 CPU 수만 보고 정하지 않습니다. CPU 계산은 코어 수 근처에서 시작해 측정하고, 블로킹 작업은 대기 비율과 외부 자원 용량을 함께 봅니다. 큐가 무한히 커지는 실행기는 과부하를 숨길 수 있으므로 실제 서비스에서는 큐 한도와 거부 정책도 수명 계약에 포함합니다.


Executor는 스레드 생성 편의가 아니라 작업 제출, 실행 용량, 완료 대기, 종료를 분리하는 수명 관리 경계입니다.