1. CompletableFuture 결합하기
현실에서는 모든 것이 하나의 소스에서 오지 않습니다: 사용자 프로필과 주문은 병렬로 로드할 수 있고, 두 마이크로서비스의 데이터를 합칠 수 있으며, 때로는 가장 먼저 도착한 응답만 처리하고 싶을 때도 있습니다. 동기 방식은 순서대로 기다리게 만들지만, CompletableFuture는 모든 것을 동시에 시작하고 결과를 우아하게 결합할 수 있게 해줍니다. 이를 위한 전용 메서드인 thenCombine, allOf, anyOf를 제공합니다. 하나씩 살펴보겠습니다.
병렬 작업: 두 개의 비동기 요청
동기 방식이라면 다음과 같습니다:
String name = loadUserName(); // 오래 걸림
int balance = loadUserBalance(); // 오래 걸림
System.out.println("이름: " + name + ", 잔액: " + balance);
문제: 두 번째 호출은 첫 번째가 끝난 후에야 시작됩니다.
비동기 방식
CompletableFuture를 사용하면 두 작업을 동시에 시작할 수 있습니다:
CompletableFuture<String> nameFuture = CompletableFuture.supplyAsync(() -> loadUserName());
CompletableFuture<Integer> balanceFuture = CompletableFuture.supplyAsync(() -> loadUserBalance());
그렇다면 두 결과를 함께 받아 처리하려면 어떻게 할까요? 이를 위해 thenCombine이 쓰입니다.
2. thenCombine: 두 작업의 결과를 합치기
메서드 thenCombine은 두 개의 CompletableFuture를 결합하여 두 작업이 모두 완료되었을 때 동작을 수행합니다. 결합 결과를 담는 새로운 CompletableFuture를 반환합니다.
시그니처:
<A, B, C> CompletableFuture<C> thenCombine(
CompletionStage<? extends B> other,
BiFunction<? super A, ? super B, ? extends C> fn
)
- A — 첫 번째 future의 결과 타입,
- B — 두 번째 future의 결과 타입,
- C — 결합된 결과의 타입.
예시
CompletableFuture<String> nameFuture = CompletableFuture.supplyAsync(() -> loadUserName());
CompletableFuture<Integer> balanceFuture = CompletableFuture.supplyAsync(() -> loadUserBalance());
CompletableFuture<String> resultFuture = nameFuture.thenCombine(
balanceFuture,
(name, balance) -> "이름: " + name + ", 잔액: " + balance
);
resultFuture.thenAccept(System.out::println);
동작 방식:
- 두 future가 병렬로 시작됩니다.
- 둘 다 완료되는 즉시 함수 (name, balance) -> ...가 호출됩니다.
- 최종 future에는 결과 문자열이 들어 있습니다.
숫자로 보는 미니 예시
CompletableFuture<Integer> f1 = CompletableFuture.supplyAsync(() -> 2);
CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(() -> 3);
CompletableFuture<Integer> sum = f1.thenCombine(f2, Integer::sum);
sum.thenAccept(result -> System.out.println("합계: " + result));
출력:
합계: 5
비동기 버전
결합이 무거운 작업이라면 thenCombineAsync를 사용하세요:
f1.thenCombineAsync(f2, (a, b) -> a * b);
3. allOf: 작업이 많을 때
두 개가 아니라 열 개의 작업이 있다면 어떻게 할까요? 예를 들어, 10명의 사용자 데이터를 동시에 로드하고 싶을 수 있습니다. 이를 위해 CompletableFuture.allOf가 있습니다.
설명
CompletableFuture.allOf(f1, f2, ..., fn)은 전달된 모든 작업이 완료되면 완료되는 새로운 future를 반환합니다. 하지만 주의할 점이 있습니다. 이 future는 결과를 담지 않으며 — 타입은 항상 CompletableFuture<Void>입니다. 결과를 얻으려면 원래 future들에서 따로 값을 «꺼내야» 합니다.
예시
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "첫 번째");
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "두 번째");
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2);
all.thenRun(() -> {
// 모든 작업이 완료됨!
String s1 = f1.join(); // join() — get()와 유사하지만 unchecked exception을 던짐
String s2 = f2.join();
System.out.println(s1 + " & " + s2);
});
출력:
첫 번째 & 두 번째
여러 작업 배열 예시
List<CompletableFuture<String>> futures = new ArrayList<>();
for (int i = 0; i < 5; i++) {
int id = i;
futures.add(CompletableFuture.supplyAsync(() -> "사용자 " + id));
}
CompletableFuture<Void> all = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
all.thenRun(() -> {
for (CompletableFuture<String> f : futures) {
System.out.println(f.join());
}
});
작동 방식:
- 모든 작업이 병렬로 시작됩니다.
- allOf는 모든 작업이 완료되면 완료됩니다.
- thenRun 블록에서 join()으로 결과를 가져올 수 있습니다.
시각적 다이어그램
[Future1] \
[Future2] ----> [allOf] ---> thenRun
[Future3] /
4. anyOf: 가장 먼저 완료된 작업을 기다리기
때로는 모두를 기다리는 대신 가장 빠른 작업의 결과만 받으면 됩니다. 예를 들어, 두 서버에 데이터를 요청하고 — 먼저 응답한 쪽의 데이터를 사용하는 경우입니다. 이를 위해 CompletableFuture.anyOf가 있습니다.
설명
CompletableFuture.anyOf(f1, f2, ..., fn)은 전달된 작업 중 하나라도 완료되면 완료되는 future를 반환합니다. 결과 타입은 작업들의 타입이 서로 다를 수 있으므로 CompletableFuture<Object>입니다.
예시
CompletableFuture<String> fast = CompletableFuture.supplyAsync(() -> {
sleep(500);
return "빠른 서버";
});
CompletableFuture<String> slow = CompletableFuture.supplyAsync(() -> {
sleep(2000);
return "느린 서버";
});
CompletableFuture<Object> any = CompletableFuture.anyOf(fast, slow);
any.thenAccept(result -> System.out.println("받은 값: " + result));
출력:
받은 값: 빠른 서버
서로 다른 타입 예시
서로 다른 타입의 작업을 조합할 수 있지만, 이 경우 결과 타입은 Object가 되며 명시적 캐스팅이 필요합니다.
5. 유용한 팁
권장사항
- allOf는 결과 배열을 반환하지 않습니다. 값을 얻으려면 원래 future를 보관해 두고 join() 또는 get()으로 꺼내야 합니다.
- anyOf는 가장 먼저 완료된 결과를 반환하지만 타입은 Object입니다. 모든 작업의 타입이 같다면 캐스팅하면 됩니다.
- allOf에 포함된 작업 중 하나라도 오류로 끝나면 최종 future도 오류로 완료됩니다.
- thenCombine에서는 두 작업이 모두 성공적으로 완료되어야 하며, 그렇지 않으면 예외가 발생합니다.
메서드 비교 표
| 메서드 | 사용 시점 | 결과 타입 |
|---|---|---|
|
두 작업의 결과를 결합해야 할 때 | 결합 결과 |
|
모든 작업의 완료를 기다려야 할 때 | |
|
작업들 중 아무거나 하나가 끝나면 되는 경우 | Object (가장 먼저 완료된 작업의 결과) |
6. CompletableFuture 결합 시 자주 하는 실수
실수 1: 메인 스레드에서 get()/join()으로 결과를 기다림.
비동기 코드를 작성해 놓고 마지막에 결국 get()이나 join()을 호출하면 스레드를 블로킹하여 비동기의 장점이 사라집니다. 블로킹 없이 결과를 처리하려면 thenAccept/thenRun을 사용하세요.
실수 2: allOf를 사용할 때 원래 future의 참조를 보관하지 않음.
CompletableFuture.allOf(f1, f2, f3)를 호출해 놓고 f1, f2, f3를 저장하지 않으면 결과를 얻을 수 없습니다. allOf는 Void만 반환합니다!
실수 3: 체인에서 오류를 처리하지 않음.
작업 중 하나가 오류로 끝나면 전체 allOf나 thenCombine도 오류로 종료됩니다. 예외를 놓치지 않도록 오류 처리 메서드(exceptionally, handle, whenComplete)를 사용하세요.
실수 4: anyOf에서 타입 불일치.
anyOf는 Object를 반환합니다. future들의 타입이 서로 다르면 무엇이 먼저 왔는지에 따라 분기 처리와 캐스팅이 필요합니다. 가능하다면 같은 타입을 사용하는 것이 좋습니다.
실수 5: 주석 없는 과도하게 복잡한 체인.
코드가 많아지면 future 체인이 스파게티가 되기 쉽습니다. 체인을 적절히 변수로 분리하고 단계별로 주석을 다는 것을 주저하지 마세요.
GO TO FULL VERSION