본문 바로가기
Android 개발/Coroutine , Flow, Channel

Kotlin Coroutine 총정리 part2 # Cancellation Exception Handling

by Developer88 2022. 10. 9.
반응형

이 글은 이전글에 이어서, Exception Handling과 Cancellation 그리고 여러개의 Job을 다룰때의 주의점등에 대해서 정리해 보겠습니다.

참고로 이전 글을 아래 링크를 참조해주세요.

>> Kotlin Coroutine 총정리 part1 # launch async Context Job CoroutineScope

 

1. Exception Handling

cancel된 Coroutine은 CancellationException을 전파한다는 것을 알게 되었는데요.

이것은 suspension포인트에서는 무시됩니다.

이것은 cancel을 위한 정상적인 방법이기 때문입니다.

 

1-1. Exception의 전파

코루틴의 Exception전파에 있어서는 2가지로 나누어 지는데요.

 

Exception 전파 방법 사용하는 API 특징
Exception전파를
자동으로 처리
launch exception을 uncaught exceptions로 처리.

Java의 Thread.uncaughtExceptionHandler 와 유사함
Exception전파를
유저에게 노출시킴
async
produce
(Channel API)
await와 같이 유저가 final exception을 consume하는 것에 의존함

 

 

설명이 조금 어려울 수 있으니, 코드를 실행해보고 정리해 보겠습니다.

참고로 전역스코프인 GlobalScope에서 실행시킨 이유는 이들을 root coroutine으로 실행시키기 위해서 입니다.

먼저 실행을 시켜보고 좀 더 이해해 보겠습니다.

 

 

launch에서 throw된 Exception은 아래와 같이 로그가 찍히는 것을 볼 수 있구요.

async에서 throw된 Exception은 await()함수에서 try..catch로 잡혔습니다.

launch는 Exception이 바로 전파되는 반면,

결과값을 가지는 async는 Exception이 발생하면 일단 내부에서 catch했다가,

await()와 같이 값을 받는 시점에 전파해서 유저가 처리하도록 한다는 것 입니다.

 

 

1-2. 까다로운 async의 Exception 처리

위에까지 보면 async의 Exception처리는 그렇게 어렵지 않아보이는데요.

문제는 GlobalScope는 독립적인 Scope를 가지고 있어서,

실제 프로덕트를 만들때는 사용하지 않도록 권장되는 API라는 것 입니다.

GlobalScope가 아닌 환경에서 async가 어떻게 동작하는지 보도록 하겠습니다.

아래코드는 runBlocking을 root스코프로 가지는 코루틴입니다.

launch안에 자식으로 async가 있구요. 

IllegalStateExceptino 을 내었습니다.

 

 

실행해보면 다음과 같은 결과가 나옵니다.

GlobalScope에서 실행할 때와는 전혀 다른 결과를 보여줍니다.

Exception이 catch되어 print되었음에도 불구하고, 앱이 Crash 된 것을 볼 수 있습니다.

 

 

이것은 왜 그런 것일까요?

Structured Concurrency에 의하면,

Exception이 발생하면 관계를 맺고있는 부모와 자식에게 모두 전파됩니다.

 

그런데, async는 Exception이 발생하면 발생하는 위치에서 전파하는 것이아니고,

async가 catch해서 아무일 없는것처럼 가지고 있다가,

await()를 할 때 이를 전파합니다.

이 때, async가 전파하는 이것은 해당 Scope내로 전부 퍼지게 되어서 부모를 Exception과 함께 종료시키게 됩니다.

try..catch를 await()에서 하면 문제없을 것이라 생각하고 작업하던 개발자는 뒤통수를 맞게 되는것 이지요.

 

위에서 예로 실행해보았던 Globalscope는 스코프가 독립적이라 전파될 부모나자식이 없으니 아무 문제없이 catch되었던 것이구요.

부모의 부모인 root의 scope까지 다 전파되어버린것입니다.

 

그래서 아래와 같이 root에서 try..catch를 실행해 주었습니다.

 

 

 

이제야 Exception이 잡히는 것을 볼 수 있었습니다.

 

 

