[Kotlin] async로 만든 코루틴, launch로 만든 것처럼 예외 처리 해도 될까?

Kame·2025년 5월 4일
0

Kotlin

목록 보기
3/9
post-thumbnail

들어가며

링크를 통해 이 글을 요약한 AI 팟캐스트를 들어볼 수 있습니다.

async 코루틴 빌더와 그것이 반환하는 Deferred 타입을 활용할 때 적절한 예외 처리 방식을 동작 원리와 함께 알아봅니다.

선행 지식

Kotlin Coroutines

  • launch 코루틴 빌더
  • 구조화된 동시성(Structured Concurrency)
    • 코루틴의 취소와 예외 전파
  • Job, SupervisorJob
    • Job의 예외 처리
  • CoroutineExceptionHandler

Job과 Deferred

코루틴을 만들기 위해서는, 코루틴 빌더를 활용해야 합니다.

📚 코루틴 빌더
: 코루틴을 생성하고 시작시키는 함수

코루틴에는 다음과 같은 빌더 함수들이 존재합니다.

  • launch
  • async
  • runBlocking - 본 글과는 연관이 적습니다.

일반적인 개발 환경에서, launch와 async를 많이 활용합니다.
두 함수의 대표적인 차이점으로 반환 타입, 결괏값 유무를 들 수 있습니다.

launchasync
반환 타입JobDeferred<T> (Job을 구현)
결괏값없음있음(T)
fun main() = runBlocking {
    // 결괏값을 갖지 않는 Job 반환
    val job: Job = launch {
        println("launch started")
        delay(500)
        println("launch completed") // Unit
    }

	// join()을 활용해 Job의 실행 완료까지 단순 대기
    job.join() 
    println("launch result: completion")
}
fun main() = runBlocking {
    // 결괏값을 갖는 Deferred<T> 반환
    val deferred: Deferred<String> = async {
        println("async started")
        delay(500)
        println("async completed")
        "Hello from async" // 결괏값!
    }

	// await()을 활용해 Deferred의 실행 완료까지 대기하고 결괏값 확보
    val result = deferred.await() 
    println("async result: $result") // async result: Hello from async
}

하지만 이 둘의 차이는 반환 타입과 결괏값 유무에 그치지 않습니다. 사실 예외의 전파 및 처리 방식에서 큰 차이점이 숨겨져 있습니다. 예외를 잘못 처리해 앱이 예상치 못한 방향으로 동작하는 상황을 방지하기 위해서는, 그 차이를 이해하는 것이 중요합니다.


구조화된 동시성

예외 처리를 비롯한 코루틴의 많은 특성들은 구조화된 동시성(Structured Concurrency)을 빼놓고는 논하기 힘듭니다.

구조화된 동시성코루틴이 특정 스코프 내에서 실행되며, 그 안에서 코루틴들끼리 부모-자식 관계를 형성한다는 구조 상 특징입니다. 이러한 성질 덕에, 아래 특성들을 활용하여 코루틴 생명주기 관리의 안정성을 확보할 수 있습니다.

🧑‍🏫 구조화된 동시성 관련 코루틴의 특성들
‒ 코루틴은 모든 자식 코루틴들이 완료되어야 완료될 수 있다.
ㅤ: 완료됨(Completed) 상태
‒ 코루틴이 취소되면 자식 코루틴들도 연쇄적으로 취소된다.
코루틴에 예외가 발생하면 부모 코루틴으로 예외가 연쇄적으로 전파된다.
→ 단, 부모가 SupervisorJob을 가지면 예외는 상위로 전파되지 않음

이는 Java의 Thread를 활용할 때 명시적으로 스레드 종료를 관리하고, 예외를 각 쓰레드마다 개별적으로 처리해야 한다는 번거로움과 대비되는 장점입니다. 이 특성을 바탕으로 launch와 async로 만든 코루틴이 예외 처리 측면에서 어떤 공통점과 차이점을 보이는지 알아보도록 하겠습니다.


Job과 Deferred의 예외 처리 상 공통점

예외가 부모로 전파된다

