본문 바로가기
WEB개발/백엔드

코루틴 내부에서 예외가 발생했을 때

by iks15174 2025. 3. 8.

목적

저번 글에서 코루틴의 async, launch와 관련된 예외 케이스를 다뤘다. 하지만 좀 더 근본적으로 코루틴 내부에서 예외가 발생했을 때 어떻게 처리되는지 알고 싶어서 이 글을 작성하게 됐다

 

미리 알아둬야 하는 개념

이 글을 읽기 전 미리 알아둬야 하는 개념이 있다. 그것은 바로 job이다. 코루틴도 job의 하나이다

job의 특징에 대해 나열해 보도록 하겠다

  1. job은 취소가 가능하다
  2. job은 부모-자식 관계를 만들 수 있으며, 부모 job이 취소되면 자식 job들도 재귀적으로 취소된다
  3. 자식 job이 예외를 던지면서 실패하면 부모 job에게 취소요청을 보낸다. 단 'CancellationException' 외의 예외일 때만 해당되며, 이 동작은 SupervisorJob에 의해 변할 수 있다
  4. 코루틴에서 lauch와 async를 통해 코루틴을 만들 때 job도 함께 만들어진다. 즉 코루틴 빌더를 이용해서 코루틴을 만들 때, 이 코루틴은 job을 상속하고 있으며 job의 역할도 한다
  5. job은 기본적으로 실행결과를 가지고 있지 않는다. 하지만 async 코루틴 빌더는 결과를 가지고 있는데 이것이 가능한 이유는 Job을 상속한 Deferred를 사용하기 때문이다. 따라서 async로 만들어진 코루틴은 job의 성격을 가지면서도 실행결과를 담을 수 있다

이러한 job의 성질을 코루틴의 취소와 연관 지어서 설명해 보겠다. 왜냐하면 코루틴도 job의 한 종류이기 때문이다

코루틴 블록 내에서 예외가 발생하면 [3]의 성질에 의해 부모 job에 취소 요청을 보낸다. 그리고 부모는 다시 자신의 부모에게 취소 요청을 보낸다. 최상단 부모(root)까지 왔다면 root는 [2]의 성질에 의해 자신의 자식들을 취소한다. 결국 계층관계를 맺고 있던 모든 코루틴들이 취소된다

 

코루틴에서의 예외를 이해하기 위해서는 job의 특징과 중첩된 코루틴들이 어떤 job의 계층관계를 맺고 있는지가 매우 중요하다

아래에서 여러 예시를 통해 연습해 보도록 하겠다

 

GlobalSocpe, lauch, async 케이스

테스트 코드와 결과는 아래와 같다

fun globalLaunchAsyncTest() {
    GlobalScope.launch {
        println("launch started")
        async {
            println("async started")
            throw RuntimeException("async exception")
        }
        println("launch finished")
    }
    Thread.sleep(100)
}
/*
launch started
launch finished
async started
Exception in thread "DefaultDispatcher-worker-2 @coroutine#2" java.lang.RuntimeException: async exception
*/

GlobalScope는 job을 가지고 있지 않은 scope이기 때문에 launch, async로 만들어진 코루틴 job들끼리만 관계가 형성된다. 아래 이미지와 같다

async블록 내에서 예외가 발생하면 async는 Deferred라는 특수한 job이므로 예외를 보관하고 동시에 예외를 launch-job으로 예외를 전파한다. launch-job은 자기가 root이므로 예외를 처리하게 된다. launch로 만들어진 job의 경우 예외 핸들러를 등록하지 않으면 스레드 자체의 예외 핸들러를 사용한다. 'Exception in thread 'DefaultDispatcher-worker-2'라는 문구에서 확인할 수 있다

GlobalSocpe, 여러 개의 lauch, async 케이스

테스트 코드와 결과는 아래와 같다

fun globalSeveralLaunchAsyncTest() {
    GlobalScope.launch {
        println("launch1 started")
        async {
            println("async1 started")
            throw RuntimeException("async1 exception")
        }
        println("launch1 finished")
    }

    GlobalScope.launch {
        println("launch2 started")
        async {
            println("async2 started")
            throw RuntimeException("async2 exception")
        }
        println("launch2 finished")
    }
    Thread.sleep(100)
}
/*
launch1 started
launch2 started
launch1 finished
launch2 finished
async1 started
async2 started
Exception in thread "DefaultDispatcher-worker-4 @coroutine#4"
Exception in thread "DefaultDispatcher-worker-3 @coroutine#3"
*/

위 코드를 통해 만들어진 job의 관계들을 아래 이미지와 같다. launch1과 laucnh2가 각각 root job이 되며 서로에게 전혀 영향을 끼치지 않는다. async1에서 던져진 예외는 launch1까지만 전파되고, async2에서 던져진 예외는 launch2까지만 전파된다

CoroutineSocpe, lauch, async 케이스