이외에도를 문제를 해결하는 방법은 다음과 같은데요.

  • root 코루틴 스코프에서 try..catch 해주기
  • root 코루틴스코프에 ExceptionHandler 넣어주기
  • 코루틴 스코프에 SupervisorJob 넣어주기
  • SupervisorScope에서 실행하기

이 중 아직 알아보지 못했던 ExceptionHandler를 넣어주는 방법과,

SupervisorJob 그리고 SupervisorScope에 대해서는

아래에서 정리해 보도록 하겠습니다.

참고로, 아래에서도 다시 언급하겠으나,

runBlocking은 ExceptionHandler가 동작하지 않는 API입니다.

위의 해결방법중 ExceptionHandler를 사용할 필요는 없습니다.

 

1-3. CoroutineExceptionHandler

Java의  Thread.uncaughtExceptionHandler 같은 역할을 하는,

핸들링되지 않은 익셉션을 잡을 수 있는 ExceptionHandler가 Corutine에도 있는데요.

CoroutineExceptionHandler가 그것입니다.

 

만약, 자식 코루틴이 익셉션을 부모코루틴에서 처리하도록 하고,

부모는 그위의 루트 코루틴에게 위임할수도 있는데ㅛ.

 

이 Handler에서 Exception에 대응해서 회복을 한다거나 하는 것은 불가능하구요.

여기서는 로그를 남기고, 유저에게 익셉션에 대해서 메시지를 남기거나,

앱을 restart하는 것들을 할 목적으로 사용할 수 있습니다.

 

아래코드와 같이 ExceptionHandler를 코루틴빌더인 launch에 넣어주면 됩니다.

 

 

실행해보면 다음과 같은 결과를 볼 수 있습니다.

handler가 launch의 익셉션은 핸들링 하였지만, async의 것은 그렇지 못하였습니다.

 

이것은 7-1에서도 다루었지만, async는 익셉션핸들링에 있어서 await()에서 consume하는 것으로 되어있기 때문입니다.

그래서 async라는 코루틴빌더는 우선 모든 익셉션을 내부적으로 catch해버립니다.

그리고 그렇게 catch한 Exception을 Deferred객체에 전달해서, await()에서 컨숨하도록 하는 것 이지요.

그래서 CoroutineExceptionHandler 는 async를 사용할 경우 아무런 효과가 없습니다. 

 

 

그리고 runBlocking에 대해서도 알아둘 것이 있는데요.

runBlocking은 자식이 Exception으로 종료되면 같이 종료되고, ExceptionHandler가 동작하지 않는 API이기 때문입니다.

 

아래와 같이 2개의 자식을 같이 부모가 있는데,

부모에게 핸들러를 넣어주었고, 자식이 Exception을 throw하는 경우도 보겠습니다.

 

 

 

실행해보면 아래와 같이 핸들링된 것을 볼 수 있습니다.

 

 

1-4. Cancellaion Exception 과 Exception처리

코루틴에서 job의 cancel을 CancellationException을 통해서 처리한다고 하였는데요.

그래서, 모든 핸들러들이 CancellationException 만큼은 무시를 하게됩니다.

ExceptionHandler에서 이들을 catch 할 수는 있지만, 디버그용도로 사용하라고 공식문서는 적고 있습니다.

 

그래서 job.cancel()이 실행될 경우,

해당 job은 terminate되지만, 그것의 부모는 cancel()하지 않습니다.

이것은 Exception이 아니라 cancel을 하는 정상적인 동작이기 때문인데요.

 

잊지말아야 할 것은,

cancel()에 대해서만 부모의 코루틴을 terminate시키지 않는 것 이구요.

CacellationException이 아닌, 다른 Exception이 전파된다면,

해당 Exception과 함께 부모에 전파되어 부모는 Cancel()됩니다.

Structured Concurrency 디자인에 따라서 이렇게 작동하도록 설계된 것입니다.

 

아래코드에서는 자식코루틴에 cancel을 해 주었는데요.

실행해 보도록 하겠습니다.

 

 

