오늘은 Java 비동기 프로그래밍에 사용되는 Future와 Callback 패턴을 자세하게 뜯어보면서 실행 과정을 익혀보려고 합니다.
먼저 비동기와 동기의 차이에 대해 먼저 정리하고, Future와 Callback이 어떻게 비동기 처리를 하는지 살펴봅시다.
동기는 작업이 하나씩 순차적으로 실행되는 방식을 말합니다. 반드시 한 번에 하나의 작업만을 실행하고 작업과 작업 사이가 강하게 연결되어 있으므로 작업이 실행 중인 동안 다른 작업을 실행할 수 없습니다.
따라서 다음 작업은 실행중인 작업의 결과에 관심을 가지고 끝나기를 기다립니다. 즉, 작업이 완료될 때까지 실행 흐름을 멈추는 Blocking 특성을 가집니다.

한 번에 하나의 작업을 실행하고 그 작업이 끝난 뒤에야 다음 작업을 시작하기 때문에 작업자가 한 명일 경우에 동기가 성립합니다.
작업자가 한 명이 아닌 경우는 작업을 독립적으로 동시에 처리하기 때문에 동기가 깨지게 됩니다.
비동기는 작업이 독립적으로 실행되는 방식을 말합니다. 작업이 실행 중이어도 다른 작업을 시작할 수 있으며, 작업 결과를 기다리지 않고 다른 일을 처리합니다.
특정 작업이 진행 중일 때도 다른 작업이 계속 실행되므로 NonBlocking 특성을 가진다.

주로 I/O처럼 시간이 오래 걸리는 작업에 유용하고, 다수의 작업을 동시에 처리하거나 빠른 응답 시간이 보장되어야 할 경우에 사용합니다.
비동기로 작업을 요청해두고, 그 작업 결과에 관심을 가지는 경우 작업의 결과를 기다리는 동안 대기하는 동기(Blocking) 상황이 발생할 수 있습니다.

Java 비동기 프로그래밍에서 사용되는 패턴으로 비동기 작업이 완료되었을 때 수행할 동작을 정의한 인터페이스 또는 클래스입니다. 비동기 작업 실행 중에 블로킹되지 않고 비동기 작업이 완료되면 콜백을 호출합니다.