launch 코루틴과 async 코루틴에서 예외가 발생하면,
부모 코루틴이 SupervisorJob이 아닌 Job을 갖는 경우 공통적으로 예외를 부모로 전파시키고, 예외를 전파받은 부모의 실행은 취소된다.

🤯 Uncaught Exception

프로그래머 혹은 컴파일러에 의해 잡히지 않은 예외

예외는 try-catch, runCatching 등의 수단을 활용해 명시적으로 처리하는 것이 바람직하며, 처리하지 않는다면 해당 예외는 스레드 측에서 Uncaught Exception으로 간주됩니다.

이 경우, JVM은 예외가 발생한 Thread 인스턴스의 dispatchUncaughtException()를 호출하여 예외와 함께 스레드를 강제 종료 시킵니다. 이 작업은 스레드에 등록된 UncaughtExceptionHandler에 의해 처리됩니다.

public class Thread implements Runnable {
    /**
     * 스레드가 예외와 함께 종료될 때 호출
     */
    void dispatchUncaughtException(Throwable e) {
        getUncaughtExceptionHandler().uncaughtException(this, e);
    }
}

아래 코드에서는 메인 스레드에서 Uncaught Exception이 발생하여, 아래와 같은 로그를 출력하며 비정상 종료됩니다.

fun main() {
	println("start")
    // 메인 스레드에서 예외를 잡지 않음 -> Uncaught Exception
	throw Exception("uncaught exception")
    println("end")
}

start
Exception in thread "main" java.lang.Exception: uncaught exception

당연하지만 코루틴을 활용할 때 역시 예외를 적절히 처리해야 합니다. 이와 관련하여 중요한 사실은 코루틴을 launch/async 중 어떤 것으로 만드는가에 관계 없이 예외는 상위로 전파된다는 것입니다.

만약 어떤 코루틴에서 발생한 예외가 어디에서도 처리되지 않는다면, 해당 예외는 Uncaught Exception으로 처리되어 프로그램 실행에 악영향을 미칠 수 있습니다.

예시

launch에서 예외 발생 시

아래 그림과 같은 구조를 가진 코드가 있다고 가정해봅시다.

fun main() = runBlocking(CoroutineName("root")) {
    println("root started")
    
    launch(CoroutineName("parent")) {
        println("parent started")
        
        launch(CoroutineName("child")) {
            println("child started")
            delay(500)

            throw RuntimeException("exception in ${this.coroutineContext[CoroutineName.Key]}")
            println("child completed")
        }
        
        delay(700)
        println("parent completed")
    }

    delay(1000)
    println("root completed")
}

child의 예외가 parent 코루틴을 거쳐, runBlocking으로 만들어진 root 코루틴까지 전파되었습니다. runBlocking이 메인 스레드에서 실행되고 있었으므로 메인 스레드에서 Uncaught Exception이 발생했고, 프로그램이 강제 종료 되었습니다.

root started
parent started
child started
Exception in thread "main" java.lang.RuntimeException: exception in CoroutineName(child)
ㅤㅤ...

async에서 예외 발생 시

앞서 살펴본 코드를 launch 대신 async로 변경해 보겠습니다.

참고) 본 포스트에서 나오는 async 활용 사례들은 예외 처리에만 초점을 맞춘 것으로, async의 바람직한 활용 모습은 아닐 수 있습니다.

fun main() = runBlocking(CoroutineName("root")) {
    println("root started")
    
    async(CoroutineName("parent")) {
        println("parent started")
        
        async(CoroutineName("child")) {
            println("child started")
            delay(500)

            throw RuntimeException("exception in ${this.coroutineContext[CoroutineName.Key]}")
            println("child completed")
        }
        delay(700)
        println("parent completed")
    }

    delay(1000)
    println("root completed")
}

launch를 활용했을 때와 마찬가지로 예외가 root까지 전파되었고, 예외가 잡히지 않았기 때문에 동일한 결과가 나타납니다.

root started
parent started
child started
Exception in thread "main" java.lang.RuntimeException: exception in CoroutineName(child)
ㅤㅤㅤ...

구조화된 동시성의 특성에 따라 기본적으로 자식 코루틴의 예외는 부모로 전파되기 때문에, 코루틴을 다룰 때는 예외 처리가 누락되지 않도록 하는 것이 중요합니다.

