네트워크 요청을 보내고, 응답이 올 때까지 기다리는 동안 다른 일을 하고 싶다. 응답이 오면 "이 코드를 실행해줘"라고 미리 등록해둘 수 있을까?

이것이 콜백과 비동기 패턴의 핵심 아이디어입니다. 이 글에서는 콜백 인터페이스부터 Future, CompletableFuture, NIO의 CompletionHandler까지 — 자바에서 "나중에 불러줘"를 구현하는 방법이 어떻게 진화해왔는지 정리해볼게요.

TIP: 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.

1. 콜백이란 — "나중에 불러줘"의 의미

** 콜백(Callback)은 다른 코드에게 넘겨주는 실행 가능한 코드 조각입니다.** "이 작업이 끝나면 이 코드를 실행해줘"라는 약속이에요.

콜백이라고 해서 무조건 비동기는 아닙니다. 두 종류를 구분해야 해요.

  • ** 동기 콜백 **: 넘긴 그 자리에서 바로 실행됩니다. Collections.sort()Comparator를 넘기는 것이 대표적이에요.
  • ** 비동기 콜백 : 작업이 끝난 후 ** 나중에 다른 스레드에서 실행됩니다. 네트워크 요청 완료 후 결과를 처리하는 경우예요.
JAVA
// 동기 콜백 — 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. 인터페이스로 콜백 구현

자바에는 함수를 직접 넘기는 문법이 없습니다. 대신 ** 인터페이스 **를 통해 콜백을 구현해요.

JAVA
// 콜백 인터페이스 정의
public interface TaskCallback {
    void onComplete(String result);  // 성공 시 호출
    void onError(Exception e);       // 실패 시 호출
}
JAVA
public class AsyncWorker {
    // 콜백을 매개변수로 받아 비동기로 실행
    public void doWork(TaskCallback callback) {
        new Thread(() -> {
            try {
                Thread.sleep(1000); // 시간이 걸리는 작업
                callback.onComplete("작업 완료!"); // 성공 콜백
            } catch (Exception e) {
                callback.onError(e); // 실패 콜백
            }
        }).start();
    }
}
JAVA
// 익명 클래스로 콜백 전달
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개)여야 해요.

JAVA
@FunctionalInterface
public interface SimpleCallback {
    void onComplete(String result);
}
JAVA
// 익명 클래스 → 람다 → 메서드 레퍼런스 진화
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값을 제공
JAVA
// Consumer를 콜백으로 사용
public void fetchData(Consumer<String> onSuccess) {
    new Thread(() -> {
        String data = callExternalApi();
        onSuccess.accept(data); // 콜백 호출
    }).start();
}

fetchData(data -> System.out.println("받은 데이터: " + data));

4. 이벤트 리스너 패턴

콜백의 확장판이 ** 이벤트 리스너** 패턴입니다. 옵저버(Observer) 패턴과 밀접해요.

JAVA
// 리스너 인터페이스
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 등으로 만나게 됩니다.

JAVA
// Spring 이벤트 리스너 — 콜백의 실전 활용
@Component
public class OrderNotificationListener {
    @EventListener
    public void handleOrderCreated(OrderCreatedEvent event) {
        sendNotification(event.getOrder());
    }
}

콜백과 이벤트 리스너의 차이를 정리하면, 콜백은 1:1(한 작업에 하나의 콜백)이고 이벤트 리스너는 1:N(하나의 이벤트에 여러 리스너)입니다.

5. Future와 Callable — 비동기의 시작

Java 5에서 CallableFuture가 도입되면서 비동기 작업의 결과를 받아볼 수 있게 되었습니다.

JAVA
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의 한계를 모두 해결합니다.

JAVA
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
    return callExternalApi(); // ForkJoinPool.commonPool()에서 실행
});

핵심 콜백 메서드

JAVA
// 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)

JAVA
// thenApply를 쓰면 CompletableFuture가 중첩됨
CompletableFuture<CompletableFuture<Order>> nested =
    getUserId().thenApply(id -> getOrder(id)); // 중첩!

// thenCompose로 평탄화
CompletableFuture<Order> flat =
    getUserId().thenCompose(id -> getOrder(id)); // 깔끔

thenApply는 Stream의 map(값을 동기 변환), thenComposeflatMap(CompletableFuture를 반환하는 비동기 변환을 평탄화)이다.

예외 처리

JAVA
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 접미사가 붙는 변형이 있습니다.

JAVA
cf.thenApply(s -> transform(s));          // 같은 스레드 또는 호출 스레드
cf.thenApplyAsync(s -> transform(s));     // ForkJoinPool에서 실행
cf.thenApplyAsync(s -> transform(s), ex); // 지정한 스레드 풀에서 실행