테스트 코드로 과정을 살펴봅시다. 먼저 Callback 인터페이스를 정의하고 구현체를 생성해줍니다.
interface Callback {
void onComplete(int result);
}
static class MyCallback implements Callback {
private int result;
@Override
public void onComplete(int result) {
this.result = result;
}
public int getResult() {
return result;
}
}
이제 1개 크기의 스레드 풀을 생성한 뒤 비동기 작업을 실행하고, callback을 통해 작업의 결과를 알립니다.
// given
ExecutorService executor = Executors.newFixedThreadPool(1);
MyCallback callback = new MyCallback();
CountDownLatch latch = new CountDownLatch(1); // 작업이 끝날 때까지 대기하는 Latch
// when
executor.execute(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
int result = 42;
callback.onComplete(result);
latch.countDown(); // 작업이 완료되면 Latch 카운트를 줄임
});
// then
latch.await(); // Latch가 0이 될 때까지 대기 (즉, 비동기 작업이 끝날 때까지 대기)
assertEquals(42, callback.getResult(), "비동기 작업 결과가 예상과 다릅니다.");
여기서 CountDownLatch는 비동기 작업이 완료될 때까지 메인 스레드를 대기시키는 데 사용됩니다. 이를 통해 비동기 작업의 완료를 기다린 후, 테스트가 정상적으로 실행될 수 있게 해줍니다.
Callback 패턴을 사용하는 이유는 주로 비동기 작업의 완료 후에 해야 할 일을 나중에 정의하기 위해서입니다. 보통 비동기 작업은 I/O 작업과 같이 오래 걸리는 작업으로 예측할 수 없는 시간에 완료됩니다.
이 때 Callback 패턴을 사용하면 비동기 작업이 완료되었을 때 실행되어야 할 동작을 나중에 정의할 수 있도록 해줍니다. 또한, 비동기 작업이 끝나면 메인 스레드는 Callback을 통해 알림을 받을 수 있기 때문에 비동기 작업의 완료를 기다릴 필요가 없이 다른 일을 할 수 있습니다.
예를 들어 큰 용량의 파일을 다운로드 후 처리해야하는 기능을 구현한다고 생각해봅시다.
큰 용량의 파일을 다운로드하는 작업은 매우 오래 걸리기 때문에 보통 비동기 로직으로 처리합니다. 이런 경우, 메인 스레드를 차단하지 않고 다운로드 작업이 백그라운드에서 실행되도록 하고, 작업이 완료되면 다운로드한 파일을 처리하는 후속 작업을 수행해야 합니다.
Callback 패턴은 이러한 비동기 작업에서 매우 유용합니다. 메인 스레드는 계속해서 다른 작업을 수행하고, 파일이 다운로드되면 Callback을 통해 그 사실을 알림받아 이후 처리를 진행할 수 있습니다. 비동기적으로 실행되기 때문에 사용자 경험을 개선하거나 시스템 리소스를 효율적으로 사용할 수 있죠.
마찬가지로 Callback 인터페이스를 정의하고, 후속 처리 작업을 정의한 Callback 구현체를 만들어줍시다.
interface FileDownloadCallback {
void onDownloadComplete(File file);
void onDownloadFailed(String error);
}
class FileDownloadCallbackImpl implements FileDownloadCallback {
@Override
public void onDownloadComplete(File file) {
System.out.println("파일 다운로드 완료: " + file.getName());
// 다운로드된 파일로 비즈니스 로직 처리 ..
}
@Override
public void onDownloadFailed(String error) {
System.out.println("파일 다운로드 실패: " + error);
// 실패 시 로직 처리 ..
}
}
이 콜백을 활용한 파일 다운로드 서비스는 다음과 같이 구현할 수 있습니다. 파일 다운로드가 2초 걸린다고 가정하고 파일 다운로드 성공/실패 여부에 따라 다른 callback 로직이 호출되도록 구현했습니다.
// 파일 다운로드 서비스
class FileDownloadService {
// 비동기적으로 파일 다운로드
public void downloadFile(String fileUrl, FileDownloadCallback callback) {
ExecutorService executor = Executors.newFixedThreadPool(1);
executor.execute(() -> {
try {
// 파일 다운로드 시뮬레이션 (2초 걸림)
Thread.sleep(2000);
File file = new File("downloaded_file.txt");
// 파일 생성 시뮬레이션
if (!file.exists()) Files.createFile(file.toPath());
// 성공 시 콜백 호출
callback.onDownloadComplete(file);
} catch (InterruptedException | IOException e) {
// 실패 시 콜백 호출
callback.onDownloadFailed("파일 다운로드 실패: " + e.getMessage());
}
});
executor.shutdown();
}
}
// Main
public class FileDownloadExample {
public static void main(String[] args) {
FileDownloadService downloadService = new FileDownloadService();
FileDownloadCallbackImpl callback = new FileDownloadCallbackImpl();
// 파일 다운로드 요청 및 콜백 처리
downloadService.downloadFile("example/file.txt", callback);
}
}
이처럼 Callback 패턴은 비동기 작업이 완료된 후의 동작을 정의할 때 매우 유용하며, 작업 완료 후 어떤 일이 일어날지를 유연하게 정의할 수 있습니다. 이로 인해 코드가 더 명확해지고, 응답성이 중요한 시스템에서 특히 효과적으로 사용될 수 있습니다.
비동기 작업을 제출한 후, 바로 결과를 받을 수 없으므로 Future 객체를 사용하여 결과가 준비될 때까지 기다리거나, 필요할 때 결과를 얻을 수 있습니다. Future 패턴은 병렬 프로그래밍이나 비동기 작업에서 매우 유용하게 사용됩니다.

