[Android] Coroutine 파헤치기#2

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

이전 글에 이어서 진행하겠습니다.

코루틴의 Scope

이제 특정 코루틴과 자식 코루틴을 어떻게 취소할지를 결정하는 2가지 Scope를 알아보려 합니다.

  • CoroutineScope
  • SupervisorScope

CoroutineScope

아래에 일반적인 CoroutineScope 내부에 2개의 자식 코루틴을 실행하는 코드가 있습니다. 앱을 실행해볼까요?

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        CoroutineScope(Dispatchers.Main).launch {
            launch {
                delay(300L)
                throw Exception("Coroutine 1 failed")
            }
            launch {
                delay(400L)
                println("Coroutine 2 finished")
            }
        }
    }
}

당연하게도 Coroutine 2가 완료되기도 전에 Coroutine 1에서 예외를 던지므로 앱에서 크래시가 발생하고 "Coroutine 1 failed"라는 에러를 확인할 수 있습니다.

그렇다면 CoroutineExceptionHandler를 사용하면 어떻게 될까요?

val handler = CoroutineExceptionHandler { _, throwable ->
println("Caught exception: $throwable")
}

// + 연산자를 통해 2개의 CoroutineContext를 합쳐서 CoroutineScope에 적용
CoroutineScope(Dispatchers.Main + handler).launch {
    launch {
        delay(300L)
        throw Exception("Coroutine 1 failed")
    }
    launch {
        delay(400L)
        println("Coroutine 2 finished")
    }
}

"Caught exception: java.lang.Exception: Coroutine 1 failed"에서 예외를 잡아 앱에 크래시가 발생하지 않았지만 Coroutine 2가 완료된 것을 확인할 수 없습니다.

위에서 2개의 자식 코루틴은 개별적으로 실행되는 것으로 보이는데 왜 Coroutine 1이 Coroutine 2에 영향을 끼친 것처럼 보일까요?

그 이유는 바로 CoroutineScope를 사용했기 때문입니다.

CoroutineScope는 예외를 처리했든 안했든 코루틴이 실패하자마자 모든 자식 코루틴과 전체 Scope를 취소합니다. 다시 정리하자면 CoroutineScope는 단 하나의 코루틴이 실패하더라도 스코프 전체가 취소됩니다. 여기서 실패(fail)는 예외를 던지는 것을 의미합니다.

여기서 다른 버전의 CoroutineScope인 SupervisorScope 개념이 등장합니다.

SupervisorScope

그러면 위의 코드에서 2개의 launch 블록들을 supervisorScope 내부에 넣고 재실행 해봅시다.

val handler = CoroutineExceptionHandler { _, throwable ->
    println("Caught exception: $throwable")
}

CoroutineScope(Dispatchers.Main + handler).launch {
    supervisorScope {  // 자식 코루틴들을 supervisorScope 내부에 넣는다.
        launch {
            delay(300L)
            throw Exception("Coroutine 1 failed")
        }
        launch {
            delay(400L)
            println("Coroutine 2 finished")
        }
    }
}

위의 코드를 실행해 보면 예외도 잡혔고 Coroutine 2도 완료된 것을 확인할 수 있습니다.

SupervisorScope는 내부의 코루틴 하나가 실패하거나 예외를 던지더라도 해당 Scope 내부의 다른 코루틴에게 영향을 주지 않습니다.

즉, 여러 개의 코루틴들을 묶어서 하나가 실패하면 모두 실패할지 아닐지에 대한 동작을 CoroutineScope 또는 SupervisorScope를 통해 정의할 수 있는 것입니다.

이 개념이 중요한 이유는 앱에서 커스텀한 CoroutineScope가 필요해지는 경우가 있기 때문입니다. 컴포넌트의 수명 주기를 관리하기 위해 고유한 CoroutineScope를 작성하여 해당 컴포넌트가 적절히 취소되어 더 이상 사용되지 않도록 위해서 말이죠.

viewModelScope가 그러한 scope의 예시입니다. ViewModel이 clear되면 해당 ViewModel 내에서 실행되는 모든 코루틴들 또한 clear됩니다.

이러한 동작을 하는 custom scope를 생성하기 위해서 많은 사람들이 CoroutineScope(Dispatchers.Main + handler)와 같은 형태의 코드를 사용합니다. 하지만 여기서 가장 큰 실수와 문제점은 자신의 custom scope에 대해 이러한 작업을 수행할 경우, 전체 컴포넌트에서 하나의 코루틴이 실패하면 다른 모든 것들 또한 실패하고 CoroutineScope가 취소된다는 것입니다. Scope가 한 번 취소되면 새로운 코루틴을 다시 실행할 수 없습니다.