7. 콜백 체이닝 — 파이프라인 만들기

CompletableFuture의 진가는 콜백을 체인으로 연결할 때 드러납니다.

JAVA
// 주문 처리 파이프라인
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;
    });

비동기 흐름이 위에서 아래로 읽히고, 각 단계의 역할이 한눈에 파악됩니다.

상황메서드비유
동기 변환 (값 → 값)thenApplyStream.map
비동기 변환 (값 → CF)thenComposeStream.flatMap
결과 소비 (값 → void)thenAcceptStream.forEach
결과 무관 실행thenRun

8. allOf, anyOf — 여러 비동기 작업 조합

allOf — 모든 작업 완료 대기

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

JAVA
CompletableFuture<Object> fastest = CompletableFuture.anyOf(
    callServiceA(), callServiceB(), callServiceC()
);
fastest.thenAccept(result -> System.out.println("가장 빠른 응답: " + result));

여러 서버에 동시에 요청을 보내고 ** 가장 빠른 응답 **을 사용하는 패턴에 유용합니다.

9. 콜백 지옥과 해결법

콜백을 중첩하면 코드가 오른쪽으로 끝없이 밀려나갑니다.

JAVA
// 콜백 지옥
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 체이닝으로 해결하면 들여쓰기가 일정해집니다.

JAVA
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라는 자체 콜백 인터페이스를 사용합니다.

JAVA
public interface CompletionHandler<V, A> {
    void completed(V result, A attachment); // 성공 시 호출
    void failed(Throwable exc, A attachment); // 실패 시 호출
}

V는 결과 타입, A는 콜백에 추가 컨텍스트를 넘기는 첨부 객체(Attachment)입니다.

AsynchronousFileChannelread()는 콜백 객체를 받아 비동기로 파일을 읽어요.

JAVA
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
    Path.of("data.txt"), StandardOpenOption.READ
);
ByteBuffer buffer = ByteBuffer.allocate(1024);

읽기 요청을 보내면서 CompletionHandler를 함께 전달합니다. 읽기가 완료되면 completed()가, 실패하면 failed()가 호출돼요.

JAVA
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 + CallableJava 5get() 블로킹불가ExecutionException
CompletableFutureJava 8논블로킹thenApply 등exceptionally, handle
CompletionHandlerJava 7논블로킹불가failed() 콜백

CompletableFuture 주요 메서드

메서드입력 → 출력설명
thenApplyT → U값을 변환 (map)
thenAcceptT → void값을 소비
thenRun() → void결과 무관 실행
thenComposeT → CF<U>비동기 체이닝 (flatMap)
exceptionallyThrowable → T예외 시 대체값
handle(T, Throwable) → U성공/실패 모두 처리
allOfCF[]CF<Void>모든 작업 완료 대기
anyOfCF[]CF<Object>가장 빠른 작업 결과

선택 가이드

PLAINTEXT
비동기 작업이 필요한가?
├── 아니요 → 동기 콜백 (Comparator 등)
└── 예
    ├── 단순 비동기 + 블로킹 OK → Future
    ├── 논블로킹 + 체이닝 필요 → CompletableFuture ✅
    └── NIO 비동기 I/O → CompletionHandler

주의할 점

CompletableFuture 체인에서 예외가 사라지는 실수

thenApplythenAccept 뒤에 exceptionally를 달지 않으면, 중간 단계에서 발생한 예외가 조용히 무시됩니다. 체인 끝에 반드시 exceptionally() 또는 handle()을 붙여야 예외를 놓치지 않아요.

allOf()의 반환 타입은 CompletableFuture

allOf()는 결과를 직접 모아주지 않습니다. 모든 작업이 완료된 후 각각 join()으로 결과를 꺼내야 해요. 이걸 모르면 "allOf 했는데 결과가 없다"는 혼란에 빠지게 됩니다.

Future.get()은 비동기의 이점을 상쇄한다

Futureget()은 결과가 준비될 때까지 호출 스레드를 블로킹합니다. 비동기로 작업을 던져놓고도 결과를 받으려면 기다려야 하므로, 논블로킹이 필요한 상황에서는 CompletableFuture의 콜백 기반 메서드를 써야 합니다.

마무리

자바의 비동기 패턴은 시대에 따라 진화해왔습니다.

시기패턴한계
Java 1.0~콜백 인터페이스체이닝 불가, 콜백 지옥
Java 5Future + Callableget() 블로킹, 콜백 등록 불가
Java 7CompletionHandler (NIO)체이닝 불가
Java 8CompletableFuture논블로킹 체이닝, 예외 처리 통합
Java 21가상 스레드동기 코드로 비동기 수준의 처리량
댓글 로딩 중...