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

안동민 개발노트

본문 시작
15장 : 동시성

Future와 CompletableFuture

여러 비동기 작업의 결과를 조합하려면 순차적인 Future.get만으로는 흐름이 중첩되기 쉽습니다.

CompletableFuture는 값 변환, 다른 비동기 작업 연결, 두 결과 결합, 예외 복구를 파이프라인으로 표현합니다.

thenApply는 값을 즉시 변환하고 thenCompose는 또 다른 CompletionStage를 평탄화합니다.

비동기 접미사가 붙은 메서드는 실행 Executor가 달라질 수 있습니다.

이 절에서는 성공과 실패가 같은 결과 그래프를 따라 흐르게 합니다.

CompletableFuture가 있다는 사실만으로 작업이 비동기가 되지는 않습니다. non-Async 연결 메서드는 이전 단계를 완료한 스레드에서 이어질 수 있고, Async 변형은 지정한 Executor나 기본 공용 풀에 다시 배치됩니다. 각 단계가 어느 실행 자원을 사용하는지 외부 I/O와 CPU 작업 기준으로 정해야 합니다.


supplyAsync가 결과 있는 작업을 시작한다

기본 공용 풀 또는 명시한 Executor에서 Supplier를 실행합니다.

블로킹 작업은 전용 실행 정책을 고려합니다.


thenApply와 thenCompose의 중첩이 다르다

일반 값 변환은 apply, 비동기 작업을 반환하는 함수 연결은 compose를 사용해 중첩 Future를 피합니다.

예제의 첫 흐름은 독립 작업을 결합하고, 둘째 흐름은 앞 결과에 의존하는 새 비동기 작업을 연결합니다.

FutureComposition.java
import java.util.concurrent.CompletableFuture;

public class FutureComposition {
    public static void main(String[] args) {
        CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> 100);
        CompletableFuture<Integer> quantity = CompletableFuture.supplyAsync(() -> 3);

        int total = price.thenCombine(quantity, (p, q) -> p * q)
                .thenApply(value -> value - 20)
                .exceptionally(error -> 0)
                .join();
        System.out.println(total);

        String chained = CompletableFuture.completedFuture("25")
                .thenCompose(text -> CompletableFuture.supplyAsync(() -> Integer.parseInt(text)))
                .thenApply(value -> "JDK " + value)
                .join();
        System.out.println(chained);
    }
}

가격 100과 수량 3은 서로 독립적으로 시작되고 둘 다 끝난 뒤 300으로 결합됩니다. 이어진 할인 변환이 20을 빼 280을 만듭니다. 두 번째 흐름은 문자열 파싱 작업이 Future를 반환하므로 compose로 한 단계 결과에 평탄화합니다.

280
JDK 25

독립된 가격과 수량을 동시에 시작해 결합하고, 두 번째 파이프라인은 비동기 결과를 compose로 평탄화합니다.

독립 작업을 Future로 시작

기본 공용 풀 또는 명시한 Executor에서 Supplier를 실행합니다.

의존 관계에 따라 apply·compose·combine

일반 값 변환은 apply, 비동기 작업을 반환하는 함수 연결은 compose를 사용해 중첩 Future를 피합니다.

예외 정책 뒤 최종 결과 join

서로 의존하지 않는 두 작업을 동시에 시작하고 둘 다 완료된 뒤 결과를 결합합니다.


thenCombine은 독립 결과 둘을 합친다

서로 의존하지 않는 두 작업을 동시에 시작하고 둘 다 완료된 뒤 결과를 결합합니다.

실패한 Future를 복구하는 handle과 결과를 바꾸지 않고 관찰하는 whenComplete를 이어서 차이를 확인합니다.

비교 1
CompletableFuture<String> failed = CompletableFuture.failedFuture(
        new IllegalStateException("network"));
비교 2
String recovered = failed
        .handle((value, error) -> error == null ? value : "fallback")
        .whenComplete((value, error) -> System.out.println("observed=" + value))
        .join();
비교 3
// thenApply(fnReturningFuture)는 Future<Future<T>>가 될 수 있음
// thenCompose는 비동기 결과를 한 단계로 평탄화

예외도 파이프라인에서 처리한다

exceptionally는 대체값, handle은 성공·실패 모두 변환, whenComplete는 결과를 유지하며 관찰하는 데 적합합니다.


비동기 예외도 결과 경로의 일부다

작업 예외는 Future.get이나 join에서 감싸져 전달됩니다.

exceptionally, handle, whenComplete의 복구와 관찰 차이를 구분합니다.

두 번째 입력 "25""bad"로 바꾸고 exceptionallyjoin 앞에 붙여 보세요. NumberFormatException이 비동기 단계의 실패가 되고 복구값을 주면 이후 단계가 다시 정상 값으로 이어집니다. 복구 위치를 앞뒤로 옮기며 어느 단계까지 건너뛰는지 확인하세요.

join은 실패를 CompletionException, get은 checked ExecutionException으로 감쌉니다. 업무 경계에서는 이 래퍼 안의 cause를 읽어 적절한 오류로 번역해야 합니다. orTimeout으로 결과를 시간 초과시켜도 이미 시작한 외부 호출이 자동 중단된다고 가정하지 말고, 클라이언트 자체의 timeout과 취소 정책도 함께 설정합니다.

의존 작업을 thenCombine으로 묶으면 안 됩니다. 두 번째 작업에 첫 번째 결과가 필요하다면 첫 결과 뒤 thenCompose로 시작해야 합니다. 반대로 독립 작업을 compose로 직렬화하면 오류는 없지만 불필요하게 전체 지연이 합산됩니다.


CompletableFuture는 콜백 수를 줄이는 도구보다 값과 예외의 의존 관계를 하나의 비동기 그래프로 표현하는 도구입니다.