[Android] Coroutine 예외 처리 파헤치기

kk_jang93·2024년 7월 12일
0
post-thumbnail

코루틴을 막 학습한 사람에게 코루틴은 매우 간단하고 자바스크립트의 async, await와 비슷하게 보이기도 해서 비동기 프로그래밍을 위한 아주 쉽고 훌륭한 도구로 보일 수 있습니다. 실제로 쉽고 훌륭한 도구이긴 하지만요.

하지만 코루틴을 더 깊게 살펴보면 실제로 걸리기 쉬운 함정들이 많이 존재합니다. 예외 처리나 취소를 try-catch 블록을 통해 간단히 할 수 있으리라 생각하지만 실제로는 복잡한 매커니즘으로 동작하고 있기에 많은 것들이 잘못될 수도 있습니다.

  • 코루틴에서 어떻게 예외를 잡고 처리해야 하는지
  • 코루틴에서 예외 처리가 일반적으로 어떻게 작동하는지
  • 코루틴이 취소되거나 코루틴을 취소할 때 무엇을 고려해야 하는지
dependencies
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0'

많은 사람들이 오해하는 코루틴의 예외 처리

launch에서의 예외 처리

먼저 CoroutineScope인 lifecycleScope를 통해 코루틴 빌더인 launch를 수행하는 MainActivity 코드를 작성한 뒤, 내부에 예외를 던지는 자식 코루틴을 생성해봅시다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch { // 부모 코루틴
            launch { // 자식 코루틴
                throw Exception()
            }
        }
    }
}

여기서 많은 사람들은 try-catch 블록을 통해 간단히 해당 예외를 처리할 수 있으리라 생각합니다. 실제로 아래의 코드를 실행하면 어떻게 될까요?

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch {
            try {
                launch {
                    throw Exception()
                }
            } catch (e: Exception) {
                println("Caught Exception: $e")
            }
        }
    }
}

  • try-catch 블록을 분명히 사용했는데도 크래시가 발생했네요.
    이것이 바로 코루틴에서 try-catch 블록이 제대로 동작하지 않는 경우입니다. 왜 이렇게 동작하는지 이해하기 위해서는 먼저 CoroutineScope와 코루틴이 동작하는 방식을 이해해야 합니다.

  • 우리는 일반적으로 외부에 lifecycleScope, viewModelScope 또는 직접 생성한 Custom Scope와 같은 CoroutineScope를 가지고 이 Scope 내부에서 코루틴을 실행합니다.

lifecycleScope.launch {
    try {
        launch {
            throw Exception()
        }
    } catch (e: Exception) {
        println("Caught Exception: $e")
    }
}

그리고 위의 코드처럼 해당 코루틴 내부에 자식 코루틴을 만들 수 있는데, 자식 코루틴 내부에서 예외를 던지면 어떤 일이 발생할까요?

참고로 해당 예외에 대한 가장 일반적인 예시는 Retrofit을 사용한 API 호출에서 HttpException이 발생하여 서버가 404 Not Found를 응답하는 경우 예외를 던지는 상황입니다.

순서는 다음과 같습니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch {  // 4. 예외가 처리되지 않고 root 코루틴까지 예외가 전파됨(이 시점에 앱 크래시 발생)
            try {
                launch {  // 3. 여전히 예외가 처리되지 않았으므로 이 코루틴으로도 예외가 전파됨
                    launch {  // 2. 현재 코루틴으로 예외가 전파됨(propagation)
                        throw Exception()  // 1. 예외 발생
                    }
                }
            } catch (e: Exception) {
                println("Caught Exception: $e")
            }
        }
    }
}

코루틴에서 예외가 전파되는 것처럼 코루틴의 취소(cancellation)에서도 같은 일이 발생합니다.

코루틴이 취소될 때 CancellationException을 던지는데 이 예외는 항상 코루틴에서 처리되거나 잡히기 때문에 무언가 잘못되거나 나쁜 것이 아닙니다. 하지만 취소는 여전히 코루틴 트리로 전파되므로 부모 코루틴을 포함해 모든 자식 코루틴들이 특정 코루틴이 취소된 것을 감지합니다.

코루틴 트리는 structured concurrency의 동작 방식을 통해 코루틴이 내부적으로 트리 구조(부모-자식)의 형태로 관리가 되고 있음을 추측할 수 있는데 더 자세한 내용을 보고 싶으시다면 이 글을 참고하시면 좋을 것 같습니다.

async에서의 예외 처리

lauch와 비교해서 asyc에서 예외 처리가 동작하는 방식의 차이점은 async는 await를 호출할 때 누적된 예외를 던지는 것입니다.

아래의 코드에서 await()는 root 코루틴인 launch 블록을 async 블록이 실행되고 0.5초 뒤에 Result 값을 사용가능 할 때까지 suspend됩니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch {
            val string = async {
                delay(500L)
                "Result"
            }
            println(string.await())
        }
    }
}