테스트 코드와 결과는 아래와 같다

fun scopeLaunchAsyncTest() {
    CoroutineScope(Dispatchers.IO).launch {
        println("launch started")
        async {
            println("async started")
            throw RuntimeException("async exception")
        }
        println("launch finished")
    }
    Thread.sleep(100)
}
/*
launch started
launch finished
async started
Exception in thread "DefaultDispatcher-worker-3 @coroutine#2" java.lang.RuntimeException: async exception
*/

위 코드를 통해 만들어진 job의 관계는 아래 이미지와 같다. CoroutineScope를 생성하면 내부적으로 job이 생성된다. 그리고 scope 내부에서 코루틴 빌더(launch, async..)를 통해 코루틴을 만들면 해당 코루틴의 job이 CoroutineScope의 job의 자식이 된다

async로 만들어진 코루틴 내부에서 예외가 던져지면 async 코루틴은 해당 예외를 보관하고 동시에 예외를 포함한 취소 요청을 부모로 전파한다. 결국은 상위의 launch와 CoroutineScope까지 도달하게 된다

출력결과를 보면 예외로그가 찍혔는데, 해당 예외로그는 root job인 coroutineScope에서 에러를 핸들링하면서 로그를 찍은 것일까? 정답은 아니다. 예외를 핸들링한 것은 launch1에 해당하는 job이다

CoroutineScope에 의해 만들어지는 job의 구현체는 JobImpl인데, JobImpl은 예외를 핸들링하지 않는다고 한다

따라서 launch1 job에 예외를 포함한 취소요청이 도달했을 때 부모 job이 예외를 핸들링할 수 있는지 확인하고, 예외 핸들링을 할 수 없기 때문에 launch1 job에서 예외를 처리한다. 그리고 예외를 포함한 취소 요청만 부모 job으로 전달할 뿐이다

 

CoroutineSocpe, 여러 개의 lauch, async 케이스

테스트 코드와 결과는 아래와 같다

fun scopeSeveralLaunchAsyncTest() {
    val scope = CoroutineScope(Dispatchers.IO)
    scope.launch {
        println("launch1 started")
        async {
            println("async1 started")
            throw RuntimeException("async1 exception")
        }
        println("launch1 finished")
    }

    scope.launch {
        println("launch2 started")
        async {
            println("async2 started")
            throw RuntimeException("async2 exception")
        }
        println("launch2 finished")
    }
    Thread.sleep(100)
}
/*
launch1 started
launch2 started
launch1 finished
launch2 finished
async1 started
async2 started
Exception in thread "DefaultDispatcher-worker-4 @coroutine#4" java.lang.RuntimeException
Exception in thread "DefaultDispatcher-worker-1 @coroutine#3" java.lang.RuntimeException
*/

위 코드를 통해 만들어진 job의 관계는 아래 이미지와 같다. async1,2 job에서 예외가 발생하면 async1,2 job은 예외를 저장하고 예외를 포함한 취소요청을 launch1,2로 전달한다. launch1,2는 각각 부모 job이 예외를 핸들링할 수 없는 상태라는 걸 확인하고 각자 예외를 핸들링한다. 그래서 위 코드 결과에 에러로깅이 두 번 된 것을 확인할 수 있다. 최종적으로 CoroutineScope job까지 예외를 포함한 취소 요청이 전달되고, 하위의 job들을 모두 취소시킨다

만약 async1이 async2 보다 delay(50)을 통해서 예외를 늦게 던지면 어떻게 될까? async2에 의한 예외 때문에 CorountieScope job까지 취소 요청이 전달되고 CoroutineScope job은 자식 job들을 모두 취소시킨다. async1의 job도 마찬가지로 취소된다. 그리고 CancellationException을 던지는데 job성질 [3]에 의해 별다른 핸들링 없이 종료된다. 따라서 에러로그는 한 번만 찍히게 된다 

 

정리

  1. 코루틴에서 예외가 발생했을 때의 상황을 예측하려면 job의 관계를 파악하는 것이 중요하다. 추가적으로 해당 job이 예외를 핸들링할 수 있는지, 할 수 있다면 어떤 방식으로 하는지도 확인해야 한다
  2. async를 통해 만들어진 job은 내부에서 예외가 발생하면 예외를 저장하고(await 호출되면 던짐) 부모가 존재하면 부모로 취소 요청을 전파한다. 부모가 예외 핸들링을 지원하지 않더라도 자신 또한 별다른 핸들링을 하지 않는다
  3. launch를 통해 만들어진 job은 내부에서 예외가 발생하면 부모가 존재할 경우 부모로 취소 요청을 전파한다. 부모가 예외 핸들링을 지원하지 않는다면, 자신이 예외 핸들링을 한다. 등록된 예외 핸들러가 있을 경우 그걸 사용하고, 없다면 launch를 실행하는 스레드의 예외 핸들러를 사용한다(에러 로깅)