콜백과 비동기 패턴 — 자바에서 나중에 불러줘를 구현하는 법
네트워크 요청을 보내고, 응답이 올 때까지 기다리는 동안 다른 일을 하고 싶다. 응답이 오면 "이 코드를 실행해줘"라고 미리 등록해둘 수 있을까?
이것이 콜백과 비동기 패턴의 핵심 아이디어입니다. 이 글에서는 콜백 인터페이스부터 Future, CompletableFuture, NIO의 CompletionHandler까지 — 자바에서 "나중에 불러줘"를 구현하는 방법이 어떻게 진화해왔는지 정리해볼게요.
TIP: 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
1. 콜백이란 — "나중에 불러줘"의 의미
** 콜백(Callback)은 다른 코드에게 넘겨주는 실행 가능한 코드 조각입니다.** "이 작업이 끝나면 이 코드를 실행해줘"라는 약속이에요.
콜백이라고 해서 무조건 비동기는 아닙니다. 두 종류를 구분해야 해요.
- ** 동기 콜백 **: 넘긴 그 자리에서 바로 실행됩니다.
Collections.sort()에Comparator를 넘기는 것이 대표적이에요. - ** 비동기 콜백 : 작업이 끝난 후 ** 나중에 다른 스레드에서 실행됩니다. 네트워크 요청 완료 후 결과를 처리하는 경우예요.
// 동기 콜백 — sort가 Comparator를 즉시 호출
List<String> names = new ArrayList<>(List.of("Charlie", "Alice", "Bob"));
Collections.sort(names, (a, b) -> a.compareTo(b));
// 비동기 콜백 — 작업 완료 후 나중에 호출
CompletableFuture.supplyAsync(() -> fetchFromApi())
.thenAccept(result -> System.out.println("나중에 실행: " + result));
콜백 = 비동기라고 생각하기 쉽지만,
Collections.sort()의Comparator처럼 동기 콜백도 분명히 존재한다.
2. 인터페이스로 콜백 구현
자바에는 함수를 직접 넘기는 문법이 없습니다. 대신 ** 인터페이스 **를 통해 콜백을 구현해요.
// 콜백 인터페이스 정의
public interface TaskCallback {
void onComplete(String result); // 성공 시 호출
void onError(Exception e); // 실패 시 호출
}
public class AsyncWorker {
// 콜백을 매개변수로 받아 비동기로 실행
public void doWork(TaskCallback callback) {
new Thread(() -> {
try {
Thread.sleep(1000); // 시간이 걸리는 작업
callback.onComplete("작업 완료!"); // 성공 콜백
} catch (Exception e) {
callback.onError(e); // 실패 콜백
}
}).start();
}
}
// 익명 클래스로 콜백 전달
AsyncWorker worker = new AsyncWorker();
worker.doWork(new TaskCallback() {
@Override
public void onComplete(String result) {
System.out.println("성공: " + result);
}
@Override
public void onError(Exception e) {
System.out.println("실패: " + e.getMessage());
}
});
System.out.println("doWork 호출 후 바로 실행 — 비동기!");
doWork()를 호출하면 내부에서 새 스레드가 생기고, 현재 스레드는 바로 다음 줄로 넘어갑니다. 이것이 비동기 콜백의 전형적인 구조예요.
3. 람다로 간결하게
익명 클래스는 코드가 장황합니다. 람다로 대체하려면 콜백 인터페이스가 ** 함수형 인터페이스 **(추상 메서드 1개)여야 해요.
@FunctionalInterface
public interface SimpleCallback {
void onComplete(String result);
}
// 익명 클래스 → 람다 → 메서드 레퍼런스 진화
worker.doSimpleWork(new SimpleCallback() {
@Override
public void onComplete(String result) {
System.out.println("결과: " + result);
}
});
worker.doSimpleWork(result -> System.out.println("결과: " + result));
worker.doSimpleWork(System.out::println);
직접 인터페이스를 만들지 않아도 java.util.function 패키지에 표준 함수형 인터페이스가 있습니다.
| 인터페이스 | 시그니처 | 용도 |
|---|---|---|
Consumer<T> | T → void | 결과를 받아서 처리 |
Function<T,R> | T → R | 결과를 변환 |
Supplier<T> | () → T | 값을 제공 |
// Consumer를 콜백으로 사용
public void fetchData(Consumer<String> onSuccess) {
new Thread(() -> {
String data = callExternalApi();
onSuccess.accept(data); // 콜백 호출
}).start();
}
fetchData(data -> System.out.println("받은 데이터: " + data));
4. 이벤트 리스너 패턴
콜백의 확장판이 ** 이벤트 리스너** 패턴입니다. 옵저버(Observer) 패턴과 밀접해요.
// 리스너 인터페이스
public interface OrderEventListener {
void onOrderCreated(Order order);
}
// 이벤트 소스 — 리스너를 등록하고, 이벤트 발생 시 호출
public class OrderService {
private final List<OrderEventListener> listeners = new ArrayList<>();
public void addListener(OrderEventListener listener) {
listeners.add(listener);
}
public void createOrder(Order order) {
saveOrder(order);
// 등록된 모든 리스너에게 알림
listeners.forEach(l -> l.onOrderCreated(order));
}
}
실무에서는 Swing의 addActionListener, Spring의 @EventListener 등으로 만나게 됩니다.
// Spring 이벤트 리스너 — 콜백의 실전 활용
@Component
public class OrderNotificationListener {
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
sendNotification(event.getOrder());
}
}
콜백과 이벤트 리스너의 차이를 정리하면, 콜백은 1:1(한 작업에 하나의 콜백)이고 이벤트 리스너는 1:N(하나의 이벤트에 여러 리스너)입니다.
5. Future와 Callable — 비동기의 시작
Java 5에서 Callable과 Future가 도입되면서 비동기 작업의 결과를 받아볼 수 있게 되었습니다.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(2000);
return "API 응답 데이터";
});
System.out.println("다른 작업 수행 중...");
String result = future.get(); // 여기서 블로킹!
System.out.println("결과: " + result);
executor.shutdown();
Future의 가장 큰 한계는 get()이 ** 블로킹 **이라는 점입니다. 비동기로 작업을 던져놓고도 결과를 받으려면 기다려야 해요.
Future의 한계 정리:
get()블로킹 — 논블로킹 처리 불가- 콜백 등록 불가 — 완료 후 자동 실행할 코드를 연결할 수 없음
- 조합 불가 — 여러 Future를 엮어서 파이프라인을 만들 수 없음
- 예외 처리 불편 —
ExecutionException으로 감싸져 나옴
6. CompletableFuture — 진짜 비동기
Java 8에서 도입된 CompletableFuture는 Future의 한계를 모두 해결합니다.
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
return callExternalApi(); // ForkJoinPool.commonPool()에서 실행
});
핵심 콜백 메서드
// thenApply — 값 변환 (map)
CompletableFuture.supplyAsync(() -> "Hello World")
.thenApply(s -> s.length()); // 결과: 11
// thenAccept — 값 소비 (void)
CompletableFuture.supplyAsync(() -> fetchUserName())
.thenAccept(name -> System.out.println("안녕하세요, " + name + "님!"));
// thenRun — 결과와 무관하게 실행
CompletableFuture.supplyAsync(() -> saveToDatabase())
.thenRun(() -> System.out.println("저장 완료!"));
thenCompose — 비동기 체이닝 (flatMap)
// thenApply를 쓰면 CompletableFuture가 중첩됨
CompletableFuture<CompletableFuture<Order>> nested =
getUserId().thenApply(id -> getOrder(id)); // 중첩!
// thenCompose로 평탄화
CompletableFuture<Order> flat =
getUserId().thenCompose(id -> getOrder(id)); // 깔끔
thenApply는 Stream의map(값을 동기 변환),thenCompose는flatMap(CompletableFuture를 반환하는 비동기 변환을 평탄화)이다.
예외 처리
CompletableFuture.supplyAsync(() -> {
if (Math.random() > 0.5) throw new RuntimeException("실패!");
return "성공";
})
.exceptionally(ex -> {
System.out.println("에러: " + ex.getMessage());
return "기본값"; // 대체값 반환
});
// handle — 성공/실패 모두 처리
CompletableFuture.supplyAsync(() -> riskyOperation())
.handle((result, ex) -> {
if (ex != null) return "에러 복구값";
return result.toUpperCase();
});
Async 변형
모든 콜백 메서드에는 Async 접미사가 붙는 변형이 있습니다.
cf.thenApply(s -> transform(s)); // 같은 스레드 또는 호출 스레드
cf.thenApplyAsync(s -> transform(s)); // ForkJoinPool에서 실행
cf.thenApplyAsync(s -> transform(s), ex); // 지정한 스레드 풀에서 실행
7. 콜백 체이닝 — 파이프라인 만들기
CompletableFuture의 진가는 콜백을 체인으로 연결할 때 드러납니다.
// 주문 처리 파이프라인
CompletableFuture.supplyAsync(() -> authenticateUser(token))
.thenCompose(user -> checkInventory(user.getCartItems()))
.thenApply(inventory -> calculateTotal(inventory))
.thenCompose(total -> processPayment(total))
.thenAccept(receipt -> sendConfirmationEmail(receipt))
.exceptionally(ex -> {
handleOrderFailure(ex); // 어느 단계에서든 실패하면 여기로
return null;
});
비동기 흐름이 위에서 아래로 읽히고, 각 단계의 역할이 한눈에 파악됩니다.
| 상황 | 메서드 | 비유 |
|---|---|---|
| 동기 변환 (값 → 값) | thenApply | Stream.map |
| 비동기 변환 (값 → CF) | thenCompose | Stream.flatMap |
| 결과 소비 (값 → void) | thenAccept | Stream.forEach |
| 결과 무관 실행 | thenRun | — |
8. allOf, anyOf — 여러 비동기 작업 조합
allOf — 모든 작업 완료 대기
CompletableFuture<String> userCf = CompletableFuture.supplyAsync(() -> fetchUser());
CompletableFuture<String> orderCf = CompletableFuture.supplyAsync(() -> fetchOrders());
CompletableFuture<String> reviewCf = CompletableFuture.supplyAsync(() -> fetchReviews());
CompletableFuture.allOf(userCf, orderCf, reviewCf)
.thenRun(() -> {
// 모든 작업 완료 후 결과 조합
String user = userCf.join();
String orders = orderCf.join();
String reviews = reviewCf.join();
System.out.println(user + ", " + orders + ", " + reviews);
});
allOf의 반환 타입은 CompletableFuture<Void>입니다. 결과를 직접 모아주지 않으므로 각각 join()으로 꺼내야 해요.
anyOf — 가장 빠른 작업 하나만
CompletableFuture<Object> fastest = CompletableFuture.anyOf(
callServiceA(), callServiceB(), callServiceC()
);
fastest.thenAccept(result -> System.out.println("가장 빠른 응답: " + result));
여러 서버에 동시에 요청을 보내고 ** 가장 빠른 응답 **을 사용하는 패턴에 유용합니다.
9. 콜백 지옥과 해결법
콜백을 중첩하면 코드가 오른쪽으로 끝없이 밀려나갑니다.
// 콜백 지옥
getUser(userId, user -> {
getOrders(user.getId(), orders -> {
getPayment(orders.get(0).getId(), payment -> {
getReceipt(payment.getId(), receipt -> {
sendEmail(receipt, result -> {
System.out.println("완료: " + result);
});
});
});
});
});
CompletableFuture 체이닝으로 해결하면 들여쓰기가 일정해집니다.
getUserAsync(userId)
.thenCompose(user -> getOrdersAsync(user.getId()))
.thenCompose(orders -> getPaymentAsync(orders.get(0).getId()))
.thenCompose(payment -> getReceiptAsync(payment.getId()))
.thenCompose(receipt -> sendEmailAsync(receipt))
.thenAccept(result -> System.out.println("완료: " + result))
.exceptionally(ex -> {
System.out.println("실패: " + ex.getMessage());
return null;
});
| 방식 | 장점 | 단점 |
|---|---|---|
| 중첩 콜백 | 간단한 경우 직관적 | 깊어지면 가독성 최악, 에러 처리 분산 |
| CompletableFuture 체이닝 | 평탄한 구조, 에러 통합 | 학습 곡선 |
| 가상 스레드 (Java 21) | 동기 코드처럼 작성 가능 | 최신 버전 필요 |
10. CompletionHandler — NIO의 비동기 I/O 콜백
java.nio.channels 패키지의 비동기 채널은 CompletionHandler라는 자체 콜백 인터페이스를 사용합니다.
public interface CompletionHandler<V, A> {
void completed(V result, A attachment); // 성공 시 호출
void failed(Throwable exc, A attachment); // 실패 시 호출
}
V는 결과 타입, A는 콜백에 추가 컨텍스트를 넘기는 첨부 객체(Attachment)입니다.
AsynchronousFileChannel의 read()는 콜백 객체를 받아 비동기로 파일을 읽어요.
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
Path.of("data.txt"), StandardOpenOption.READ
);
ByteBuffer buffer = ByteBuffer.allocate(1024);
읽기 요청을 보내면서 CompletionHandler를 함께 전달합니다. 읽기가 완료되면 completed()가, 실패하면 failed()가 호출돼요.
channel.read(buffer, 0, buffer, new CompletionHandler<>() {
@Override
public void completed(Integer bytesRead, ByteBuffer buf) {
buf.flip();
System.out.println("읽은 바이트: " + bytesRead);
}
@Override
public void failed(Throwable exc, ByteBuffer buf) {
System.err.println("읽기 실패: " + exc.getMessage());
}
});
System.out.println("읽기 요청 후 바로 실행 — 논블로킹!");
실무에서는 Netty 같은 프레임워크가 이 저수준 API를 감싸서 제공하므로 직접 쓸 일은 드뭅니다. 하지만 비동기 I/O가 내부적으로 콜백 기반이라는 원리를 이해하는 데 좋은 예시예요.
11. 정리 테이블
비동기 패턴 비교
| 패턴 | 도입 시기 | 블로킹 | 체이닝 | 에러 처리 |
|---|---|---|---|---|
| 콜백 인터페이스 | Java 1.0~ | 비동기 가능 | 불가 (중첩) | 직접 구현 |
Future + Callable | Java 5 | get() 블로킹 | 불가 | ExecutionException |
CompletableFuture | Java 8 | 논블로킹 | thenApply 등 | exceptionally, handle |
CompletionHandler | Java 7 | 논블로킹 | 불가 | failed() 콜백 |
CompletableFuture 주요 메서드
| 메서드 | 입력 → 출력 | 설명 |
|---|---|---|
thenApply | T → U | 값을 변환 (map) |
thenAccept | T → void | 값을 소비 |
thenRun | () → void | 결과 무관 실행 |
thenCompose | T → CF<U> | 비동기 체이닝 (flatMap) |
exceptionally | Throwable → T | 예외 시 대체값 |
handle | (T, Throwable) → U | 성공/실패 모두 처리 |
allOf | CF[] → CF<Void> | 모든 작업 완료 대기 |
anyOf | CF[] → CF<Object> | 가장 빠른 작업 결과 |
선택 가이드
비동기 작업이 필요한가?
├── 아니요 → 동기 콜백 (Comparator 등)
└── 예
├── 단순 비동기 + 블로킹 OK → Future
├── 논블로킹 + 체이닝 필요 → CompletableFuture ✅
└── NIO 비동기 I/O → CompletionHandler
주의할 점
CompletableFuture 체인에서 예외가 사라지는 실수
thenApply나 thenAccept 뒤에 exceptionally를 달지 않으면, 중간 단계에서 발생한 예외가 조용히 무시됩니다. 체인 끝에 반드시 exceptionally() 또는 handle()을 붙여야 예외를 놓치지 않아요.
allOf()의 반환 타입은 CompletableFuture
allOf()는 결과를 직접 모아주지 않습니다. 모든 작업이 완료된 후 각각 join()으로 결과를 꺼내야 해요. 이걸 모르면 "allOf 했는데 결과가 없다"는 혼란에 빠지게 됩니다.
Future.get()은 비동기의 이점을 상쇄한다
Future의 get()은 결과가 준비될 때까지 호출 스레드를 블로킹합니다. 비동기로 작업을 던져놓고도 결과를 받으려면 기다려야 하므로, 논블로킹이 필요한 상황에서는 CompletableFuture의 콜백 기반 메서드를 써야 합니다.
마무리
자바의 비동기 패턴은 시대에 따라 진화해왔습니다.
| 시기 | 패턴 | 한계 |
|---|---|---|
| Java 1.0~ | 콜백 인터페이스 | 체이닝 불가, 콜백 지옥 |
| Java 5 | Future + Callable | get() 블로킹, 콜백 등록 불가 |
| Java 7 | CompletionHandler (NIO) | 체이닝 불가 |
| Java 8 | CompletableFuture | 논블로킹 체이닝, 예외 처리 통합 |
| Java 21 | 가상 스레드 | 동기 코드로 비동기 수준의 처리량 |