[Java] Thread 안에서 발생하는 예외는 어떻게 처리되나

kindof

·

2023. 3. 31. 18:19

0. 문제

이번 글에서는 자바 개별 Thread에서 발생하는 예외를 어떻게 처리할 것인가에 대한 내용을 정리해보려고 합니다.

 

먼저 간단한 문제 상황을 보기 위해 아래 테스트 코드를 작성했습니다.

@Test
void threadExceptionTest() {
   ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10,10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
   int successCount = 0;
   for (int i = 0; i < 10; i++) {
      threadPoolExecutor.submit(() -> {
         throw new NullPointerException();
      });
      successCount++;
   }
   Assertions.assertEquals(10, successCount);
}

위 테스트 코드는 각 쓰레드에서 NullPointerException을 의도적으로 발생시키고, 별도의 try-catch 블록을 사용하지도 않습니다.

 

그러나, 이 테스트 코드는 '성공'합니다.

테스트가 성공한다.

 

각 쓰레드에서 분명 NullPointerException을 발생시키지만, 메인 쓰레드의 관점에서는 내부 쓰레드의 예외를 처리하지 않기 때문입니다.

 

따라서 이렇게 개별 쓰레드의 예외 처리를 적절하게 하지 않는다면 개별 쓰레드 내 버그로 인해 서비스 장애가 발생할 수 있습니다.

 

그렇다면 이제 개별 쓰레드에서 발생하는 예외를 메인 쓰레드에서 어떻게 처리할 수 있는지, 몇 가지 방법에 대해 정리해보겠습니다.

 

1. 메인 쓰레드에서 Try-Catch

먼저 위 코드의 for문을 try-catch 블록으로 감싸보겠습니다.

@Test
void threadExceptionTest() {
   ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10,10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
   int successCount = 0;
   try {
      for (int i = 0; i < 10; i++) {
         threadPoolExecutor.submit(() -> {
            throw new NullPointerException();
         });
         successCount++;
      }
   } catch (NullPointerException e) {
      System.out.println("NullPointerException catch");
      throw e;
   }
   Assertions.assertEquals(10, successCount);
}

 

위 테스트 코드의 결과 역시 성공이며, catch 블록에서 NullPointerException을 잡아내지 못합니다.

테스트가 성공한다.

메인 쓰레드에서의 Try-Catch 블록 역시 개별 쓰레드의 예외를 직접 잡아낼 수 없다는 것을 알 수 있습니다.

 

2. Future.get() 사용하기

Future는 비동기 연산의 결과를 표현하는 인터페이스로 연산이 완료되었는지 확인하고 완료될 때까지 대기하며, 결과를 표현합니다.

 

특히 비동기 연산의 결과를 얻기 위해 아래와 같이 get() 메서드를 사용하는데요.

Future.get()

연산의 결과에서 예외가 발생한다면(if the computation threw an exception) ExecutionException을 발생시키게 됩니다.

 

아래와 같이 테스트 코드를 수정해보겠습니다.

@Test
void threadExceptionTest() throws ExecutionException, InterruptedException {
   ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10,10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
   int successCount = 0;
   try {
      for (int i = 0; i < 10; i++) {
         Future<?> future = threadPoolExecutor.submit(() -> {
            throw new NullPointerException();
         });
         Object result = future.get();
         successCount++;
      }
   } catch (Exception e) {
      System.out.println("Exception Catch!");
      throw e;
   }
   Assertions.assertEquals(10, successCount);
}

테스트 코드가 실패한다.

개별 쓰레드에서 NullPointerException이 발생했고 future.get()은 이 결과를 ExecutionException으로 던집니다. 그리고 메인 쓰레드는 catch 블록에서 해당 예외를 throw e; 하고 있습니다.

 

그래서 결과적으로는 메인 쓰레드에서 예외가 발생하여 테스트 코드가 실패하는 것을 알 수 있습니다.

 

참고로 ExecutionException은 Unchecked Exception이기 때문에 아래와 같이 try-catch 블록을 감싸주지 않아도 런타임에 예외를 던지게 됩니다.

@Test
void threadExceptionTest() throws ExecutionException, InterruptedException {
   ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10,10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
   int successCount = 0;
   for (int i = 0; i < 10; i++) {
      Future<?> future = threadPoolExecutor.submit(() -> {
         throw new NullPointerException();
      });
      Object result = future.get();
      successCount++;
   }
   Assertions.assertEquals(10, successCount);
}

Try-Catch 블록이 없는 테스트

 

 

3. CompletableFuture.exceptionally() 사용하기

CompletableFuture는 위에서 설명한 Future를 보완한 인터페이스입니다. CompletableFuture 자체에 대한 설명은 생략하고 예외 처리 방식에 대해 바로 보겠습니다.

 

쓰레드 내에서 예외가 발생했을 때, exceptionally() 구절에서 예외에 대한 작업을 선언할 수 있습니다.

@Test
void threadExceptionTest(){
   int successCount = 0;
   for (int i = 0; i < 10; i++) {
      CompletableFuture.runAsync(() -> {
         throw new NullPointerException();
      }).exceptionally(e -> {
         e.printStackTrace();
         throw new RuntimeException("RuntimeException!");
      });
      successCount++;
   }
   Assertions.assertEquals(10, successCount);
}

테스트가 성공한다.

CompletableFuture의 Async 작업은 반환값이 있을 때는 supplyAsync(), 반환값이 없을 때는 runAsync()를 사용합니다.

 

위 예제에서는 반환값이 없기 때문에 runAsync()를 사용했습니다.

 

한편, CompletableFuture.exceptionally()를 통해 개별 쓰레드에서 발생한 예외를 처리할 수 있게 됩니다. 하지만 이 예외는 쓰레드 안에서 처리되기 때문에 e.printStackTrace()를 통해 로그를 찍어야 예외의 내용을 볼 수 있습니다.

 

또한, 쓰레드 안에서 RuntimeException을 발생시켰기 때문에 맨 처음과 마찬가지로 메인 쓰레드에서는 해당 예외를 처리하지 않습니다.

 

따라서 메인 메서드에서 예외를 처리하기 위해서는 아래와 같이 join() 혹은 get() 메서드를 사용해서 쓰레드의 결과 상태를 받아 try-catch 블록으로 처리해주어야 합니다.

@Test
void threadExceptionTest() {
   int successCount = 0;
   CompletableFuture<Object> completableFuture = CompletableFuture.supplyAsync(() -> {
      throw new NullPointerException();
   }).exceptionally(e -> {
      e.printStackTrace();
      throw new RuntimeException("RuntimeException!");
   });
   try {
      Object result = completableFuture.join();
      successCount++;
   } catch (CompletionException e) {
      throw e;
   }
   Assertions.assertEquals(1, successCount);
}

테스트가 실패한다.

 

4. 정리

이번 글에서는 개별 쓰레드에서 터지는 예외가 메인 쓰레드에서는 잡히지 않는다는 사실에서 출발해 이러한 예외를 어떻게 하면 개별 쓰레드에서 / 메인 쓰레드에서 처리할 수 있는지를 살펴봤습니다.

 

예시로 작성한 코드들에서 생략한 부분들이 많지만 가장 중요한 핵심은 개별 쓰레드에서 예외가 터질 수 있다는 것을 항상 염두해두고 적절한 로깅과 쓰레드 내 / 메인 쓰레드에서의 Catch 작업을 해주어야 한다는 것입니다.

 

4. Reference

- https://github.com/HomoEfficio

- https://mangkyu.tistory.com/263