submit()을 통해 Callable 인터페이스를 통해 결과를 반환하는 task를 실행합니다. 그 후 Future의 get()을 사용하면 비동기 작업이 완료될 때까지 메인 스레드는 블로킹된 후 결과를 얻습니다.
public class FutureTest {
@Test
void Future_예제() throws ExecutionException, InterruptedException {
// given
ExecutorService executor = Executors.newFixedThreadPool(1);
// when
Future<Integer> future = executor.submit(() -> {
try {
Thread.sleep(1000);
System.out.println("비동기 작업 중..");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return 42;
});
System.out.println("비동기 작업 시작");
// then
int result = future.get();
assertEquals(42, result, "비동기 작업 결과가 예상과 달라요.");
executor.shutdown();
}
}

Future는 Java 비동기 프로그래밍에서 중요한 역할을 하는 인터페이스이므로 내부적으로 어떻게 작동하는지 자세하게 뜯어봅시다.

Future는 비동기 작업을 수행할 때 결과를 기다리거나, 작업이 완료되었는지 여부를 확인하는 등의 작업에 유용합니다. 특히, 비동기 작업이 오래 걸리는 네트워크 요청이나 파일 입출력 작업에서 사용됩니다.
하지만 Future는 여러 작업을 조합하는 문제, 예외 처리 등의 어려움이 있습니다. 이런 단점을 보완하기 위해 Java8부터 CompletableFuture가 도입되었는데요. 비동기 프로그래밍에서 매우매우 중요한 개념으로 다음 포스트에서 자세하게 뜯어봅시다!


cancel()이 호출되면 작업과 연관된 모든 대기 중인 스레드를 깨우고 시그널을 보내며 done()을 호출하고 callable을 null로 설정합니다.
또한, cancel()은 작업이 현재 취소되었는지 여부를 반드시 나타내지는 않기 때문에 정확한 취소 여부는 isCancelled()를 사용해야 합니다.

Callable은 자바에서 비동기 작업을 수행하기 위한 함수형 인터페이스입니다. Runnable과 비슷하지만 차이점은 결과를 반환할 수 있으며, 예외를 던질 수 있다는 점입니다. call() 메서드를 구현해 비동기 작업을 정의하고 작업의 결과를 반환하는데요. 비동기 작업이 끝났을 때 그 결과를 가져오는 역할을 하는 인터페이스가 바로 Future입니다.

따라서 ExecutorService의 submit() 메서드를 통해 Callable을 받아 작업을 실행한 뒤 Future를 반환하는 구조입니다.
FutureTask는 Callable을 비동기 작업으로 실행하고 그 결과를 관리하는 역할을 담당하는 객체입니다. Future와 Runnable을 구현한 실행 가능한 Task로, Callable을 감싸서 비동기 작업을 관리합니다.

즉, FutureTask로 Callable을 실행할 수 있을 뿐만 아니라, 그 결과를 안전하게 저장하고 나중에 가져올 수 있도록 해줍니다.


FutureTask가 가능한 상태 전환은 4가지입니다.
NEW → COMPLETING → NORMALNEW → COMPLETING → EXCEPTIONALNEW → INTERRUPTING → INTERRUPTEDNEW → CANCELLED


결국 awaitDone()을 통해 3가지 상태 중 하나일 때까지 스레드를 대기시키는 것입니다.



mayInterruptIfRunning이 true더라도 Callable에서 실행하는 작업이 인터럽트에 반응하지 않는다면, 즉 sleep이나 wait 작업이 없다면 작업이 중단되지 않고 계속 진행합니다.
살펴본 것처럼 Future는 비동기 작업의 결과를 받을 수 있도록 하고, Callback은 작업 완료 후에 해야할 동작을 미리 정의할 수 있도록 합니다.
하지만 Future와 Callback은 복잡한 비동기 작업을 관리하기 어렵다는 단점이 있습니다. 여러 비동기 작업을 조합하거나 예외 처리 및 결과 핸들링이 까다롭습니다.
이를 위해 Java8부터는 CompletableFuture가 도입되었는데요! 다음 글에서는 Future의 확장판인 CompletableFuture를 자세히 뜯어보는 글로 돌아오겠습니다!
감사합니다 😃