Exception이 아니고, Cancel이므로,

자식만 캔슬되고 부모는 캔슬되지 않는 것을 볼 수 있습니다.

 

 

1-5. SupervisorJob

코루틴에서 Exception은 부모와 자식, 형제 등 모든 hierarchy를 통해 전파가 되는데요.

 

한방향으로 으로만 전파되는 것이 좋을 때가 있습니다.

만약 UI에서 자식의 task가 Exception으로 fail될 경우, 전체 UI에 모두 영향을 미치게 될 경우 등,

의도적으로 Exception방향을 자식쪽으로 보내는 것이 좋은 경우들이 있습니다.

 

SupervisorJob을 사용하면 Exception이 발생해도,

Cancel을 부모쪽이아니라, 자식쪽으로만 전파되도록 합니다.

 

자식때문에 부모가 Cancel()되버릴 경우,

Structure상 그 부모의 다른 자식에 Cancel이 전파되는데,

이런 문제에서 SupervisorJob이 특히 유용하겠지요.

 

 

간단한 예제를 실행해 보겠습니다.

 

 

 

아래와 같이 Exception이 잘 잡힌것을 볼 수 있는데요.

이후에도 job2는 정상적으로 실행되었습니다.

 

 

참고로 위에서 사용된 joinAll은 jobs.forEach를 해주는 함수입니다.

 

 

이번에는 좀 더 복잡한 코드를 실행해 볼텐데요.

결과를 실행한 후에 보도록 하겠습니다.

 

 

실행해보면 다음과 같은데요.

CoruotineScope의 context에 인자로 supervisorJob이 추가되었는데요.

첫번째 자식은 AssertionError가 발생했는데, 다른 hierarchy로 전파되지 않고,

CoroutineExceptionHandler에 의해 핸들링 되었습니다.

특히 부모의 두번째 자식은 부모가 cancel되지 않았기에 마찬가지로 실행이 되었습니다.

하지만, supervisorJob이 cancel()되자 두번째 자식도 cancel되었습니다.

 

 

1-6. SupervisorScope

위에서 보았던 SupervisorJob을 엘리먼트로 포함한 CoroutineScope를 생성해주는 것이,

SupervisorScope입니다.

구체적으로는 바깥범위의 CoroutineContext를 상속받고,

context의 job을 supervisorJob으로 overide 해주는 역할을 합니다.

supervisorJob과 마찬가지로 자식의 에러가,

부모나, 부모의 다른 자식에게 전파되지 않도록 해 줍니다.

 

 

아래 코드를 실행해 보겠습니다.

 

 

실행결과, 자식코루틴(launch에 의해 실행되는 코루틴)이 AssertionError()를 전파시켰지만,

전달된 handler로 익셉션은 처리되었고,

supervisorScope에 의해서, 

부모는 영향을 받지 않고, The scope is completed를 찍었습니다.

 

 

2. Cancellaion( Cancel )

네트워크 호출이 일어나던 페이지에서,

유저가 보던 페이지를 close하게 되면 더이상 결과를 볼 필요는 없습니다.

그 호출을 cancel하면 될 뿐인데요.

Coroutine에서 이러한 cancel을 하려면 어떠한 것들을 알고 있어야 할지 정리해 보겠습니다.

 

2-1. job객체를 이용한 cancel

위에서 launch와 같은 코루틴 빌더들은 Job객체를 리턴해 준다고 하였는데요.

이 Job객체의 cancel()함수를 사용하면, 해당하는 코루틴을 취소할 수 있습니다.

 

아래는 공식문서의 예제인데요.

우선 실행을 해보고 이해해보도록 하겠습니다.

 

 

 

launch라는 빌더내부의 코루틴 코드에서 repeat는 1천번 반복이 되는데요.

그때마다 0.5초마다 delay가 일어납니다.

runBlocking내부에서는 1300ms동안 delay를 시킨후에,

프린트를 찍고나서, job을 cancel()시킵니다.

원래는 job.join()함수가 있으므로, repeat1000번이 다 실행될 때까지 기다려줘야하는 코드였는데요.