🤯 생각해보기

  • 아래 코드를 실행하면 어떤 결과가 나오는가?
  • launch가 사용된 모든 곳을 async로 바꾸면 어떤 결과가 나오며, 실행 결과에 차이가 존재하는가? 차이가 존재한다면 그 이유는 무엇인가?
fun main() = runBlocking(CoroutineName("root")) {

Thread.setDefaultUncaughtExceptionHandler { _, e ->
        println("메인 스레드 예외: ${e.message}")
    }

    println("root started")
    // 새로운 Job 생성
    val newJob = Job()
   
    // 구조화 파괴! 새로운 루트 코루틴
    launch(CoroutineName("parent") + newJob) {
        println("parent started")
       
        launch(CoroutineName("child")) {
            println("child started")
            delay(500)

            throw RuntimeException("exception in ${this.coroutineContext[CoroutineName.Key]}")
            println("child completed")
        }
        delay(700)
        println("parent completed")
    }

    delay(1000)
    println("root completed")
}

Job과 Deferred의 예외 처리 상 차이점

루트인 경우, 예외를 바로 발생시키는가 vs 사용자에 맡기는가

🫚 루트 코루틴
: 코루틴 스코프의 직접적인 자식 코루틴 (direct child of a scope)

📗 차이점

💥 launch가 루트인 경우 : 바로 폭발
: 내부에서 예외를 잡지 않으면 바로 throw되며, Uncaught Exception으로 처리된다.

💣 async가 루트인 경우 : 폭탄 상태
: 루트 async로 반환된 Deferred 객체의 await()를 호출하지 않으면 예외가 throw되지 않는다.

앞선 예시 코드 중, async를 활용한 예시를 다시 살펴보도록 하겠습니다.

fun main() = runBlocking(CoroutineName("root")) {
    println("root started")
    
    async(CoroutineName("parent")) {
        println("parent started")
        
        async(CoroutineName("child")) {
            println("child started")
            delay(500)

            throw RuntimeException("exception in ${this.coroutineContext[CoroutineName.Key]}")
            println("child completed")
        }
        delay(700)
        println("parent completed")
    }

    delay(1000)
    println("root completed")
}

child 코루틴에 예외가 발생하고, 어느 곳에서도 별도로 예외 처리를 진행하고 있지 않아 예외가 root 코루틴까지 전파되었습니다. 결국 스레드의 UncaughtExceptionHandler에서 예외가 처리되었습니다.

지금까지의 상황은 루트 코루틴이 runBlocking으로 만들어진 경우였습니다.
이 상황에서 루트 코루틴을 launch, async로 변경하는 상황을 각각 살펴보겠습니다.

☝️ launch를 루트 코루틴으로 만들기

runBlocking 블록 내부에서 구조화를 깨는 방식으로 새로운 루트 코루틴을 생성하였습니다. 대표적인 방법이 코루틴의 부모를 새로운 Job 객체로 설정하는 것입니다.

fun main(): Unit = runBlocking {
    val newJob = Job()
    // 새로운 부모 Job으로 설정
    launch(CoroutineName("root") + newJob) {
        println("root started")
        
        async(CoroutineName("parent")) {
            println("parent started")
            
            async(CoroutineName("child")) {
                println("child started")
                delay(500)

                throw RuntimeException("exception in ${this.coroutineContext[CoroutineName.Key]}")
                println("child completed")
            }

            delay(700)
            println("parent completed")
        }

        delay(1000)
        println("root completed")
    }
    
    delay(1500)
    newJob.complete()
}

실행 결과, 예외 발생 와중에 그 어떠한 예외 처리도 없었기 때문에 예외가 newJob까지 전파되었습니다. 따라서 newJob을 비롯한 하위의 모든 코루틴들이 취소되고 (completed 관련 로그 누락), Uncaught Exception이 로그로 출력되었습니다.

root started
parent started
child started
Exception in thread "main" java.lang.RuntimeException: exception in CoroutineName(child)

✌️ async를 루트 코루틴으로 만들기

fun main(): Unit = runBlocking {
    val newJob = Job()
    async(CoroutineName("root") + newJob) {
        println("root started")
        // 동일 코드
    }

    delay(1500)
    newJob.complete()
}

