CodeGym /행동 /JAVA 25 SELF /비동기 코드에서의 오류 처리: exceptionally, handle

비동기 코드에서의 오류 처리: exceptionally, handle

JAVA 25 SELF
레벨 55 , 레슨 3
사용 가능

1. 문제: 비동기 코드의 예외

일반적인(동기) 코드에서는 간단합니다. 메서드에서 예외가 발생하면 호출 스택을 따라 위로 ‘터져 나가고’, 우리는 try-catch로 이를 잡을 수 있습니다. 예:

try {
    int x = 1 / 0;
} catch (ArithmeticException ex) {
    System.out.println("0으로 나누기!");
}

비동기 코드에서는 상황이 더 복잡합니다. CompletableFuture.supplyAsync로 작업을 실행하면 다른 스레드에서 수행됩니다. 그곳에서 예외가 발생해도 메인 스레드로 던져지지 않습니다! 대신 예외는 CompletableFuture 객체 안에 ‘포장’되고, 나중에 get() 또는 join()을 호출하면 ExecutionException 형태로 받게 됩니다.

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    // 어, 여기서 오류!
    return 1 / 0;
});

try {
    Integer result = future.get(); // 여기서 예외가 던져집니다!
} catch (Exception ex) {
    System.out.println("오류가 발생했습니다: " + ex.getMessage());
}

하지만 get()을 호출하지 않고(그 자체로는 별로 비동기적이지도 않습니다) thenApply 같은 메서드로 체인을 구성하면, 오류가 ‘사라질’ 수 있습니다. 그래서 비동기 프로그래밍에서는 CompletableFuture 체인 안에서 바로 오류를 잡고 처리하는 능력이 매우 중요합니다.

2. exceptionally 메서드: 오류 처리와 값 반환

exceptionally 메서드는 체인의 이전 단계에서 예외가 발생했을 때 이를 잡아 처리하고 대체 값을 반환할 수 있게 해줍니다. 비동기 데이터 흐름을 위한 catch와 같습니다.

시그니처:

CompletableFuture<T> exceptionally(Function<Throwable, ? extends T> fn)

사용 예

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    System.out.println("위험한 계산 수행 중...");
    if (Math.random() > 0.5) {
        throw new RuntimeException("무언가 잘못되었습니다!");
    }
    return 42;
});

future = future.exceptionally(ex -> {
    System.out.println("오류가 발생했습니다: " + ex.getMessage());
    return 0; // '안전한' 값을 반환
});

thenAccept 예

future.thenAccept(result -> System.out.println("결과: " + result));

출력(예시):

위험한 계산 수행 중...
오류가 발생했습니다: 무언가 잘못되었습니다!
결과: 0
위험한 계산 수행 중...
결과: 42

중요! exceptionally는 체인에서 자신보다 앞선 단계에서 처리되지 않은 예외가 있을 때만 동작합니다. 모든 것이 정상이라면 결과를 그대로 다음 단계로 ‘통과’시킵니다.

3. handle 메서드: 결과와 오류를 모두 처리하는 범용 처리기

때로는 결과오류를 동시에 처리해야 할 때가 있습니다. 예를 들어, 정상이라면 결과를 반환하고, 오류라면 대체 값을 반환하거나 오류를 로깅할 수 있습니다.

시그니처:

CompletableFuture<U> handle(BiFunction<? super T, Throwable, ? extends U> fn)
  • 첫 번째 인수 — 결과(오류가 있었다면 null),
  • 두 번째 — 예외(정상이라면 null).

사용 예

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    if (Math.random() > 0.5) throw new RuntimeException("무작위 오류!");
    return 100;
});

CompletableFuture<Integer> safeFuture = future.handle((result, ex) -> {
    if (ex != null) {
        System.out.println("오류 감지됨: " + ex.getMessage());
        return -1;
    }
    return result;
});

safeFuture.thenAccept(r -> System.out.println("최종 결과: " + r));

출력:

오류 감지됨: 무작위 오류!
최종 결과: -1
최종 결과: 100

handle은 작업이 성공했는지 실패했는지와 상관없이 동작하고 싶을 때 사용합니다. 항상 호출되어 두 개의 인수, 즉 결과(문제가 없으면)와 예외(문제가 있었으면)를 받아서 둘 다를 한 곳에서 처리할 수 있는 범용 최종 처리기입니다.

이 메서드는 오류를 중앙집중적으로 로깅하고, 체인을 끊지 않으면서 기본값을 반환하거나, 비동기 시나리오를 깔끔하게 마무리해야 할 때 이상적입니다.

예:

CompletableFuture<Integer> future = CompletableFuture
    .supplyAsync(() -> 10 / 0) // 여기서 오류가 발생함
    .handle((result, ex) -> {
        if (ex != null) {
            System.out.println("오류: " + ex.getMessage());
            return 0; // 기본값
        }
        return result;
    });

System.out.println(future.join()); // 0을 출력함

exceptionally오류에만 반응하는 것과 달리, handle항상 호출되어 한 곳에서 두 가지 경우를 모두 처리해 체인의 흐름을 부드럽게 유지할 수 있습니다.