job.cancel()이 들어오면서, launch빌더에서 실행시켰던 코드는 중지되게 됩니다.

 

 

참고로 cancel()함수에는 인자로 cancel의 이유를 제공해 줄 수 있습니다.

 

2-2. Cooperative

공식문서에서는 cancellaion이 cooperative하다고 설명이 되어있는데요.

Coroutine의 cancel을 하기 위해서는 아래와 같은 장치들이 있어야 한다고 합니다.

  • suspend 함수를 사용해서 cancellable한 코드를 작성
  • isActive 또는 Yield 함수 같은 cancel을 체크하며, Cancel이 가능한 포인트를 제공해주는 API사용

 

취소를 하고 싶으면 취소할 수 있는 포인트들이 존재해야하고,

그런 포인트들이 없다면 취소가 되지 않는다는 것 입니다.

그러니 cancel을 하고 싶다면, 코드로서 협조를 하라는 말인데요.

 

좀 더 구체적으로 보면, suspend가 가능한 포인트에서 cancellable Exception을 던져서 cancel을 해야하는데,

그런 포인트가 없으면 못한다.

그래서 취소하고 싶다면, 위에서 언급한 장치들로 그런 포인트들을 만들어 놓으란 의미인것 같네요.

 

A. isActive

한가지 특이한 것은 isActive 인데요.

이 확장 property는 completed되거나 cancell되지 않았는지 체크해주는 역할을 합니다.

cancel을 체크할 수 있는 것이나, Cancel에 Cooperative하다는 것 입니다.

 

 

아래 코드에서 job은 isActive가 있기 때문에 cancellable한 코드가 됩니다.

isActive는 공식문서에서 제시하는 cancellable한 코드를 만드는 방법 중 하나이기도 합니다.

 

 

 

참고로 cancelAndJoin은 cancel()과 join()을 순차적으로 실행시켜 줍니다.

cancel을 해주고, job이 완결되는 것을 기다려 주는 API입니다.

 

 

3번 실행되고, cancel이 된 것을 볼 수 있습니다.

정상적으로 cancel()함수가 동작해서, 1300ms의 delay가 지나고,

cancle()이 실행된 것을 알 수 있습니다.

 

 

B. Yield

위에서도 언급하였지만, 공식문서에서 cancellable 한 코드를 만드는 방법으로 제시한 것은 크게 3가지 인데요.

suspend함수를 써서 supendable한 포인트를 만드는 것과, isActive를 이용하는 것,

그리고, Yield()함수를 사용하는 것 입니다.

 

 

외국 도로에 보면 양보라하는 곳에서 Yield라는 글자가 도로바닥에 적혀있는데요.

이 Yield함수도 그런 의미입니다.

같은 디스패처의 다른 코루틴에게 현재의 코루틴 쓰레드를 양보하는 API라고 공식문서에 적혀있는데요.

연산이 많고 쓰레드를 많이 사용하는 작업의 경우 사용되게 되겠지요.

Return 타입이 Unit으로 명시되어 있는 아무값도 반환하지 않는 함수입니다.

 

 

2-3. finally

cancel이 되더라도 반드시 해야되는 작업들이 있습니다.

파일을 열었다가 닫거나, DB를 close해주어야 하거나 등의 작업들인데요.

그럴경우 finally를 이용해서 반드시 실행되도록 해 줄 수 있습니다.

 

suspend함수는 suspend와 resume의 포인트가 되고,

cancellation이 발생할 경우 CancellationException 을 throw 해 줍니다.

구조적으로 cancel을 하기위해서 Exception을 활용하는 것 인데요.

이를 활용해서, 아래와 같이 finally 구문으로 해당 suspend 함수를 감싸주어서,

cancel이 되더라도 반드시 처리해 주어야 할 것들을 수행해주면 됩니다.

아래 코드에서의 suspend함수는 delay가 되겠지요.

 

 

위의 코드를 실행시키면,

아래와 같이 finally 블록이 실행된 것을 볼 수 있습니다.

 

 

