본문 바로가기

Language/Java

[Java] Java 비동기 결과 처리의 진화: Callable → Future → CompletableFuture

이전 포스트에서 Java 동시성 구현 메커니즘(Thread / ExecutorService / ForkJoinPool / VirtualThread)을 살펴보았습니다.
이번 포스트에서는 비동기 작업의 결과를 어떻게 받고 조합하는가에 초점을 맞춥니다.

 


들어가며: 비동기란 무엇인가?

비동기(Asynchronous)란 작업을 요청하고, 그 결과를 기다리지 않고 다음 코드를 실행하는 방식입니다.

DB 조회, 네트워크 요청처럼 오래 걸리는 작업을 기다리는 동안 메인 스레드가 멈춰 있으면 낭비입니다. 그 시간에 다른 일을 하는 것이 비동기의 핵심입니다.

 

 

동기는 메인 스레드가 작업이 끝날 때까지 멈춰서 기다립니다. 비동기는 별도 스레드에서 작업을 돌리고, 메인 스레드는 계속 다른 일을 합니다. 완료되면 콜백으로 결과를 받습니다.

 

Java는 Java 1.0부터 Thread로 비동기 실행이 가능했습니다. 문제는 비동기 실행 자체가 없어서가 아니라, 결과를 받고 조합하는 방법이 불편했기 때문에 API가 계속 발전했습니다.

 


전체 진화 흐름

 


1. Runnable과 Callable — 작업을 정의하는 인터페이스

⚠️ Runnable과 Callable은 비동기 API가 아닙니다.

이 둘은 "실행할 코드 덩어리를 정의하는 인터페이스"일 뿐입니다.
비동기냐 동기냐는 이 작업을 누가, 어떻게 실행하느냐에 달려 있습니다.

Runnable task = () -> System.out.println("실행!");

task.run();               // 동기 실행 (지금 여기서 그냥 실행)
new Thread(task).start(); // 비동기 실행 (별도 스레드에서 실행)

같은 Runnable이지만, 실행 방식에 따라 동기도 되고 비동기도 됩니다.

 


Runnable (Java 1.0, 1996)

public interface Runnable {
    void run(); // 반환값 없음, Checked Exception 불가
}

 

 

 

Thread에 넘겨서 별도 스레드에서 실행할 수 있지만, 두 가지 한계가 있습니다.

  • 결과를 돌려받을 방법이 없습니다.
  • Checked Exception을 던질 수 없습니다. 예외를 내부에서 try-catch로 직접 삼켜야 합니다.
Runnable r = () -> {
    try {
        Thread.sleep(1000); // Checked Exception → 반드시 내부에서 처리해야 함
    } catch (InterruptedException e) {
        e.printStackTrace(); // 여기서 삼켜버려야 함, 밖으로 못 던짐
    }
};

 


Callable (Java 5, 2004)

Runnable의 두 가지 한계를 모두 해결하기 위해 등장했습니다.

public interface Callable<V> {
    V call() throws Exception; // 반환값 있음, Checked Exception 가능
}
// Runnable — 결과 없음, 예외 못 던짐
Runnable r = () -> {
    try { Thread.sleep(1000); }
    catch (InterruptedException e) { e.printStackTrace(); }
};

// Callable — 결과 반환 가능, 예외도 그냥 던지면 됨
Callable<String> c = () -> {
    Thread.sleep(1000); // throws Exception이라 try-catch 불필요
    return "완료";
};

 

CallableExecutorService에 제출하면, 내부에서 발생한 예외는 future.get() 호출 시 ExecutionException으로 감싸져서 전달됩니다.

Future<String> future = executor.submit(() -> {
    throw new IOException("DB 연결 실패");
});

try {
    future.get();
} catch (ExecutionException e) {
    Throwable cause = e.getCause(); // 원래 IOException을 꺼낼 수 있음
}

단, CallableThread에 직접 넘길 수 없고 ExecutorService와 함께 써야 합니다. 그리고 그 결과를 받을 그릇이 필요한데, 그게 바로 Future입니다.

 


2. Thread — 결과를 받던 시절의 불편함