4. whenComplete 메서드: 완료 후 부수 작업

때로는 결과를 바꾸지 않고, 작업이 끝난 후 어떤 동작만 수행하고 싶을 때가 있습니다 — 예를 들어 성공 여부와 상관없이 작업이 끝났음을 로깅하는 경우입니다.

시그니처:

CompletableFuture<T> whenComplete(BiConsumer<? super T, ? super Throwable> action)
  • 첫 번째 인수 — 결과(오류가 있으면 null),
  • 두 번째 — 예외(성공이면 null).

사용 예

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    if (Math.random() > 0.5) throw new RuntimeException("오류!");
    return 10;
});

future.whenComplete((result, ex) -> {
    if (ex != null) {
        System.out.println("실행 중 오류: " + ex.getMessage());
    } else {
        System.out.println("성공적으로 완료, 결과: " + result);
    }
});

중요한 차이점:
whenComplete는 결과나 예외를 변경하지 않고 동작만 수행합니다. 만약 whenComplete 내부에서 예외가 발생하면 기존 예외에 ‘붙어서’ 전파됩니다.

예: 로깅만 하고 개입하지 않기

future
    .whenComplete((res, ex) -> {
        System.out.println("작업이 완료되었습니다. 오류인가요? " + (ex != null));
    })
    .thenAccept(r -> System.out.println("사용자에게 보여줄 결과: " + r));

5. 구현상의 특징과 주의점

Best practices: CompletableFuture에서 오류를 올바르게 처리하는 법

  • 항상 비동기 체인에 오류 처리(exceptionally, handle 또는 whenComplete)를 추가하세요. 그렇지 않으면 오류가 눈치채지 못한 채 남아 애플리케이션이 예측 불가능하게 동작할 수 있습니다.
  • 메인 스레드에서 get() 또는 join()try-catch 없이 사용하지 마세요 — 비동기 코드를 동기화시키고 블로킹을 유발할 수 있습니다.
  • 오류 시 ‘대체’ 값을 반환해야 한다면 exceptionally 또는 handle을 사용하세요.
  • 부수 효과(로깅, 사용자 알림 등)에는 whenComplete를 사용하세요.
  • 체인에서 조합할 수 있습니다. 예를 들어 먼저 exceptionally로 오류를 처리하고, 이어 whenComplete로 로깅하며, 그다음 결과 처리를 계속합니다.
  • 오류가 처리되지 않았다면 이후 get()/join() 호출로 ‘흘러가’ 애플리케이션을 종료시킬 수 있다는 점을 기억하세요.

메서드 순서

  • exceptionally는 오직 체인에서 자신 앞에서 발생한 오류만 가로챕니다.
  • exceptionally 이후 thenApply 등에서 다시 오류가 발생하면 별도로 처리해야 합니다.
  • handle은 범용적입니다 — 오류 여부와 상관없이 항상 호출됩니다.

메서드 조합

CompletableFuture.supplyAsync(() -> {
    // ...
})
.handle((result, ex) -> {
    if (ex != null) return "오류: " + ex.getMessage();
    return result;
})
.whenComplete((res, ex) -> {
    System.out.println("작업이 완료되었고, 결과: " + res);
});

오류를 처리하지 않으면 어떻게 될까?

예외가 처리되지 않은 상태에서 get() 또는 join()을 호출하면 ExecutionException(또는 CompletionException)으로 던져지며, 애플리케이션이 오류로 종료될 수 있습니다.

6. CompletableFuture 오류 처리 시 흔한 실수

실수 №1: 오류 처리가 없음. exceptionally, handle, whenComplete 중 아무것도 추가하지 않으면, 오류는 다음 get()/join() 호출까지 그냥 ‘사라져’ 버리며, 이는 발생 지점에서 멀리 떨어져 있을 수 있습니다.

실수 №2: 메인 스레드에서 try-catch 없이 get()/join()을 사용. 이는 비동기 코드를 동기 코드로 바꾸고, 블로킹이나 예기치 않은 애플리케이션 종료로 이어질 수 있습니다.

실수 №3: 처리기가 정확히 어디에서 동작하는지 오해. exceptionally는 체인에서 자기 앞의 오류만 잡습니다. 이후에 다시 오류가 발생하면 이 메서드로는 처리되지 않습니다.

실수 №4: 오류를 처리하지만 값을 반환하지 않음. exceptionallyhandle에서는 반드시 값을 반환해야 합니다. 그렇지 않으면 다음 단계는 null을 받거나 아무것도 받지 못합니다.

실수 №5: handle과 whenComplete를 혼동. handle은 결과를 변경할 수 있고, whenComplete는 동작만 수행합니다(예: 로깅). 결과를 바꾸고 싶다면 handle을 사용하세요.

실수 №6: 오류 처리 로직의 중복. 종종 한 곳에서 오류 처리를 통합해 중복을 줄일 수 있습니다 — 예를 들어 중앙집중적인 handle이나 공용 처리기를 두는 방식입니다.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION