사용자 정보, 주문 내역, 추천 상품을 동시에 조회하고 결과를 합쳐야 한다면? 순차적으로 하면 3배 느리고, 콜백 헬에 빠지고 싶지도 않습니다.

이게 뭔가요?

CompletableFuture 는 자바의 비동기 프로그래밍 API로, 비동기 작업의 결과를 조합하고 변환하는 풍부한 메서드를 제공합니다. Future의 한계(결과를 기다리는 것밖에 못함)를 완전히 해결합니다.

기본 생성

JAVA
// 비동기 작업 시작
CompletableFuture<User> future = CompletableFuture.supplyAsync(() -> {
    return userService.findById(userId); // 별도 스레드에서 실행
});

// 반환값이 없는 비동기 작업
CompletableFuture<Void> voidFuture = CompletableFuture.runAsync(() -> {
    emailService.sendWelcome(userId);
});

변환: thenApply vs thenCompose

JAVA
// 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 — 두 작업 합치기

JAVA
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 — 모든 작업 완료 대기

JAVA
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 — 가장 빠른 작업 하나

JAVA
// 여러 서버에 동시에 요청, 가장 빠른 응답 사용
CompletableFuture<Object> fastest = CompletableFuture.anyOf(
    supplyAsync(() -> serverA.getData()),
    supplyAsync(() -> serverB.getData()),
    supplyAsync(() -> serverC.getData())
);

에러 핸들링

exceptionally — 예외 시 기본값

JAVA
CompletableFuture<User> future = supplyAsync(() -> userService.findById(id))
    .exceptionally(ex -> {
        log.error("사용자 조회 실패", ex);
        return User.anonymous(); // 폴백 반환
    });

handle — 성공/실패 모두 처리

JAVA
CompletableFuture<Result> future = supplyAsync(() -> riskyOperation())
    .handle((result, ex) -> {
        if (ex != null) {
            log.error("실패", ex);
            return Result.failure(ex.getMessage());
        }
        return Result.success(result);
    });

whenComplete — 결과를 변경하지 않고 부수효과만

JAVA
CompletableFuture<User> future = supplyAsync(() -> userService.findById(id))
    .whenComplete((user, ex) -> {
        if (ex != null) log.error("에러 발생", ex);
        else log.info("사용자 조회 성공: {}", user.getName());
        // 결과값은 변경하지 않음
    });

Async 변형

모든 메서드에 Async 접미사 버전이 있습니다.

JAVA
// thenApply — 이전 작업과 같은 스레드에서 실행
future.thenApply(user -> transform(user));

// thenApplyAsync — ForkJoinPool의 새 스레드에서 실행
future.thenApplyAsync(user -> transform(user));

// thenApplyAsync — 지정한 Executor에서 실행
future.thenApplyAsync(user -> transform(user), customExecutor);

타임아웃 (Java 9+)

JAVA
CompletableFuture<User> future = supplyAsync(() -> slowApiCall())
    .orTimeout(3, TimeUnit.SECONDS)          // 3초 초과 시 TimeoutException
    .completeOnTimeout(User.anonymous(), 3, TimeUnit.SECONDS); // 3초 초과 시 기본값

실전 패턴: 병렬 조회 후 합치기

JAVA
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는 건너뛰고 exceptionallyhandle에서 잡힙니다.
  • allOf의 반환 타입: allOfCompletableFuture<Void>를 반환합니다. 개별 결과는 각 future의 join()으로 가져와야 합니다.

정리

메서드역할
thenApply동기 변환 (map)
thenCompose비동기 변환 (flatMap)
thenCombine두 결과 합치기
allOf모든 작업 완료 대기
anyOf가장 빠른 작업 하나
exceptionally예외 시 폴백
handle성공/실패 모두 처리
orTimeout타임아웃 (Java 9+)

References

댓글 로딩 중...