스레드 풀에서 발생한 예외를 메인 스레드가 잡을 수 있을까?

과거에는 스레드 풀에서 발생한 예외를 메인 스레드가 잡을 수 있다고 생각했지만, 최근 시스템 장애 상황에서 스레드 풀 태스크에 예외 처리 코드가 없으면 메인 스레드의 catch 블록이 예외를 잡지 못해 문제 진단이 어려웠습니다. 이에 대한 내용을 연구하고 기록합니다.

1. JVM 예외 처리

JVM이 처리되지 않은 예외를 어떻게 다루는지 살펴보겠습니다.

@Test
public void testException() {
    int a = 1 / 0;
}

0으로 나누기를 시도하면 예외를 명시적으로 잡지 않았음에도 콘솔에 스택 트레이스가 출력됩니다. JVM 예외 처리 메커니즘은 다음과 같은 단계로 이루어집니다:

  • 예외 발생: 실행 중 예외가 발생하면 해당 예외 객체가 생성되고 던져집니다.
  • 예외 포착: try-catch 블록을 사용해 예외를 잡습니다.
  • 예외 전파: 현재 메서드에서 예외를 잡지 않으면 호출 스택을 따라 상위로 전파됩니다. 적절한 핸들러를 찾거나 최상위에 도달하면 프로그램이 종료되고 예외 정보가 출력됩니다.
  • 예외 처리: catch 블록에서 예외를 처리합니다.
  • 예외 체인: throw를 사용해 예외를 다른 예외의 원인으로 연결할 수 있습니다.

2. 서브 스레드에서 예외 발생

@Test
public void testThreadException() {
    try {
        Thread thread = new Thread(() -> {
            System.out.println("서브 스레드 테스트");
            throw new RuntimeException("시스템 예외");
        });
        thread.start();
    } catch (Exception e) {
        System.out.println("예외 포착: " + e);
    }
}

위 코드를 실행하면 메인 스레드가 서브 스레드의 예외를 잡지 못합니다. sleep()을 추가해도 동일합니다. 이유는 Thread.start() 메서드가 start0() 네이티브 메서드를 호출하고, 이 메서드가 Thread.run()을 실행하기 때문입니다.

@Override
public void run() {
    if (target != null) {
        target.run();
    }
}

Thread.run()은 예외 처리 코드가 없으므로, 예외는 JVM에 의해 처리됩니다. JVM은 dispatchUncaughtException() 메서드를 호출합니다:

private void dispatchUncaughtException(Throwable e) {
    getUncaughtExceptionHandler().uncaughtException(this, e);
}

getUncaughtExceptionHandler()는 사용자 정의 핸들러가 없으면 스레드 그룹 객체를 반환하고, ThreadGroup.uncaughtException()이 콘솔에 예외를 출력합니다.

3. 스레드 풀에서 서브 스레드 예외

ThreadPoolExecutor를 사용해 실험합니다.

3.1 execute()로 태스크 제출

@Test
public void testThreadPoolException1() {
    try {
        ExecutorService threadPoolExecutor = Executors.newFixedThreadPool(10);
        threadPoolExecutor.execute(() -> {
            System.out.println("스레드 풀 테스트");
            throw new RuntimeException("시스템 예외");
        });
    } catch (Exception e) {
        System.out.println("예외 포착: " + e);
    }
}

메인 스레드가 예외를 잡지 못합니다. runWorker() 메서드에서 태스크 실행 중 예외가 발생하면 다시 던져지고, 이후 Thread의 dispatchUncaughtException()과 동일한 로직으로 처리됩니다.

3.2 submit()으로 태스크 제출

@Test
public void testThreadPoolException2() {
    try {
        ExecutorService threadPoolExecutor = Executors.newFixedThreadPool(10);
        threadPoolExecutor.submit(() -> {
            System.out.println("스레드 풀 테스트");
            throw new RuntimeException("시스템 예외");
        });
    } catch (Exception e) {
        System.out.println("예외 포착: " + e);
    }
}

메인 스레드가 예외를 잡지 못하고, 콘솔에도 출력되지 않습니다. Future.get()을 호출하면 예외가 전달됩니다:

@Test
public void testThreadPoolException3() {
    try {
        ExecutorService threadPoolExecutor = Executors.newFixedThreadPool(10);
        Future future = threadPoolExecutor.submit(() -> {
            System.out.println("스레드 풀 테스트");
            throw new RuntimeException("시스템 예외");
        });
        future.get();
    } catch (Exception e) {
        System.out.println("예외 포착: " + e);
    }
}

submit()은 FutureTask를 생성합니다. FutureTask.run()에서 예외가 발생하면 setException()을 호출해 outcome 변수에 저장합니다. get()은 report()를 호출해 ExecutionException을 던집니다.

3.3 CompletableFuture

@Test
public void testThreadPoolException4() {
    try {
        CompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> {
            System.out.println("스레드 풀 테스트");
            throw new RuntimeException("시스템 예외");
        });
    } catch (Exception e) {
        System.out.println("예외 포착: " + e);
    }
}

@Test
public void testThreadPoolException5() {
    try {
        CompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> {
            System.out.println("스레드 풀 테스트");
            throw new RuntimeException("시스템 예외");
        });
        cf.join();
    } catch (Exception e) {
        System.out.println("예외 포착: " + e);
    }
}

@Test
public void testThreadPoolException6() {
    try {
        CompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> {
            System.out.println("스레드 풀 테스트");
            throw new RuntimeException("시스템 예외");
        });
        cf.get();
    } catch (Exception e) {
        System.out.println("예외 포착: " + e);
    }
}

testThreadPoolException4는 예외를 잡지 못하지만, testThreadPoolException5와 6은 join() 또는 get()을 통해 예외를 포착합니다.

4. 결론

  • 직접 생성한 서브 스레드에서 예외가 발생하면 메인 스레드가 잡을 수 없습니다. Thread가 기본적으로 예외를 콘솔에 출력하지만, 운영 환경에서는 태스크 내에서 예외를 잡고 로그를 기록하는 것이 좋습니다.
  • 스레드 풀에서 발생한 예외도 메인 스레드가 잡을 수 없습니다. 태스크 내에 예외 처리 로직을 추가해야 합니다.
  • get() 또는 join() 같은 메서드로 결과를 가져올 때는 스레드 풀의 예외를 메인 스레드가 잡을 수 있습니다.

태그: java threadpool JVM ExceptionHandling ExecutorService

6월 26일 02:51에 게시됨