앞선 실행 결과와 비슷하지만, Uncaught Exception이 로그로 출력되지 않았습니다.

root started
parent started
child started // uncaught exception 출력되지 않음

실행 결과의 차이를 그림으로 확인해 볼 수 있습니다. 공통적으로 예외가 루트 코루틴, newJob까지 전파는 되어 취소는 이뤄졌습니다. 하지만 launch에서는 바로 예외가 던져져 Uncaught Exception이 발생한 반면, async의 경우는 예외가 던져지지 않았다는 차이가 존재합니다.

이는 await()가 호출되기 전까지는 예외가 결괏값과 함께 Deferred 객체 내부에 저장되어 있을 뿐, 실제로 던져지지 않기 때문입니다. async 코루틴 내부에 예외가 존재하더라도 await() 호출이 없다면, 예외가 표면적으로 드러나지 않고 JVM에서 Uncaught Exception으로 간주되지 않습니다.

만약 예외가 던져지길 원한다면, async 루트 코루틴의 반환값인 Deferred에 await()를 호출해야 합니다.

fun main(): Unit = runBlocking {
    val newJob = Job()
    val deferred = async(CoroutineName("root") + newJob) {
        // 동일 코드
    }
    
    // 예외 throw
    deferred.await() 

    // await() 호출로 필요 없어짐
    // delay(1500)
    newJob.complete()
}

root started
parent started
child started
Exception in thread "main" java.lang.RuntimeException: exception in CoroutineName(child)

다시 한 번 강조하자면, 지금까지 설명한 특성은 루트 async 코루틴에 await()가 호출되어야 적용된다는 것임에 유의해야 합니다.

아래 코드에서는 await()를 root Deferred에서는 호출하지 않고, parent Deferred에서만 호출하고 있습니다. 이 코드를 실행해 보면, 예외 발생 없이 프로그램이 종료됩니다.

fun main(): Unit = runBlocking {
    val newJob = Job()
    val d1 = async(CoroutineName("root") + newJob) {
        println("root started")
        
        val d2 = async(CoroutineName("parent")) {
            println("parent started")
            
            val d3 = async(CoroutineName("child")) {
                println("child started")
                println("child completed")
            }

            delay(700)
            // parent 예외
            throw RuntimeException("exception in ${this.coroutineContext[CoroutineName.Key]}")
            println("parent completed")
        }

        delay(1000)
        // parent Deferred의 await() 호출
        d2.await()
        println("root completed")
    }

    delay(1500)
    // root Deferred의 await()를 호출하지 않음!
    // d1.await()
    newJob.complete()
}

root started
parent started
child started
child completed

참고) 일단 코루틴이 자식으로부터 예외를 넘겨받기만 한다면, 그 코루틴에는 취소 요청이 이뤄집니다. 따라서 async 코루틴이 루트 코루틴인 경우, 예외를 넘겨받고 await()를 호출하지 않아도 루트 코루틴은 취소됩니다.

fun main() = runBlocking {
    val newJob = Job()
    val d = async(CoroutineName("root") + newJob) {
        async(CoroutineName("parent")) {
            async(CoroutineName("child")) {
                delay(500)
                throw RuntimeException("exception in ${this.coroutineContext[CoroutineName.Key]}")
            }
            delay(700)
        }
        delay(1000)
    }

    delay(1500)
    newJob.complete()

    println("d - isCancelled : ${d.isCancelled}, isCompleted : ${d.isCompleted}")
    println("newJob - isCancelled : ${newJob.isCancelled}, isCompleted : ${newJob.isCompleted} ")
}

결과 :
d - isCancelled : true, isCompleted : true
newJob - isCancelled : true, isCompleted : true


Deferred 예외 처리 방법

지금까지 알아본 async 코루틴 빌더와 Deferred의 동작 원리를 바탕으로 예외 처리 방식을 알아보도록 하겠습니다.

😃 try-catch/runCatching 활용

await() 호출부 예외 처리

만약 자식 코루틴 내부에서 예외 처리가 이뤄지지 않는 경우, 루트 async 코루틴의 await()를 호출해야 사용하는 측에서 예외를 포착할 수 있습니다.