이러한 상황이 viewModelScope에서 발생한다고 가정해봅시다. 예시로 하나의 네트워크 호출이 viewModelScope에서 실패하여 예외를 던진다면 내부의 다른 코루틴들도 모두 취소될 것이고, viewModelScope 전체도 취소되어 ViewModel 전체를 다시 생성하지 않는 한 새로운 코루틴을 시작할 수 없습니다. 이것은 우리가 ViewModel에서 원하는 동작이 아닐겁니다.

실제로 ViewModel의 viewModelScope를 확인해보면 내부적으로 SupervisorJob()과 Dispatchers.Main.immediate를 합친 CoroutineContext를 CoroutineScope에 넘겨주고 있는 것을 확인할 수 있습니다. 이건 아주 중요한 부분인데 viewModelScope가 supervisorScope라는 것입니다. 왜냐하면 ViewModel에서 하나의 코루틴이 실패하면 다른 코루틴들도 실패하고 취소되는 동작을 원치 않기 때문이고 이는 lifcycleScope의 내부 구현에서도 동일합니다.

직접 CoroutineScope를 구현하는 경우 이것을 이해하는 것이 매우 중요합니다.

사람들이 자주하는 실수


본문의 위에서 언급한 사람들이 코루틴에서 예외를 처리할 때 자주하는 실수에 대해 다뤄보고자 합니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            val job = launch {
                try {
                    delay(500L)
                } catch (e: Exception) {
                    e.printStackTrace()
                }
                println("Coroutine 1 finished")
            }
            delay(300L)
            job.cancel()
        }
    }
}

위의 코드는 0.5초 뒤에 작업이 완료되는 job을 앱이 실행되고 0.3초 뒤에 취소하는 코드입니다. 일반적인 예상대로라면 job이 완료가 되기도 전에 취소했으므로 Coroutine 1은 완료되지 않아야하는데 Logcat에 CancellationException이 발생한 이후 "Coroutine 1 finished"가 출력된 것을 확인할 수 있습니다. 이러한 예상치 못한 동작이 발생한 이유를 알아봅시다.

위의 코드에서 job에 할당한 코루틴이 취소되면 어떤 일이 발생할까요?

여기서 suspend 함수인 delay()는 코루틴이 취소될 경우 CancellationException을 던집니다. 하지만 delay()는 일반적인 Exception을 처리하는 try-catch 블록 내에서 실행되고 있기에 CancellationException이 try-catch 블록에 의해 처리되어 버립니다. 해당 예외가 이미 처리되어 버렸기 때문에 제대로 전파되지 않으므로 외부의 CoroutineScope는 자식 Coroutine이 취소된 것을 알지 못하는 것이지요. 이것이 여전히 "Coroutine 1 finished"를 출력하는 이유입니다.

delay(), yield() 등과 같은 cancellable suspending function은 코루틴이 취소될 때 CancellationException을 던지는데, 추후 정리를 하여 링크를 연결시켜놓겠습니다.

그러면 이 문제를 어떻게 해결할 수 있을지 고민해봅시다.


방법 1

특정 예외를 정확히 catch하는 것으로 먼저 위의 문제를 해결할 수 있습니다.

이 코드를 실행하면 Logcat에 "Coroutine 1 finished"가 출력되지 않음을 확인할 수 있습니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch { // 2. CancellationException이 부모 Scope까지 제대로 전파되어
            val job = launch {
                try {
                    delay(500L)
                } catch (e: HttpRetryException) {  // 1. CancellationException이 잡히지 않으므로
                    e.printStackTrace()
                }
                println("Coroutine 1 finished")  // 3. 이 라인의 작업을 실행하지 않는다.
            }
            delay(300L)
            job.cancel()
        }
    }
}

방법 2

General Exception을 catch하고 싶을 경우, 해당 예외가 CancellationException일 경우 다시 예외를 던지는 코드를 작성하는 방법으로 해결할 수 있습니다.

마찬가지로 이 코드를 실행하면 Logcat에 "Coroutine 1 finished"가 출력되지 않음을 확인할 수 있습니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {  // 2. CancellationException이 부모 Scope까지 제대로 전파되도록 한다.
            val job = launch {
                try {
                    delay(500L)
                } catch (e: Exception) {
                    if (e is CancellationException) {
                        throw e  // 1. CancellationException일 경우 예외를 다시 던져
                    }
                    e.printStackTrace()
                }
                println("Coroutine 1 finished")
            }
            delay(300L)
            job.cancel()
        }
    }
}

코루틴의 취소를 제대로 전파하지 않는 실수는 여러 사람의 코드에서 꽤나 발견되는 실수입니다. 하지만 이러한 실수로 인해 작성한 코루틴이 엉망이 될 수 있고, 이는 코루틴을 취소했는데도 여전히 작업을 수행하기 때문에 리소스를 낭비하는 결과를 초래합니다.

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

0개의 댓글