위에서 suspend함수는 suspend와 resume의 포인트가 되고,

cancellation이 발생할 경우 CanccelationException 을 throw 한다고 하였습니다.

한번 catch로 잡아서 로그를 보도록 하겠습니다.

 

 

 

한가지 Coroutine으로 개발을 하면서 생각해 둘 점은,

catch로 Exception처리를 할 경우, 

suspend함수가 cancel이 되는 과정에서 CancellationException을 던진다는 것과 이것이 정상적이라는 사실입니다.

이것을 에러로 보고, 모든 Exception을 처리해버리는 것은 Coroutine을 잘 이해하지 못하고 있다는 의미가 됩니다.

 

 

참고로 Cooperative한 방법으로 제시된,

suspend 함수나 yield()함수는 CancellationException을 발생시키지만,

isActive는 이러한 Exception을 발생시키지 않습니다.

isActive를 사용할 경우, CancellationException을 발생시켜야 할 경우도 있는데요.

이 때 사용할 수 있는 것이 ensureActive()라는 함수입니다.

job이 active한 상태가 아니면 CancellationException을 throw해 줍니다.

 

 

2-4. NonCancellable Coroutine

non-cancellable 코루틴이라는 것이 있습니다.

suspend 함수는 finally블록에서 동작하지 않기 때문인데요.

cancel된 coroutine에서 suspend와 resume 메커니즘이 동작하지 않기 때문입니다.

 

물론 대부분의 파일을 닫는다거나 하는 작업들은 suspend함수가 아니기에 문제가 되는 케이스들은 많지 않은데요.

그럼에도 불구하고 suspend함수가 반드시 필요할 경우에 방법이 있습니다.

아래와 같이 NonCancellable 컨텍스트를 이용해서 Coroutine을 빌드해주어서 사용해주면 됩니다.

 

 

8-5. withTimeout & withTimeoutOrNull

네트워킹이나 파일IO를 하는 경우,

일정시간이상이 걸리면 try를 해보다가, timeout을 걸어주는 것이 일반적인데요.

Coroutine에서는 이런것을 쉽게 할 수 있는 API를 제공해주고 있습니다.

 

withTimeout() 이 대표적인 예인데요.

일정시간이 되면, Corotuine이 취소가 됩니다.

위에서 본것처럼, Kotlin에서는 cancel을 위해서 TimeoutCancellationException을 전파시킵니다.

자식이 있을 경우, 자식 Coroutine까지도 취소가 됩니다.

아래에서는 800ms동안 작업이 완료되지 않으면 TimeoutCancellationException을 throw합니다.

 

 

실행해보면 다음과 같은 결과를 볼 수 있습니다.

 

 

다만, 이렇게 throw되는 것을 try catch로 계속 잡는것은 불편할텐데요.

이 때 유용하게 쓸수있는 것이 withTimeoutOrNull() 입니다.

timeout이 되면 심플하게 null을 return해 줍니다.

 

 

실행해보면 결과는 다음과 같습니다.

 

 

null이 있으니 아래와 같이, kotlin의 elvis연산자를 사용해주면 더욱 좋겠지요.

 

 

2-5. 의미없는 Cancel 

cancel이 되기는 하는데, 의미가 없는 cancel이 되어버리는 경우가 있습니다.

cancel앞에 delay()같은 함수들이 실행되는 것 인데요.

delay가 되는 동안, 이미 job들은 실행되어 버리기 때문에, 의미가 없어져 버리는 것 이지요.

 

아래코드는 suspend함수인 delay()가 들어가 있어 cooperative한데요.

아래에서 800ms의 딜레이가 진행되는동안,

이미 코드는 실행되어버립니다.

 

 

 

코드를 실행시켜 이를 확인해 보겠습니다.

 

 

2-6. CoroutineScope 와 Cancel 

안드로이드 같이 생명주기를 가지고 있는 앱의 경우,

해당 페이지가 닫아질 경우,

그동안 실행되던 모든 비동기코드들이 cancel()이 되어야 합니다.

이런 일을 하기위해, Job들과 Context를 컨트롤하기보다는,