Future 이전에도 Thread를 직접 쓰면 비동기 실행은 가능했습니다.

Thread thread = new Thread(() -> {
    System.out.println("별도 스레드에서 실행!"); // 비동기로 돌아감
});
thread.start(); // 비동기 시작, 메인 스레드는 바로 다음으로
System.out.println("메인 스레드는 기다리지 않음");

문제는 결과를 받는 방법이 없었다는 것입니다. 억지로 받으려면 이런 꼼수를 써야 했습니다.

String[] result = {null}; // 공유 변수 꼼수

Thread thread = new Thread(() -> {
    result[0] = "계산 결과"; // 별도 스레드에서 저장
});
thread.start();
thread.join(); // 끝날 때까지 블로킹

System.out.println(result[0]); // 그제야 결과 사용 가능

공유 변수 + join()으로 억지로 해결해야 했고, 스레드 동기화 문제도 생기고 코드도 지저분했습니다.

 


3. Future (Java 5, 2004) — 결과를 받는 표준 방법

Thread로 비동기 실행은 됐지만, 결과를 받는 표준적인 방법이 없었습니다. Future"나중에 결과가 담길 약속 표" 로, 이 문제를 해결했습니다.

 

CallableExecutorService에 제출하면 Future를 즉시 돌려줍니다.

ExecutorService executor = Executors.newFixedThreadPool(2);

Future<String> future = executor.submit(() -> {
    Thread.sleep(1000); // 오래 걸리는 작업
    return "작업 완료!";
}); // 즉시 반환 (비동기 시작)

// ... 다른 작업 수행 가능 ...

String result = future.get(); // 결과가 나올 때까지 대기 (블로킹)
System.out.println(result);

주요 메서드

메서드 설명
get() 결과를 블로킹으로 대기
get(timeout, unit) 타임아웃 설정 후 대기
isDone() 완료 여부 확인
isCancelled() 취소 여부 확인
cancel(mayInterrupt) 작업 취소

 

한계

결과를 받는 방법은 생겼지만 여전히 문제가 있었습니다.

문제 설명
블로킹 get() 호출 시 결과가 나올 때까지 스레드가 멈춤
조합 불가 여러 Future를 체이닝하거나 조합하는 API 없음
콜백 없음 완료됐을 때 자동으로 다음 작업을 실행하는 기능 없음
예외 처리 불편 예외가 ExecutionException으로 감싸져서 처리가 복잡
수동 완료 불가 외부에서 결과를 직접 주입할 수 없음

 

// 비동기로 시작했지만, get()에서 결국 동기처럼 기다림
Future<String> f1 = executor.submit(task1);
Future<String> f2 = executor.submit(task2);

String r1 = f1.get(); // 여기서 블로킹 → f2가 먼저 끝나도 기다려야 함
String r2 = f2.get(); // 여기서 블로킹

비동기로 시작했지만 get()을 호출하는 순간 결국 동기처럼 기다려야 했습니다. 반쪽짜리 비동기였습니다.

 


4. CompletableFuture (Java 8, 2014) — 진짜 비동기

Future의 모든 한계를 극복하기 위해 Java 8에서 등장했습니다. CompletableFuture는 두 인터페이스를 구현합니다.

  • Future<T> → 기존 호환성 유지
  • CompletionStage<T> → 작업을 체이닝/조합하는 풍부한 API

get()을 호출하지 않아도, 작업이 완료되는 순간 자동으로 다음 단계가 실행됩니다.

CompletableFuture.supplyAsync(() -> "Hello")
    .thenApply(s -> s + " World")            // 완료되면 자동으로 다음 실행 (논블로킹!)
    .thenAccept(s -> System.out.println(s)); // 결과 소비

 


핵심 기능 1 — 체이닝 (thenApply / thenAccept / thenRun)

CompletableFuture.supplyAsync(() -> "Hello")
    .thenApply(s -> s + " World")               // 결과 변환 (반환 있음)
    .thenAccept(s -> System.out.println(s))     // 결과 소비 (반환 없음)
    .thenRun(() -> System.out.println("완료"));  // 결과 무관하게 실행

 


