이 글은 아래의 문서를 바탕으로 정리된 글임을 알려드립니다.
원문 : https://medium.com/androiddevelopers/coroutines-first-things-first-e6187bf3bb21
목차
- Part 1 : CoroutineScope, Job, CoroutineContext
- Part 2 : Cancellation in coroutines
- Part 3 : Exceptions in coroutines
- Part 4 : Coroutines & Patterns for work that shouldn't be cancelled
개요
이 게시물은 Part 2, Part 3의 기초 문서입니다. CoroutineScope, Job 및 CoroutineContext와 같은 핵심 코루틴 개념이 작성되어있습니다. 이 글을 읽기 전에 이 문서에서 말하는 철학 두 가지를 가슴 속에 새겨두고 스크롤을 내려봅시다.
- 작업을 '취소'하는 것은 메모리와 배터리를 낭비하는 많은 작업을 피하는 데에 중요합니다.
- 적절한 '예외 처리'는 훌륭한 UX의 핵심입니다.
CoroutineScope
코루틴의 실행을 관리하고 취소할 수 있는 핵심적인 인터페이스입니다. 대표적으로 두 가지 기능이 있습니다.
코루틴의 시작과 취소 및 관리 : CoroutineScope는 launch나 async(모두 CoroutineScope의 확장함수)와 같은 코루틴 빌더를 제공하고, 이를 사용하여 코루틴을 시작하고 관리할 수 있습니다. 실행중인 코루틴은 scope.cancel()을 호출해 언제든지 취소가 가능합니다.
코루틴의 수명주기 제어 : 특정 레이어의 앱에서 코루틴의 수명주기를 시작하고 제어하는데에도 CoroutineScope이 쓰입니다. Android와 같은 일부 플랫폼에서는 viewModelScope 나 lifecycleScope 같은 일부 lifecycle 클래스에서 이미 CoroutineScope를 제공하는 라이브러리가 있습니다.
CoroutineScope는 생성자 매개변수로 CoroutineContext를 전달해야합니다. (코드 상에서는 Job() + Dispatchers.Main에 해당)
// Job and Dispatcher are combined into a CoroutineContext which
// will be discussed shortly
val scope = CoroutineScope(Job() + Dispatchers.Main)
val job = scope.launch {
// new coroutine
}
Job
Job은 코루틴의 핸들입니다.
launch 또는 async를 사용하여 생성한 모든 코루틴은 고유한 코루틴을 식별하고 해당 수명주기를 관리하는 Job 인스턴스를 반환합니다. 앞서 언급한 것처럼 CoroutineScope에 Job을 전달하여 해당 수명주기를 핸들링할 수도 있습니다.
CoroutineContext
앞서 언급한 것처럼 CoroutineScope를 생성할 때 전달해야 할 매개변수가 CoroutineContext라고 설명드렸습니다.
CoroutineContext는 코루틴의 동작을 정의하는 요소들의 모임입니다. 이는 다음과 같은 요소들로 구성됩니다.
Job : 코루틴의 수명주기를 제어합니다.
CoroutineDispatcher : 작업을 적절한 스레드에 디스패치합니다.
CoroutineName : 코루틴의 이름으로, 디버깅에 유용합니다.
CoroutineExceptionHandler : 처리되지 않은 예외를 처리합니다. 이에 대해서는 Part 3에서 다루겠습니다.
아래 코드는 CoroutineScope를 생성하고 launch를 이용해 코루틴을 생성하여 Job을 반환받는 코드입니다.
val scope = CoroutineScope(Job() + Dispatchers.Main)
val job = scope.launch {
// New coroutine that has CoroutineScope as a parent
val result = async {
// New coroutine that has the coroutine started by
// launch as a parent
}.await()
}
여기서 우리가 CoroutineScope 생성자의 매개변수가 CoroutineContext 임을 알고있습니다. 그러나 위 코드에서는 Job() + Dispatchers.Main을 전달합니다. 즉, Job과 CoroutineDispatchers를 '+' 연산하면 CoroutineContext를 반환합니다. 어떻게 가능할까요?
위에서 언급된 Job, CoroutineDispatcher, CoroutineName, CoroutineExceptionHandler는 CoroutineContext을 상속받습니다.
즉, Job과 CoroutineDispatcher는 모두 같은 CoroutineContext입니다.
그리고 CoroutineContext는 아래와 같이 plus 연산이 구현되어있습니다. 그래서 '+' 연산이 가능하며, 연산 시 두 요소가 합쳐진 CoroutineContext를 반환하게 됩니다.
아래는 CoroutineContext의 내부 코드입니다.
public interface CoroutineContext {
/**
* Returns the element with the given [key] from this context or `null`.
*/
public operator fun <E : Element> get(key: Key<E>): E?
/**
* Accumulates entries of this context starting with [initial] value and applying [operation]
* from left to right to current accumulator value and each element of this context.
*/
public fun <R> fold(initial: R, operation: (R, Element) -> R): R
/**
* Returns a context containing elements from this context and elements from other [context].
* The elements from this context with the same key as in the other one are dropped.
*/
public operator fun plus(context: CoroutineContext): CoroutineContext =
if (context === EmptyCoroutineContext) this else // fast path -- avoid lambda creation
context.fold(this) { acc, element ->
val removed = acc.minusKey(element.key)
if (removed === EmptyCoroutineContext) element else {
// make sure interceptor is always last in the context (and thus is fast to get when present)
val interceptor = removed[ContinuationInterceptor]
if (interceptor == null) CombinedContext(removed, element) else {
val left = removed.minusKey(ContinuationInterceptor)
if (left === EmptyCoroutineContext) CombinedContext(element, interceptor) else
CombinedContext(CombinedContext(left, element), interceptor)
}
}
}
/**
* Returns a context containing elements from this context, but without an element with
* the specified [key].
*/
public fun minusKey(key: Key<*>): CoroutineContext
/**
* Key for the elements of [CoroutineContext]. [E] is a type of element with this key.
*/
public interface Key<E : Element>
/**
* An element of the [CoroutineContext]. An element of the coroutine context is a singleton context by itself.
*/
public interface Element : CoroutineContext {
/**
* A key of this coroutine context element.
*/
public val key: Key<*>
public override operator fun <E : Element> get(key: Key<E>): E? =
@Suppress("UNCHECKED_CAST")
if (this.key == key) this as E else null
public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
operation(initial, this)
public override fun minusKey(key: Key<*>): CoroutineContext =
if (this.key == key) EmptyCoroutineContext else this
}
}
이에 대한 자세한 내용은 심명표님 블로그 게시물에 아주 잘 설명되어있습니다. 관심 있으시면 참고해주시면 되겠습니다.
https://myungpyo.medium.com/reading-coroutine-official-guide-thoroughly-part-1-7ebb70a51910
코루틴은 계층 구조로 이루어져있으며, 루트는 보통 CoroutineScope 입니다. 밑의 그림을 참고해주세요.
Job lifecycle
Job은 다음과 같은 상태를 가질 수 있습니다: New, Active, Completing, Completed, Cancelling, Cancelled
상태 자체에는 접근하거나 제어할 수는 없지만, Job은 isActive, isCancelled, isCompleted와 같은 프로퍼티를 제공하여 상태를 확인할 수 있습니다
코루틴이 Active인 상태에서, 실패하거나 job.cancel()를 호출하여 취소 요청을 했을 때, Cancelling 상태로 전환됩니다(isActive == false, isCancelled == true). 모든 자식 코루틴이 작업을 완료하면 코루틴은 그 때 Cancelled 상태로 전환되며 isCompleted == true가 됩니다.
부모 CoroutineContext
부모 context는 다음 공식을 따릅니다.
Parent context = Defaults + 상속된 CoroutineContext + arguments
- Default : 일부 요소에는 기본값들이 있습니다. 예를 들면 CoroutineDispatcehr의 기본값은 Dispatchers.Default이고, CoroutineName의 기본 값은 "coroutine"입니다.
- 상속된 CoroutineContext : 코루틴을 생성한 CoroutineScope 또는 코루틴의 CoroutineContext을 뜻합니다. 즉, 부모의 CoroutineContext입니다.
- arguments : 자식 CoroutineContext는 선언 시, arguments를 전달 받아서 부모의 CoroutineContext를 덮어쓸 수 있습니다. 즉, 부모의 CoroutineContext에는 CoroutineDispatcher가 Dispatchers.Main이라는 값이 저장되었있다고 가정해봅시다. 이 부모에서 자식 코루틴을 생성할 때, CoroutineDispatcher로 Dispatchers.IO를 넘겼다면, 해당 값으로 덮어쓰여지게 되는 것입니다. 상속된 CoroutineContext의 해당 요소보다 우선합니다.
임의로 생성한 CoroutineScope가 아래 그림과 같은 컨텍스트 정보를 가진다고 가정해봅시다.
위 그림의 스코프에서 생성되는 새로운 코루틴의 컨텍스트는 다음과 같습니다.
New coroutine context = 부모 CoroutineContext + Job()
이러한 자식 코루틴을 생성한다고 가정해봅시다.
val job = scope.launch(Dispatchers.IO) {
// new coroutine
}
아래는 부모 CoroutineContext과 위 코드로 인해 생성된 그의 자식 CoroutineContext의 그림입니다.
새로운 자식 코루틴을 생성하면서 생긴 변화를 살펴봅시다.
- 부모 CoroutineContext는 launch 빌더의 arguments(Dispatchers.IO)에 의해 재정의된 Dispatchers.IO로 전환되었습니다.
- 자식 CoroutineContext에는 새로운 Job 인스턴스(녹색)가 할당되었습니다.
CoroutineScope는 그 CoroutineScope이 예외 처리를 다루는 방식을 변경하는 SupervisorJob이라는 다른 Job 구현을 갖을 수 있습니다. 따라서 그 스코프로 생성된 새로운 코루틴은 부모 Job으로 SupervisorJob을 가질 수 있습니다. 그러나 코루틴의 부모가 다른 코루틴인 경우, 부모 Job은 항상 Job유형입니다. 자세한 내용은 Part 3에서 다루겠습니다.
여기까지 코루틴의 기본 사항입니다. Part 2, Part 3에서 코루틴의 취소 및 예외를 자세히 다루겠습니다.