fun main(): Unit = runBlocking {
    val newJob = Job()
    val d1 = async(CoroutineName("root") + newJob) {
        println("root started")
        
        val d2 = async(CoroutineName("parent")) {
            println("parent started")
            delay(700)
            throw RuntimeException("exception in ${this.coroutineContext[CoroutineName.Key]}")
            println("parent completed")
        }

        delay(1000)
        println("root completed")
    }

    try {
        // 예외 발생!
        d1.await()
    } catch (e: Exception) {
        if (e is CancellationException) throw e
        println("exception caught in runBlocking")
    }

    job.complete()
}

root started
parent started
exception caught in runBlocking


반면 루트 async가 아닌 async가 반환하는 Deferred에 대한 await()try-catch를 감싸 예외 처리하는 것은 동작하지 않음에 유의해야 합니다.

fun main(): Unit = runBlocking {
    val newJob = Job()
    val d1 = async(CoroutineName("root") + newJob) {
        println("root started")
        
        val d2 = async(CoroutineName("parent")) {
            println("parent started")
            delay(700)
            throw RuntimeException("exception in ${this.coroutineContext[CoroutineName.Key]}")
            println("parent completed")
        }

        delay(1000)

        // 추가
        try {
            d2.await()
        } catch (e: Exception) {
            // 실행되지 않음!
            if (e is CancellationException) throw e
            println("exception caught in parent")
        }

        println("root completed")
    }

    try {
        // 예외 발생!
        d1.await()
    } catch (e: Exception) {
        // 실행됨!
        if (e is CancellationException) throw e
        println("exception caught in runBlocking")
    }

    newJob.complete()
}

root started
parent started
// "exception caught in parent" 출력 x
exception caught in runBlocking

😫 생각해보기
launch 코루틴과 마찬가지로 async 코루틴도 부모 측으로 예외를 전파한다. 이 특성이 어떤 경우에 문제가 될 수 있을까?

기존에 사용하고 있던 스코프 안에 다른 스코프를 만들어, 그곳에 코루틴을 생성하였다고 가정해보겠습니다. 이전의 예시들과 다른 점은 새로운 스코프에 적용되어 있는 최상위 Job에 parent를 명시함으로써 구조화를 깨지 않았다는 것입니다.

fun main(): Unit = runBlocking {
    // 새로운 Job, 부모 명시
    val newJob = Job(parent = coroutineContext[Job.Key])
    // 새로운 스코프 생성
    val newScope = CoroutineScope(newJob)
    // 새로운 스코프에서 코루틴 생성
    val d1 = newScope.async(CoroutineName("root")) {
        println("root started")
        
        async(CoroutineName("parent")) {
            println("parent started")
            delay(700)
            throw RuntimeException("exception in ${this.coroutineContext[CoroutineName.Key]}")
            println("parent completed")
        }

        delay(1000)
        println("root completed")
    }

    try {
        d1.await()
    } catch (e: Exception) {
        if (e is CancellationException) throw e
        println("exception caught in runBlocking")
    }

    newJob.complete()
}

위의 예시처럼 CoroutineScope()를 활용해 직접 스코프를 만들 수도 있지만, coroutineScope 함수를 활용하면 구조를 파괴하지 않고 더욱 편리하게 새로운 스코프를 만들 수 있습니다.

이 코드를 실행하였을 때 예외는 성공적으로 잡았지만, 같은 에러가 uncaught exception으로 다시 출력되며 프로그램이 비정상 종료되었습니다.

root started
parent started
exception caught in runBlocking
Exception in thread "main" java.lang.RuntimeException: exception in CoroutineName(parent)

이는 루트 async 코루틴도 예외를 상위로 전파하기 때문입니다. 루트 async에서 newJob으로 예외가 전파되었고, 다시 newJob의 부모로 설정된 runBlocking 측으로도 예외가 전파되었습니다. 상위로 전파된 예외에 대해서는 아무런 처리가 없었기 때문에 uncaught exception으로 처리되었던 것입니다.

이 사실은 try-catch를 사용한 코드를 주석처리 했을 때의 결과를 통해 확인해볼 수 있습니다.