핵심 기능 2 — 비동기 체이닝 (thenCompose)

thenApply는 동기 함수를 적용하고, thenCompose는 비동기 함수를 적용할 때 사용합니다. StreammapflatMap의 관계와 동일합니다.

// thenApply → 반환값이 일반 값일 때
CompletableFuture<String> f = future.thenApply(data -> data.toUpperCase());

// thenCompose → 반환값이 CompletableFuture일 때 (중첩 방지)
CompletableFuture<String> f = future.thenCompose(data -> fetchMoreDataAsync(data));

thenApplyCompletableFuture를 반환하는 함수를 넘기면 CompletableFuture<CompletableFuture<String>>이 되어 이중으로 감싸집니다. thenCompose는 이 중첩을 한 꺼풀 벗겨줍니다.

 


핵심 기능 3 — 두 작업 조합 (thenCombine)

CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> fetchUser());
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> fetchOrder());

// 두 작업이 모두 완료되면 결합
f1.thenCombine(f2, (user, order) -> user + " ordered " + order)
  .thenAccept(System.out::println);

 


핵심 기능 4 — 여러 작업 동시 실행 (allOf / anyOf)

CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> fetchFromDB());
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> fetchFromAPI());
CompletableFuture<String> f3 = CompletableFuture.supplyAsync(() -> fetchFromCache());

// 셋 다 완료될 때까지 대기
CompletableFuture.allOf(f1, f2, f3)
    .thenRun(() -> System.out.println("모두 완료!"));

// 하나라도 완료되면 실행
CompletableFuture.anyOf(f1, f2, f3)
    .thenAccept(result -> System.out.println("첫 완료: " + result));

 


핵심 기능 5 — 예외 처리 (exceptionally / handle)

CompletableFuture.supplyAsync(() -> {
        if (Math.random() > 0.5) throw new RuntimeException("실패!");
        return "성공";
    })
    .exceptionally(ex -> "기본값: " + ex.getMessage()) // 예외 시 대체값 반환
    .thenAccept(System.out::println);
메서드 설명
exceptionally(Function) 예외 발생 시 대체 값 반환
handle(BiFunction) 성공/실패 모두 처리 가능
whenComplete(BiConsumer) 결과와 무관하게 후처리 (결과 변경 불가)

 


핵심 기능 6 — 수동으로 결과 주입 (complete)

CompletableFuture<String> cf = new CompletableFuture<>();
cf.complete("외부에서 주입한 결과");                          // 외부에서 직접 완료
cf.completeExceptionally(new RuntimeException("강제 실패")); // 예외로 완료

5. 전체 비교 정리

  Thread Runnable / Callable Future CompletableFuture
도입 Java 1.0 Java 1.0 / Java 5 Java 5 Java 8
역할 비동기 실행 주체 작업 정의 인터페이스 결과를 받는 핸들 비동기 올인원
비동기 실행 - (실행 주체 아님)
결과 반환 ❌ (꼼수 필요) Callable만 ✅
예외 처리 불편 Callable ✅ ExecutionException으로 복잡 ✅ 간편
논블로킹 ❌ (join() 블로킹) - ❌ (get() 블로킹)
작업 체이닝
작업 조합

 


핵심 요약

Java의 비동기 결과 처리 API는 비동기 실행 능력이 없어서가 아니라, 결과 처리와 작업 조합이 불편해서 발전한 과정입니다.

시기 추가된 것 해결한 문제
Java 1.0 Thread 비동기 실행 자체는 됨. 하지만 결과를 받을 방법이 없음
Java 5 Callable 결과 반환 + Checked Exception 처리 가능
Java 5 Future 결과를 받는 표준이 생김. 하지만 get()에서 결국 블로킹
Java 8 CompletableFuture 완료되면 자동으로 다음 실행 — 진짜 논블로킹 + 작업 조합

 

결론: 현대 Java에서 비동기 프로그래밍은 CompletableFuture가 사실상 표준입니다.
Spring WebFlux나 Virtual Thread(Java 21) 같은 최신 기술도 결국 이 개념 위에서 발전했습니다.