목표
- 코루틴 컨텍스트와 디스패처의 역할 및 중요성 학습
- 다양한 디스패처(Main, IO, Default) 사용 시나리오 실습
- 컨텍스트 전환과 예외 처리 방법 탐구
CoroutineContext
coroutineContext는 코루틴을 실행하는 실행 환경을 설정하고 관리하는 인터페이스로, CoroutineContext 객체는 CoroutineDispatcher, CoroutineName, Job 등의 객체를 조합하여 코루틴의 실행 환경을 설정합니다. 따라서, CoroutineContext 객체는 코루틴 실행 및 관리의 핵심 역할을 합니다.
CoroutineContext는 launch나 async 코루틴 빌더의 파라미터로 설정할 수 있으며, 해당 파라미터에 CoroutineContext를 명시적으로 지정하면 특정 스레드풀로 수행 제한, 코루틴 이름 설정 등을 할 수 있습니다.
CoroutineContext 구성 요소
- CoroutineDispatcher
- 코루틴 실행 시, 어떤 스레드에서 수행(특정 스레드로 수행 제한, 특정 스레드 풀에서 실행하도록 함)할지 결정합니다.
- CoroutineName
- 코루틴의 이름을 설정합니다.
- CoroutineExceptionHandler
- 코루틴에서 발생한 예외처리를 결정합니다.
- Job
- 코루틴의 추상체로 코루틴을 조작하는 데 사용합니다.
CoroutineContext는 위 구성 요소를 각각 고유한 키로 갖고 있어 중복을 허용하지 않습니다.
- 위 예시에서 launch에 대해 context를 지정함으로써 부모로부터 상속받은 context의 요소가 override된 것을 확인할 수 있습니다.
CoroutineContext의 특정 key에 접근할 때 클래스명으로 접근할 수 있는 이유?
코틀린 공식 문서의 Companion objects 내용에는 companion object의 멤버는 클래스명으로 단순하게 호출될 수 있다고 명시되어 있다. CoroutineContext로 사용될 수 있는 CoroutineName의 클래스를 열어보면, 위처럼 Key가 companion object로 작성되어 있는 것을 확인할 수 있다. 이로 인해, CoroutineContext에서 key를 조회할 때 클래스명으로도 조회가 가능한 것이다.
참고로, Job이나 CoroutineDispatcher 역시, key를 companion object로 가지고 있어서 클래스명으로 바로 조회할 수 있습니다.
CoroutineDispatcher
앞서 보았던 CoroutineContext에서 이야기했듯이, launch나 async 같은 코루틴 빌더는 CoroutineContext 파라미터를 받고 해당 파라미터를 통해 코루틴이 수행될 스레드를 지정할 수 있습니다.
위 예시에서 coroutineContext에 어떤 Dispatcher를 지정하느냐에 따라 수행하는 스레드가 변경되는 것을 확인할 수 있습니다.
- launch에 아무런 CoroutineContext를 지정하지 않으면, CoroutineScope의 컨텍스트를 상속 받고 해당 컨텍스트에서 실행됩니다.
- 위 예시에서는 부모가 runBlocking이었기 때문에 runBlocking 컨텍스트를 받아 main 스레드에서 실행되었습니다.
CoroutineDispatcher 종류
- Dispatchers.Unconfined
- 위 예시에서는 main 스레드에서 실행된 것으로 보이지만 사실 이 dispatcher는 다른 메커니즘으로 동작합니다.
- Unconfined 디스패처는 호출된 스레드에서 코루틴을 시작하며, 중단점이 있어 재개될 경우에는 재개한 스레드에서 동작하게 됩니다.
- 기존 스레드에서 코루틴을 시작하기 때문에, 스레드 스위칭이 일어나지 않아 다른 디스패처에 비해 비용이 저렴합니다.
- 하지만, 메인 스레드에서 Unconfined 디스패처를 가진 코루틴이 실행될 경우 어플리케이션 전체가 블로킹될 수 있어 현업에서는 잘 사용하지 않습니다.
- Dispatchers.Default
- 디스패처를 설정하지 않았을 때 설정되는 기본 디스패처로 CPU 집약적인 연산을 수행하도록 설계된 디스패처입니다.
- 스레드를 많이 사용할수록 스레드 스위칭 비용이 늘어나기 때문에, CPU 연산이 많은 작업을 할 때에는 너무 많은 스레드풀을 갖고 있는게 유리하지 않아 Default 디스패처가 유용합니다.
- Default 디스패처는 코드가 실행되는 컴퓨터의 CPU 개수와 동일한 수(최소 2개 이상)의 스레드 풀을 사용합니다.
- 위 코드의 limitedParallelism에서 볼 수 있듯이 CORE_POOL_SIZE만큼만 사용할 수 있습니다.
- 여러 코루틴에 대해 Dispatchers.Default로 설정할 때 특정 작업이 비용이 많이 들 경우 스레드가 고갈될 수 있습니다. 이를 방지하고자 limitedParallelism 속성을 사용해 같은 스레드 풀을 사용하되, 특정 수 스레드만 사용하도록 제한할 수 있습니다.
- 공유 자원을 사용하여 싱글 스레드로 동작해야 할 경우, limitedParallelism을 1로 설정하여 싱글스레드로 제한할 수 있습니다.
- 디스패처를 설정하지 않았을 때 설정되는 기본 디스패처로 CPU 집약적인 연산을 수행하도록 설계된 디스패처입니다.
- Dispatchers.Main
- 안드로이드에서 메인 스레드는 UI와 상호작용하는데 사용하는 유일한 스레드입니다.
- 메인 스레드는 자주 사용되어야 하지만, 아주 조심스럽게 다루어야 합니다. 메인 스레드가 블로킹되면 전체 어플리케이션이 멈춰버립니다.
- Dispatchers.Main은 kotlinx-coroutines-android 아티팩트를 사용하면 안드로이드에서 사용이 가능합니다.
- Dispatchers.IO
- 파일을 읽고 쓰는 경우, DB 조회 등 블로킹 함수를 호출하는 경우처럼 I/O 연산으로 스레드를 블로킹할 때 사용하는 디스패처입니다.
- IO 디스패처의 스레드풀은 64개(또는 더 많은 코어가 있다면 해당 코어의 수)로 제한됩니다.
- 앞 예시에서 보이는 것처럼, IO 디스패처를 사용하면, 최소 64개의 스레드풀을 가져 50개 작업을 바로 스레드풀에 모두 할당할 수 있어서 1초만에 작업이 완료됨을 알 수 있습니다.
- IO 디스패처 역시, Default 디스패처와 마찬가지로 limitedParallelism 함수를 통해 IO 디스패처가 사용하는 스레드풀을 제한할 수 있습니다. 다만, Default와 다르게 IO 디스패처에서 limitedParallelism을 사용할 경우 독립적인 스레드 풀을 가진 새로운 디스패처가 만들어집니다.
- IO 디스패처에서 limitedParallelism 함수를 사용해 만들어진 디스패처는 스레드 수가 64개로 제한되지 않고 더 많은 수로 사용할 수 있습니다.
- 스레드를 블로킹하는 경우가 잦은 클래스가 있을 때, 자기만의 한도를 가진 커스텀 디스패처를 지정할 수 있어 유용합니다.
- Dispatchers.Default와 Dispatchers.IO는 같은 스레드풀을 사용합니다.
- 앞선 예시들을 보면 Default, IO 모두 DefaultDispatcher-worker 스레드를 사용하는 것을 확인할 수 있습니다.
- 같은 스레드풀을 사용하기 때문에 Default 디스패처 컨텍스트 내부에서 일부 작업을 IO 컨텍스트로 실행될 수 있고, 스레드의 한도는 각 디스패처별로 독립적이라 다른 디스패처의 스레드를 고갈시키지 않습니다.
- 예를 들어, Default 디스패처와 IO 디스패처를 모두 활성화한 상태라면 각 디스패처에 맞는 스레드풀 개수의 총합 만큼 공유 스레드풀에 만들어 사용합니다. (코어 수가 10개인 컴퓨터라면, 10 + 64 = 74개의 스레드가 공유 스레드풀에 생성됨)
Coroutine Exception Handling
앞서 보았던 CoroutineContext는 구성 요소로 CoroutineExceptionHandler라는 예외 처리기를 지원합니다. 이름 그대로 예외를 어떻게 처리할지 지정할 수 있으며, 예외를 처리하는 람다식인 handler를 매개변수로 갖습니다.
위 예시처럼, 예외가 발생했을 때 어떻게 처리할지 작성하면, 예외 발생 시 해당 구문이 동작하게 됩니다.
처리되지 않은 예외만 처리하는 CoroutineExceptionHandler
앞서 본 예시를 위와 같이 변경하면 exceptionHandler가 제대로 동작하지 않습니다. 그 이유는, CoroutineExceptionHandler는 처리되지 않은 예외만 핸들링하기 때문입니다.
만약, 자식 코루틴이 부모 코루틴으로 예외를 전파하면 자식 코루틴에서는 예외가 처리된 것으로 보아 자식 코루틴에 설정된 CoroutineExceptionHandler는 동작하지 않습니다.
구조화된 코루틴상에 여러 CoroutineExceptionHandler 객체가 설정되어 있어도, 마지막으로 예외를 전파받는 위치(예외가 처리되는 위치)에 설정된 CoroutineExceptionHandler 객체만 예외를 처리합니다.
- 위 예시 코드를 보면, launch에서 발생한 예외가 runBlocking으로 전파가 되었기 때문에 launch에만 적용된 exceptionHandler가 동작하지 않았습니다.
runBlocking으로 전파하지 않고, 지정한 coroutineExceptionHandler 객체가 예외를 처리하게 하는 가장 간단한 방법은 루트 Job과 함께 coroutineExceptionHandler를 설정하는 것입니다.
- 루트 Job을 설정함으로써, 앞선 runBlocking과 관게를 끊고(코루틴 구조화 깨짐) 예외 전파를 제한함으로써, 지정한 exceptionHandler가 동작하게 할 수 있습니다.
- 참고) 부모-자식 관계(코루틴 구조화)를 끊는 일이기 때문에 일반적으로 launch의 context 매개변수로 Job을 전달하지 않습니다.
SupervisorJob과 CoroutineExceptionHandler 함께 사용한다면?
기본적으로, supervisorJob 객체가 부모 Job으로 설정되면, 자식 코루틴으로부터 예외를 전파받지 않습니다. 하지만, supervisorJob 객체는 예외를 전파 받지 않을 뿐, 어떤 예외가 발생했는지는 자식 코루틴으로부터 전달받습니다. 따라서, 만약 SupervisorJob 객체와 CoroutineExceptionHandler 객체가 함께 설정되면 해당 exceptionHandler에 의해 예외가 처리됩니다.
자식 코루틴은 부모 코루틴으로 예외를 전파하지 않고, 전달만 하더라도 자식 코루틴에서 예외는 처리된 것으로 봅니다.
참고
앞선 예시 코드를 아래처럼 구성하게 되면 앞서 보았던 것과 다르게, launch1에 적용한 exceptionHandler가 적용됩니다.
그 이유는, apply를 사용하게 되면 supervisorScope.launch()를 호출한 것과 동일하기 때문입니다.
위 코루틴 공식 문서에서 확인할 수 있듯이, supervisorScope 내에서 직접 시작된 코루틴은 본인의 스코프의 coroutineExceptionHandler를 사용해 루트 코루틴이 예외 처리 하는 것처럼 예외를 처리합니다.
위 예시를 보면 coroutineScope와 supervisorScope간 차이를 확인할 수 있습니다.
설정한 scope만 바꿨을 뿐인데, coroutineScope 내에서 시작된 launch는 부모로 예외 전파가 되어 launch에 설정된 exceptionHandler가 동작하지 못했음을 알 수 있습니다.
CoroutineExceptionHandler는 try ~ catch가 아니다
앞선 예시들에서 보았던 것처럼, coroutineExceptionHandler를 통해 예외를 핸들링한다고 해서, 상위로 예외가 전파되지 않는 것이 아닙니다. 따라서, 상위로 예외 전파를 막고자 한다면 위 예시처럼 try ~ catch 구문을 작성해주어야 합니다.
try ~ catch 구문을 작성할 때에는 유의할 점이 있습니다. 위처럼 launch 구문 자체를 try ~ catch로 감싸게 되면 원하는대로 동작하지 않습니다.
- 위처럼 작성하게 되면, try에서 확인하는 부분은 launch로 코루틴을 생성하는 것까지만 확인하고 실제 코루틴 동작 로직인 람다식 실행을 확인해주지 못합니다.
- try ~ catch 대상이 launch 코루틴 빌더 함수 자체의 실행만을 체크하여 람다식은 예외 처리 대상에서 빠지게 됩니다.
async의 예외 처리
launch은 결과값을 반환하지 않지만, async는 결과값을 Deferred 객체로 감싸서 await 호출 시점에 노출하고 있습니다. 따라서, launch보다 좀 더 예외 처리를 신경 써주어야 합니다.
- Deferred가 반환되는 부분에서도 예외 전파가 되지 않도록 처리를 해주어야 하고, await()을 통해 실제 결과값을 가져올 때에도 예외 처리를 해주어야 합니다.
- Deferred에서 예외처리를 하지 않으면 async 내부에서 발생한 예외가 전파될 수 있습니다.
- await() 호출 시, async 내부에서 예외가 발생해 결과값을 가져올 수 없다면, 예외가 노출됩니다.
- 위 예시 중 오른쪽의 경우, supervisorScope를 사용하여 deferred가 반환되는 부분에 따로 예외 처리가 없어도 상위로 예외 전파가 되지 않았지만, supervisorScope를 사용하지 않을 땐 왼쪽 예시처럼 deferred에서도 예외 전파를 막아주어야 합니다.
전파되지 않는 예외
코루틴은 CancellationException 에외가 발생할 경우에는 부모 코루틴으로 예외가 전파되지 않습니다.
- CancellationException은 코루틴의 취소에 사용되는 특별한 예외이기 때문에 전파되지 않습니다.
CancellationException을 사용하는 대표적인 함수로는 withTimeout이 있습니다. withTimeout은 코루틴에 대해 제한시간을 두고 작업을 실행할 수 있도록 만드는 함수입니다.
- 위 정의해서 볼 수 있듯이, withTimeout은 주어진 시간이 종료되면 TimeoutCancellationException을 발생시키고, 해당 Exception은 CancellationException을 구현 상속하고 있습니다.
- 위 withTimeout 예시는 timeout은 0.1초이고, 코루틴이 걸리는 시간은 0.2초라 완료되기 전에 timeout으로 인해 취소가 일어납니다.
- 이 때 발생한 예외는 CancellationException이기 때문에 부모까지 예외 전파가 일어나지 않아서 20번째 줄이 출력됩니다.
- withTimeoutOrNull은 withTimeout과 달리, 실행 시간을 초과하더라도 코루틴이 취소되지 않고 결과가 반환해야 할 때 사용합니다.
- withTimeoutOrNull은 시간 초과 시 TimeOutCancellationException을 외부로 전파하는 대신 내부적으로 해당 예외를 처리하고 null을 반환합니다.
runBlocking에는 예외처리 핸들러가 왜 동작하지 않을까?
공식 문서에 따르면, 메인 runBlocking 상에서 수행되는 코루틴은 exceptionHandler가 동작하지 않습니다. 왜냐하면, 자식 코루틴이 exceptionHandler 설정 유무와 상관없이 예외가 발생하여 종료되면 메인 코루틴은 취소되기 때문입니다.
- 위처럼 코드를 작성할 경우, runBlocking은 자식 코루틴을 생성하고 해당 코루틴에 exceptionHandler를 설정하는 개념이 되기 때문에 자식 코루틴 상위에 exceptionHandler가 없어서 exceptionHandler에 핸들링되지 않습니다.
궁금한 점
- SupervisorJob 객체는 예외를 전파받지 않을 뿐, 어떤 예외가 발생했는지에 대한 정보는 자식 코루틴으로부터 받습니다.
- 따라서, SupervisorJob 객체와 CoroutineExceptionHandler 객체가 함께 설정되면 SupervisorScope에 설정된 CoroutineExceptionHandler에 의해 예외가 처리됩니다.
- 하지만, 위 예제를 돌렸을 때 자식 코루틴의 핸들러가 동작합니다.
위 궁금한 점에 대해 stackoverflow에 질문을 올렸고, 받은 답변을 이용해 SupervisorJob과 CoroutineExceptionHandler 함께 사용한다면? 파트에 정리해두었습니다.
CoroutineScope에서 직접 launch를 호출할 때 동작 의문점
- 앞선 예시와 다르게 SupervisorJob() 대신, 일반 Job()을 넣어도 상위 Job()을 가진 coroutineScope의 handler가 작동을 하지 않았습니다.
- 해당 내용에 대해서도 stackoverflow에 질문을 올려두었고, 답변은 아래와 같았습니다.
- 생각한대로 동작하지 않은 이유는 CoroutineScope() 함수에서 직접 launch를 호출했기 때문입니다.
- scope 하위에 launch를 호출한 것이 아닌, 새로운 scope 상에서 바로 launch를 호출한 것이기 때문에 해당 스코프에서 launch가 루트 코루틴이 되며, 스코프를 만들 때 exceptionHandler가 바로 replace되어서 supervisor라는 이름의 exceptionHandler는 무시된 것입니다.
- 앞선 예시와 다르게, CoroutineScope 함수 대신 coroutineScope 중단 함수를 사용하였기 때문에, runBlocking과의 구조화도 깨지지 않아서, 예외가 메인 코루틴까지 던져짐에 따라 예외 처리가 되지 않은 것을 확인할 수 있습니다.
참고 자료
- 도서 - 코틀린 코루틴의 정석(조세영 저)
- 도서 - 코틀린 코루틴(마르친 모스카와 저)
- Kotlin docs - companion objects
- Kotlin docs - Coroutine context and dispatchers
- Kotlin docs - exceptions in supervised coroutines
- Why can't runBocking take in CoroutineExceptionHandler?
- Why does not exception handled by parent coroutine exception handler?
'PROGRAMMING LANGUAGE > KOTLIN' 카테고리의 다른 글
[Effective Kotlin] 2장 가독성 (0) | 2024.06.27 |
---|---|
[Effective Kotlin] 1장 안정성 (1) | 2024.06.23 |
[Kotlin Coroutine] 코루틴 빌더와 비동기 패턴의 이해 (0) | 2024.06.16 |
[Kotlin Coroutine] 코루틴 기본 이해 & JVM에서의 async (0) | 2024.06.08 |
[Kotlin] Collections의 Iterable과 Sequences 차이점 (0) | 2023.08.23 |