위에서 async는 await를 호출할 때 누적된 예외를 던진다고 했습니다. 그렇다면 아래의 코드는 어느 시점에 크래시를 발생시킬까요?

lifecycleScope.launch {  // 2. 예외가 부모 코루틴으로 전파되어 앱에 크래시가 발생 
    val string = async {
        delay(500L)
        throw Exception("error")  // 1. 예외를 던지자마자
        "Result"
    }
    println(string.await())
}

이 코드에선 await가 호출되는 시점에 예외를 던지지 않습니다. launch를 사용하고 있기 때문에 async 블록 내에서 예외를 던지자마자 앱에 크래시가 발생합니다.

그러면 개념을 다른 예시로 이해해보기 위해 이번엔 await() 라인을 삭제해보겠습니다.

lifecycleScope.launch {
    val string = async {
        delay(500L)
        throw Exception("error") 
        "Result"
    }
}

이 코드도 여전히 크래시가 발생합니다. 위에서 async는 await를 호출할 때 누적된 예외를 던진거나 전파한다고 했음에도 불구하고 왜 그러는걸까요?

코루틴에서 예외가 전파되는 매커니즘을 생각해봅시다. 위 코드에서 async 블록은 자식 코루틴이기 때문에 해당 블록 내에서 예외를 던지면 부모 코루틴인 launch 블록으로 예외를 전파시킵니다. 예외가 처리되지 않았다면 이전 launch 블록에서 예외를 던지는 코드들처럼 즉시 프로그램에 크래시를 발생시킵니다.

하지만 launch를 async로 대체하고 앱을 재실행해보면 크래시가 발생하지 않습니다. 자식 코루틴에서 발생한 예외가 부모 코루틴으로 전파되더라도 둘 다 async 블록이기 때문에 즉시 앱에 크래시를 발생시키지 않습니다.

lifecycleScope.async {
    val string = async {
        delay(500L)
        throw Exception("error") 
        "Result"
    }
}

외부 async 블록의 리턴값을 deferred에 담고 deferred를 다른 스코프 내부에서 deferred.await()를 통해 소비하도록 아래와 같이 코드를 작성하면 앱에 크래시가 발생합니다.

val deferred = lifecycleScope.async {
    val string = async {
        delay(500L)
        "Result" 
    }
}
lifecycleScope.launch {  // 2. launch 블록은 앱에 크래시를 발생시킨다. 
    deferred.await()     // 1. 예외 처리를 별도로 하지 않았으므로 await()가 던진 예외가 위로 전파되고(raise) 
}                     

위와 같이 코드에 deferred가 있다면 아래의 코드처럼 await()를 호출하는 라인을 try-catch 블록으로 감싸주는 것으로 예외를 처리할 순 있습니다. 앱을 실행해도 크래시도 발생하지 않고요.



val deferred = lifecycleScope.async {
    val string = async {
        delay(500L)
        "Result"
    }
}
lifecycleScope.launch {
    try {
        deferred.await()
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

하지만 이렇게 예외를 처리하면 잘못된 상황이 쉬우며 사람들이 많이 실수하는 코드이기도 합니다.

코루틴이 어떻게 동작하는지 더 알아본 뒤, 본문의 끝부분에서 위의 코드에서 발생한 실수에 대해 이야기하겠습니다.


CoroutineExceptionHandler
그전에 try-catch 블록 이외에 예외를 처리하는 방법인 CoroutineExceptionHandler에 대해 알아보고자 합니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val handler = CoroutineExceptionHandler { _, throwable ->
            println("Caught exception: $throwable")
        }

        lifecycleScope.launch(handler) {  // root 코루틴에 handler를 전달
            throw Exception("Error")
        }
    }
}

CoroutineExceptionHandler를 생성한 뒤, 이 handler를 우리가 실행할 코루틴에 적용(install)시킬 수 있는데 반드시 root 코루틴 에게 적용시켜야 합니다.

CoroutineExceptionHandler는 root 코루틴의 모든 타입의 자식 코루틴에서 잡히지 않은 예외들을 처리할 수 있는 방법입니다.

주의해야할 점은 CoroutineExceptionHandler는 CancellationException을 잡지 않는다는 것입니다. 그렇기 때문에 코루틴 하나가 취소되더라도 CoroutineExceptionHandler의 블록은 실행되지 않습니다.

CancellationException과 코루틴은 코루틴이 취소되었다고 앱에서 크래시가 발생하는 것을 원하지 않을 것이기 때문에 기본적으로 처리가 되고, 앞에서 언급했듯 잡히지 않은 예외들만을 처리합니다.

내용이 많아 글이 길어지므로 다음 글 에서 이어서 진행하겠습니다.

profile
앱개발을 사랑하는 개발자

0개의 댓글