fun main(): Unit = runBlocking {
    val newJob = Job(parent = coroutineContext[Job.Key])
    val newScope = CoroutineScope(newJob)
    
    // 동일 코드...

    // try {
    //     d1.await()
    // } catch (e: Exception) {
    //     if (e is CancellationException) throw e
    //     println("exception caught in runBlocking")
    // }

    newJob.complete()
}

root started
parent started
Exception in thread "main" java.lang.RuntimeException: exception in CoroutineName(parent) // 예외가 runBlocking까지 전파되었기 때문에 발생

따라서 Deferred의 예외를 처리할 때는 예외가 상위로 전파한다는 특성에도 대처하는 것이 필요합니다.

async 블록 내부에서 예외 처리

예외 발생으로 인한 취소 방지, 예외의 상위 전파 방지
👉 예외로 인한 전체 흐름의 중단을 방지하고, 실패에 대한 대체 로직을 적용하고자 하는 상황에 적용하기 좋다!

launch 블록 내부에서 try-catch를 활용해 직접 예외를 잡아내듯, async 블록 내부에서도 같은 방식으로 예외를 처리할 수 있습니다.

즉각적으로 예외를 잡아냄으로써, 해당 코루틴이 취소되거나 예외가 상위로 전파되는 것을 방지할 수 있습니다. 즉 상위 코루틴이나 전체 프로그램이 영향을 받지 않도록 격리된 오류 제어가 가능해집니다.

이 방법을 활용하여 앞서 살펴본 코드의 문제를 해결해보겠습니다. 예외가 발생하는 async의 내부 로직을 다시 try-catch로 감쌌습니다.

fun main(): Unit = runBlocking {
    val newJob = Job(parent = coroutineContext[Job.Key])
    val newScope = CoroutineScope(newJob)
    val d1 = newScope.async(CoroutineName("root")) {
        println("root started")
        
        async(CoroutineName("parent")) {
            // try-catch 추가
            try {
                println("parent started")
                delay(700)
                throw RuntimeException("exception in ${this.coroutineContext[CoroutineName.Key]}")
            } catch (e: Exception) {
                if (e is CancellationException) throw e
                println("exception caught in parent")
            }

            println("parent completed")
        }

        delay(1000)
        println("root completed")
    }

    // try-catch 없이도 원활히 동작
    d1.await()
    newJob.complete()
}

parent 측에서 발생한 예외가 즉시 잡혔을 뿐만 아니라, 부모 코루틴으로도 전파되지 않아 전체적인 흐름 상의 문제 없이 프로그램이 원활히 실행되었습니다.

root started
parent started
exception caught in parent
parent completed
root completed

😆 SupervisorJob 활용

예외로 인한 취소는 이뤄져야 하지만, 예외 전파는 제한하고 싶다면?

코루틴의 부모가 SupervisorJob이라면, 코루틴의 부모로 예외가 전파되지 않습니다. 이 특성을 활용하여 일부 코루틴만 취소해야 하는 상황을 구현해 보겠습니다.

fun main(): Unit = runBlocking {
    val d1 = async(CoroutineName("root")) {
        println("root started")
        
        supervisorScope {
            async(CoroutineName("parent1")) {
                println("parent1 started")
                delay(700)
                throw RuntimeException("exception in ${this.coroutineContext[CoroutineName.Key]}")
                println("parent1 completed")
            }

            launch(CoroutineName("parent2")) {
                println("parent2 started")
                delay(800)
                println("parent2 completed")
            }
        }

        println("root completed")
    }

    // 예외가 전파되지 않았으므로 try-catch 불필요
    d1.await()
}

supervisorScope가 예외 전파를 제한했기 때문에 parent1 내부에서 발생한 예외가 상위로 전파되지 않았습니다.

parent1에서 예외가 발생했지만, parent2는 영향을 받지 않고 정상적으로 완료되었으며, 다른 코루틴들도 정상적으로 실행이 완료되었습니다.

root started
parent1 started
parent2 started
parent2 completed
root completed

🙅‍♂️ CoroutineExceptionHandler 활용?