CoroutineScope를 cancel()해 주면 됩니다.

 

class Activity {
    private val mainScope = MainScope()

    fun destroy() {
        mainScope.cancel()
    }

 

물론, 이것은  Kotlin공식문서에 의한 설명으로,

실제로는 jetpack 라이브러리를 통해서 scope를 캔슬할 일은 많지 않습니다.

viewModelScope나 lifecycleScope와 같은 것들이 페이지 종료시,

해당 요소의 lifecycle과 맞추어 cancel되기 때문입니다.

 

중요한 것은 CoroutineScope 안에서 cancel()을 해주면,

그안의 모든 코루틴들이 cancel()이 된다는 것이지요.

 

다만, 이 때도 주의를 할 점은 Scope를 cancel할 경우,

해당 Scope에서는 새로운 코루틴을 launch할 수 없게 되므로,

scope를 cancel할 때는 주의가 필요합니다.

 

3. 여러개의 job을 다룰 때 주의할 점

3-1. Cooperative 코드로 작성할 것

아래와 같이 2개이상의 job을 다룰 때는 주의가 필요한데요.

아무생각없이 코드를 쓰다보면, job1과 job2 모두 cancel될 것으로 생각할 수 있습니다.

하지만, job2는 실행이 되어 로그가 찍혀버립니다.

 

 

job1이 cancelAndJoin()이 실행되는 동안, 

job2에는 suspend포인트가 존재하지 않았기 때문입니다.

위의 cancel에서 배웠던 것처럼 cooperative한 코드가 작성되어야 cancel이 의도대로 실행될 수 있습니다.

(suspend함수, isActive, yield()함수의 활용)

 

cancel이 되지 않은 코드에 대해서 Coroutine은 그냥 순차적으로 실행해야 할 것들을 바로바로 실행시킬 뿐입니다.

 

 

job2에 yield()함수처럼 suspend포인트만 추가해 주어도 의도되로 코드가 동작하는 것을 볼 수 있습니다.

 

 

 

실행하면 아래와 같이 job1과 job2모두 취소되는 것을 볼 수 있습니다.

 

 

3-2. launch 블록 내부의 코드 실행 순서

여러개의 launch블록들의 내부 코드실행순서에 대해서도 한번 보도록 하겠습니다.

로그 출력순서가 the job1이 먼저일것이라고 생각하시는 분들도 있을 수 있는데요.

한번 실행해 보겠습니다.

 

 

가장 먼저 출력되는 것은 "after job1"입니다.

println은 launch블록에서 첫번째 코드이기는 하지만,

launch실행후에, 다음으로 println("the job1")이 먼저 실행된 것을 볼 수 있습니다.

그리고 가장 마지막으로 출력된 것은 "the job3"인데요

 

 

이러한 순서를 먼저 생각한 다음,

cancel이건 join이건, delay를 이용할 수 있어야 하겠습니다.

 

3-3. join 함수에 대한 이해

한가지 경우를 더 생각해 보겠습니다.

아래의 경우와 같이 job1에 join을 실행할 경우, job2는 cancel될 수 있을까요?

 

 

join함수는 완료가 될 때까지 coroutine을 suspend 해 주는데요.

그 동안 다른 Coroutine인 job2는 계속 실행이 되어버립니다.

따라서 job2는 취소되지 못하고 출력되어 버립니다.

 

job2에 cancel함수가 실행되기 이전에 job2와 마지막 completed는 실행이 끝나버리는 것 이지요.

코드를 작성한 개발자는 cancel()을 의도로 코드에 작성하였겠지만,

job2.cancel()라인의 코드는 join()함수덕분에 job1의 실행이 완료되어야 실행이 되는데,

그 즈음에 이미 job2는 이미 실행이 되어서 출력이 되어버린 상태입니다.

 

 

4. 정리

이상으로 Kotlin의 Coroutine에 대해서 정리해 보았습니다.

다음 글인 part3에서는 Kotlin Coroutine 의 Flow 에 대해서 정리해 보도록 하겠습니다.

728x90

댓글