CompletableFuture 심화 — 비동기 조합과 에러 핸들링 패턴
사용자 정보, 주문 내역, 추천 상품을 동시에 조회하고 결과를 합쳐야 한다면? 순차적으로 하면 3배 느리고, 콜백 헬에 빠지고 싶지도 않습니다.
이게 뭔가요?
CompletableFuture 는 자바의 비동기 프로그래밍 API로, 비동기 작업의 결과를 조합하고 변환하는 풍부한 메서드를 제공합니다. Future의 한계(결과를 기다리는 것밖에 못함)를 완전히 해결합니다.
기본 생성
// 비동기 작업 시작
CompletableFuture<User> future = CompletableFuture.supplyAsync(() -> {
return userService.findById(userId); // 별도 스레드에서 실행
});
// 반환값이 없는 비동기 작업
CompletableFuture<Void> voidFuture = CompletableFuture.runAsync(() -> {
emailService.sendWelcome(userId);
});
변환: thenApply vs thenCompose
// thenApply — 동기 변환 (map과 유사)
CompletableFuture<String> nameFuture = future
.thenApply(user -> user.getName()); // User → String
// thenCompose — 비동기 변환 (flatMap과 유사)
CompletableFuture<List<Order>> ordersFuture = future
.thenCompose(user -> orderService.findByUserIdAsync(user.getId()));
// User → CompletableFuture<List<Order>> (중첩 방지)
thenApply vs thenCompose 의 차이:
thenApply(f): f가 값을 반환 →CompletableFuture<결과>thenCompose(f): f가 CompletableFuture를 반환 → 평탄화 (중첩 방지)
조합: thenCombine, allOf, anyOf
thenCombine — 두 작업 합치기
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(() -> userService.findById(userId));
CompletableFuture<List<Order>> ordersFuture =
CompletableFuture.supplyAsync(() -> orderService.findByUserId(userId));
// 두 결과를 합침
CompletableFuture<UserDashboard> dashboard = userFuture
.thenCombine(ordersFuture, (user, orders) ->
new UserDashboard(user, orders));
allOf — 모든 작업 완료 대기
CompletableFuture<User> f1 = supplyAsync(() -> getUser(id));
CompletableFuture<List<Order>> f2 = supplyAsync(() -> getOrders(id));
CompletableFuture<List<Product>> f3 = supplyAsync(() -> getRecommendations(id));
CompletableFuture.allOf(f1, f2, f3)
.thenApply(v -> {
// 모든 작업이 완료된 후 결과 조합
User user = f1.join();
List<Order> orders = f2.join();
List<Product> recs = f3.join();
return new PageData(user, orders, recs);
});
anyOf — 가장 빠른 작업 하나
// 여러 서버에 동시에 요청, 가장 빠른 응답 사용
CompletableFuture<Object> fastest = CompletableFuture.anyOf(
supplyAsync(() -> serverA.getData()),
supplyAsync(() -> serverB.getData()),
supplyAsync(() -> serverC.getData())
);
에러 핸들링
exceptionally — 예외 시 기본값
CompletableFuture<User> future = supplyAsync(() -> userService.findById(id))
.exceptionally(ex -> {
log.error("사용자 조회 실패", ex);
return User.anonymous(); // 폴백 반환
});
handle — 성공/실패 모두 처리
CompletableFuture<Result> future = supplyAsync(() -> riskyOperation())
.handle((result, ex) -> {
if (ex != null) {
log.error("실패", ex);
return Result.failure(ex.getMessage());
}
return Result.success(result);
});
whenComplete — 결과를 변경하지 않고 부수효과만
CompletableFuture<User> future = supplyAsync(() -> userService.findById(id))
.whenComplete((user, ex) -> {
if (ex != null) log.error("에러 발생", ex);
else log.info("사용자 조회 성공: {}", user.getName());
// 결과값은 변경하지 않음
});
Async 변형
모든 메서드에 Async 접미사 버전이 있습니다.
// thenApply — 이전 작업과 같은 스레드에서 실행
future.thenApply(user -> transform(user));
// thenApplyAsync — ForkJoinPool의 새 스레드에서 실행
future.thenApplyAsync(user -> transform(user));
// thenApplyAsync — 지정한 Executor에서 실행
future.thenApplyAsync(user -> transform(user), customExecutor);
타임아웃 (Java 9+)
CompletableFuture<User> future = supplyAsync(() -> slowApiCall())
.orTimeout(3, TimeUnit.SECONDS) // 3초 초과 시 TimeoutException
.completeOnTimeout(User.anonymous(), 3, TimeUnit.SECONDS); // 3초 초과 시 기본값
실전 패턴: 병렬 조회 후 합치기
public UserPageData loadUserPage(Long userId) {
var userF = supplyAsync(() -> userService.findById(userId));
var ordersF = supplyAsync(() -> orderService.recent(userId, 5));
var pointsF = supplyAsync(() -> pointService.getBalance(userId));
var notifF = supplyAsync(() -> notificationService.unread(userId));
return CompletableFuture.allOf(userF, ordersF, pointsF, notifF)
.thenApply(v -> new UserPageData(
userF.join(), ordersF.join(),
pointsF.join(), notifF.join()))
.join(); // 최종 블로킹
}
순차 실행 시 4초 걸릴 작업이 병렬로 1초 내에 완료됩니다.
자주 헷갈리는 포인트
- join() vs get(): 둘 다 결과를 블로킹으로 가져오지만,
get()은 체크 예외를 던지고join()은 언체크 예외를 던집니다.join()이 더 편합니다. - 스레드 풀 주의:
supplyAsync()의 기본 스레드 풀은ForkJoinPool.commonPool()입니다. I/O 작업이 많으면 커스텀 Executor를 사용하세요. - 예외 전파: 체인의 중간에서 예외가 발생하면 이후
thenApply는 건너뛰고exceptionally나handle에서 잡힙니다. - allOf의 반환 타입:
allOf는CompletableFuture<Void>를 반환합니다. 개별 결과는 각 future의join()으로 가져와야 합니다.
정리
| 메서드 | 역할 |
|---|---|
| thenApply | 동기 변환 (map) |
| thenCompose | 비동기 변환 (flatMap) |
| thenCombine | 두 결과 합치기 |
| allOf | 모든 작업 완료 대기 |
| anyOf | 가장 빠른 작업 하나 |
| exceptionally | 예외 시 폴백 |
| handle | 성공/실패 모두 처리 |
| orTimeout | 타임아웃 (Java 9+) |