이 글에서는 자바가 강력한 동시성 문제를 해결하기 위해 어떤 도구들을 발전시켜왔는지 다룬다. 스레드를 효율적으로 관리하는 ExecutorService부터 현대적인 비동기 프로그래밍의 핵심인 CompletableFuture, 그리고 병렬 처리의 강력한 도구인 ForkJoinPool까지 순서대로 살펴본다.
가장 기본적인 스레드 생성 방식으로 Runnable 인터페이스에 작업 내용을 정의하고 Thread 객체로 감싸 실행한다.
Runnable task = () -> System.out.println("새 스레드에서 작업 실행!");
Thread thread = new Thread(task);
thread.start(); // 비동기 실행 시작
하지만 이 방법은 작업이 필요할 때마다 스레드를 생성하고 파괴해야 한다.

그림에 보이는 것처럼 다중 요청이 들어오면 동시에 처리하는게 아닌 요청 1을 처리하고 요청 2를 처리하기에 매우 비효율적이라고 볼 수 있다.
매번 스레드를 만들고 없애는 비효율을 없애기 위해 Thread Pool 개념 도입하게 된다.

필요한 스레드를 스레드 풀에 보관하고 관리하며 스레드가 필요하면 생성되어 있는 스레드를 스레드 풀에서 꺼내 사용하고 사용이 끝나면 스레드 풀에 다시 반납한다.
만일 이때 스레드 풀에 스레드가 없다면 요청을 거절하거나 대기하도록 설정 가능하다.
// 10개의 스레드를 가진 스레드 풀 생성
// 스레드 수를 잘 정해야 함
ExecutorService executor = Executors.newFixedThreadPool(10);
// 스레드 풀에 작업 제출 (submit). 풀 안의 스레드가 이 작업을 처리함.
executor.submit(() -> System.out.println("스레드 풀에서 작업 실행!"));
executor.shutdown(); // 스레드 풀 종료
ExecutorService 의 한 종류로 Java 7부터 도입됨ForkJoinPool의 각 스레드는 자신만의 작업 큐를 가지며 어떤 스레드가 자신의 일을 모두 끝내고 할 일이 사라지면 다른 스레드의 작업 큐에 있는 일을 빼앗아서 처리import java.util.concurrent.RecursiveTask;
// Long 타입의 결과를 반환하는 재귀 작업 정의
public class SumTask extends RecursiveTask<Long> {
private final long[] array;
private final int start;
private final int end;
private static final int THRESHOLD = 10_000; // 작업을 더 이상 쪼개지 않을 임계값
public SumTask(long[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
int length = end - start;
// 1. 임계값보다 작으면, 그냥 순차적으로 계산 (Base case)
if (length <= THRESHOLD) {
long sum = 0;
for (int i = start; i < end; i++) {
sum += array[i];
}
return sum;
}
// 2. 임계값보다 크면, 작업을 반으로 쪼갠다 (Recursive case)
int mid = start + length / 2;
SumTask leftTask = new SumTask(array, start, mid);
SumTask rightTask = new SumTask(array, mid, end);
// 왼쪽 작업은 비동기 실행을 위해 Fork하고,
// 오른쪽 작업은 현재 스레드에서 바로 실행하여 효율을 높임
leftTask.fork(); // Fork: 작업을 스케줄러에 등록
long rightResult = rightTask.compute();
long leftResult = leftTask.join(); // Join: Fork한 작업의 결과가 나올 때까지 대기
return leftResult + rightResult;
}
}
ExecutorService에 작업을 제출하면 Future 객체를 반환받을 수 있다.
future.get()을 호출해야 하는데 이 메서드는 작업이 완료될 때까지 스레드 블로킹한다..get()을 호출하면, 그 작업이 끝날 때까지 메인 스레드는 그 자리에서 멈춰서 무한정 기다려야 한다.블로킹?
자신의 작업을 진행하다가 다른 주체의 작업이 끝날 때까지 기다리느라 멈추는 것을 의미한다.
Future의 한계를 극복하고 콜백을 기반으로 비동기 작업으로 진행된다.get() 방식 대신 then…() 방식 사용 가능하다.thenApply(result -> ...): 작업 결과를 받아 다른 값으로 변환하는 후속 작업 등록thenAccept(result -> ...): 작업 결과를 받아 소비하고 반환 값은 없는 후속 작업 등록thenRun(...): 작업 결과는 신경 쓰지 않고 완료되었다는 사실 자체에만 관심 있을 때 후속 작업 등록// Future의 한계: 결국 기다려야 한다.
Future<String> future = executor.submit(() -> "Hello");
String result = future.get(); // <-- 여기서 메인 스레드가 멈춤!
System.out.println(result + " World");
// CompletableFuture: 기다림 없이 흐름을 정의한다.
CompletableFuture.supplyAsync(() -> "Hello")
.thenApply(s -> s + " World") // 'Hello'가 준비되면 바로 이어서 실행
.thenAccept(System.out::println); // 'Hello World'가 준비되면 바로 출력
System.out.println("메인 스레드는 멈추지 않고 이 코드를 바로 실행!");