📗 공식 문서 읽어 보기
👉 ... (중략) async 빌더에서 항상 모든 예외를 잡아 Deferred에 두기 때문에, async 코루틴은 uncaught exception을 유발하지 않는다. async에 CoroutineExceptionHandler를 둔다고 아무 효과도 얻을 수 없다.

앞서 await() 호출부 예외 처리에서 살펴보았던 문제점을 해결하기 위해, 루트 코루틴이 launch일 때처럼 CoroutineExceptionHandler를 활용할 수도 있을 것이라 생각할 수 있습니다.

fun main(): Unit = runBlocking {
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        println("uncaught exception")
    }
    val job = Job(parent = coroutineContext[Job.Key])
    val newScope = CoroutineScope(job + exceptionHandler)
    
    val d1 = newScope.async(CoroutineName("root")) {
        println("root started")
        async(CoroutineName("parent")) {
            println("parent started")
            delay(700)
            throw RuntimeException("exception in ${this.coroutineContext[CoroutineName.Key]}")
            println("parent completed")
        }

        delay(1000)
        println("root completed")
    }

    try {
        d1.await()
    } catch (e: Exception) {
        if (e is CancellationException) throw e
        println("exception caught in runBlocking")
    }

    job.complete()
}

참고) runBlocking의 context로 CoroutineExceptionHandler를 명시할 수 없습니다.

하지만 아래 코드를 실행해 보면, 이전과 같은 문제가 발생하고 있습니다. 상위로 전파된 예외가 CoroutineExceptionHandler로 잡히지 않는 모습입니다.

root started
parent started
exception caught in runBlocking
Exception in thread "main" java.lang.RuntimeException: exception in CoroutineName(parent)

⚠️ 주의
await()를 try-catch로 예외처리 하지 않고 발생하는 예외는 코루틴 내부 코드가 실행되는 와중에 발생하는 uncaught exception이 아닙니다.

CoroutineExceptionHandler는 launch처럼 코루틴 블록 내부가 실행되는 와중 발생하는 예외에 대해서만 작동합니다. 반면 async에서 발생하는 예외는 항상 await() 시점에서 try-catch로 직접 처리해줘야 하며, 비동기 결과를 사용하는 쪽에서 책임지고 예외를 감싸야 합니다.


마치며

요약

launch vs async, 반환 타입의 차이

launch는 결괏값이 없는 Job, async는 결괏값이 있는 Deferred를 반환합니다.

예외 발생 측면에서의 공통점

둘 다 예외가 발생하면 부모 코루틴으로 예외를 전파합니다.

예외 발생 측면에서의 차이점

하지만 루트 코루틴일 때 예외 발생 상 차이가 존재합니다. launch는 예외가 즉시 던져져 Uncaught Exception이 발생합니다. 반면 async는 예외가 Deferred에 저장되고 await()를 호출해야 예외가 던져집니다. await()를 호출하지 않으면 예외가 드러나지 않습니다.

Deferred 예외 처리 방법

Deferred의 예외는 try-catch로 직접 처리하거나, await() 호출부에서 처리해야 합니다. 루트가 아닌 async의 await()에서 예외를 잡아도 상위로 전파되기 때문에, 이에 대비하지 않으면 프로그램이 예상치 못한 방향으로 동작할 수 있습니다.

이 때 async 내부에서 직접 예외를 처리하면 상위 전파와 전체 취소를 막을 수 있습니다. 혹은 SupervisorJob이나 supervisorScope를 사용하면 예외가 상위로 전파되지 않아, 일부 코루틴만 실패하게 할 수 있습니다. CoroutineExceptionHandler는 launch에는 적용되지만, async에는 효과가 없습니다.

이러한 Deferred의 특성을 잘 고려하여, 적절한 예외 처리를 진행해야 할 것입니다.

참고 자료

https://kotlinlang.org/docs/exception-handling.html
https://medium.com/androiddevelopers/exceptions-in-coroutines-ce8da1ec060c
https://developer88.tistory.com/entry/Coroutine%EC%9D%98-await-%EC%82%AC%EC%9A%A9%EC%8B%9C-Exception-Handling%EC%97%90-%EA%B4%80%ED%95%98%EC%97%AC-Kotlin

profile
Software Engineer

0개의 댓글