오늘날, 안드로이드 개발은 Kotlin이 메인 프로그래밍 언어로 자리잡고 있습니다.
이로 인해 비동기 작업은 Kotlin Coroutines(코루틴)이 완전히 자리를 잡고 있는데요.
일부 역사가 긴 서비스를 보유한 회사에서는 Rx 계열을 사용하는 경우도 있지만, 아마 코루틴으로 마이그레이션하는 작업을 진행하지 않을까 예상됩니다.
코루틴은 구조화된 동시성이라는 원칙을 따르고 있습니다.
https://kotlinlang.org/docs/coroutines-basics.html#structured-concurrency
구조화된 동시성은 동시성 작업간에 부모-자식과 같은 관계를 형성하여 가독성, 유지보수성, 안정성이 뛰어난 동시성 코드를 작성할 수 있게 해주는 접근 방식입니다.
그렇다면 코루틴은 어떻게 구조화된 동시성을 제공할까요?
CoroutineScope 알아보기
코루틴을 실행하기 위해 우리는 CoroutineScope가 필요합니다.
Android에서 개발을 하고 있다면 ViewModelScope, LifecycleScope와 같은 것들을 일반적으로 사용하고 있을텐데, 이들은 모두 CoroutineScope 인터페이스를 구현한 하나의 구현체입니다.
package kotlinx.coroutines
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}
public operator fun CoroutineScope.plus(context: CoroutineContext): CoroutineScope =
ContextScope(coroutineContext + context)
kotlinx.coroutines 패키지의 CoroutineScope에서 확인할 수 있듯이, CoroutineScope는 CoroutineContext 속성을 가지는 인터페이스입니다.
그런데 파일을 보면 CoroutineContext를 합쳐 ContextScope를 만들어내는 plus 오퍼레이터를 구현한 것을 볼 수 있습니다.
CoroutineContext는 무엇일까요?
CoroutineContext 알아보기
package kotlin.coroutines
@SinceKotlin("1.3")
public interface CoroutineContext {
public operator fun <E : Element> get(key: Key<E>): E?
public fun <R> fold(initial: R, operation: (R, Element) -> R): R
public operator fun plus(context: CoroutineContext): CoroutineContext = ...
public fun minusKey(key: Key<*>): CoroutineContext
public interface Key<E : Element>
public interface Element : CoroutineContext {
public val key: Key<*>
public override operator fun <E : Element> get(key: Key<E>): E? = ...
public override fun <R> fold(initial: R, operation: (R, Element) -> R): R = ...
public override fun minusKey(key: Key<*>): CoroutineContext = ...
}
}
CoroutineContext는 현재 실행되고 있는 코루틴에 대한 요소의 집합체입니다. 코루틴이 동작하는 쓰레드, 취소 처리, 디버깅 정보등과 같은 정보를 담고 있습니다.
이러한 코루틴에 대한 요소들은 Element라는 인터페이스로 구현이 되며, Element는 또 다시 CoroutineContext를 구현하고 있습니다.
따라서 코루틴은 CoroutineContext들이 엮이는 구조로 이해할 수 있습니다.
그럼 우리가 사용하는 CoroutineContext 또는 Element는 무엇이 있는지 알아봅시다.
CoroutineDispatcher
우리는 코루틴이 동작할 쓰레드를 지정하기 위해 Disaptcher를 지정합니다.
예를들면 Dispatchers.Default, Dispatchers.IO, Dispatchers.Main, Dispatchers.Main.immediate와 같은 것들이 있습니다.
코드를 타고 들어가면 이들은 모두 CoroutineDispatcher를 구현하고 있는 것을 확인할 수 있습니다.
package kotlinx.coroutines
public abstract class CoroutineDispatcher :
AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {
...
}
public abstract class AbstractCoroutineContextElement(public override val key: Key<*>) : Element {
...
}
위와 같이 CoroutineDispatcher는 Element 인터페이스를 구현하는 AbstractCoroutineContextElement 추상 클래스를 상속하고 있음을 확인할 수 있습니다.
결과적으로 CoroutineDispatcher는 CoroutineConext가 됩니다.
Job
Job은 코루틴의 실행 결과로 반환되는 객체입니다. (정확히는 Job 인터페이스의 구현체를 반환합니다.)
package kotlinx.coroutines
public interface Job : CoroutineContext.Element {
public companion object Key : CoroutineContext.Key<Job>
@ExperimentalCoroutinesApi
public val parent: Job?
public val isActive: Boolean
public val isCompleted: Boolean
public val isCancelled: Boolean
public fun start(): Boolean
public fun cancel(cause: CancellationException? = null)
}
Job은 CoroutineContext.Element를 구현하고 있고, CoroutineContext.Element는 CoroutineContext를 구현하고 있는 구조이기 때문에 Job은 결과적으로 CoroutineContext 입니다.
CoroutineExceptionHandler
코루틴의 예기치 못한 예외처리를 위해 CoroutineExceptionHandler를 사용할 수 있습니다.
package kotlinx.coroutines
public interface CoroutineExceptionHandler : CoroutineContext.Element {
public companion object Key : CoroutineContext.Key<CoroutineExceptionHandler>
public fun handleException(context: CoroutineContext, exception: Throwable)
}
CoroutineExceptionHandler는 CoroutineContext.Element를 구현하고 있고, CoroutineContext.Element는 CoroutineContext를 구현하고 있는 구조이기 때문에 CoroutineExceptionHandler는 결과적으로 CoroutineContext 입니다.
CoroutineName
CoroutineName은 실행중인 코루틴의 이름을 지정하는 클래스입니다.
코루틴의 동작 자체에 영향을 주지는 않고 디버깅 시에 활용할 수 있습니다.
package kotlinx.coroutines
public data class CoroutineName(
val name: String
) : AbstractCoroutineContextElement(CoroutineName) {
public companion object Key : CoroutineContext.Key<CoroutineName>
override fun toString(): String = "CoroutineName($name)"
}
CoroutineName은 AbstractCoroutineContextElement를 구현하고 있습니다.
AbstractCoroutineContextElement는 CoroutineContext.Element를 구현하고 있고, CoroutineContext.Element는 CoroutineContext를 구현하고 있는 구조이기 때문에 CoroutineExceptionHandler는 결과적으로 CoroutineContext 입니다.
구조화된 동시성
CoroutineContext는 plus 오퍼레이터를 구현하고 있기 때문에, 요소들을 합쳐 CoroutineContext를 만들수 있습니다.
그리고 원하는 CoroutineScope에서 사용할 수 있습니다.
val job = Job()
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable -> ... }
val dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate
val name: CoroutineName = CoroutineName("MyCoroutineName")
val context: CoroutineContext = job + exceptionHandler + dispatcher + name
CoroutineScope(context).launch {
// Do something
}
viewModelScope.launch(context) {
// Do something
}
lifecycleScope.launch(context) {
// Do something
}
이렇게 실행한 코루틴 내에서 또 다른 코루틴을 실행하면 어떻게 될까요?
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}
CoroutineScope는 확장함수로 launch를 구현하고 있습니다. 즉, CoroutineScope 내에서는 얼마든지 새로운 코루틴을 실행할 수 있음을 의미합니다.
이 함수에 디폴트로 전달되는 CoroutineConext가 EmptyCoroutineContext인 것을 볼 수 있습니다.
이것은 CoroutineConext를 구현하는 싱글턴 객체(object) 입니다.
왜 EmptyCoroutineContext가 디폴트로 전달될까요?
다음은 CoroutineConext의 plus 오퍼레이터의 구현입니다.
public interface CoroutineContext {
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)
}
}
}
...
}
만약 현재의 CoroutineContext에 새로운 CoroutineContext를 더하려고 할 때, 그 대상이 EmptyCoroutineContext이면 fold 동작을 시키지 않고 바로 자기 자신을 반환하는 것을 볼 수 있습니다.
fold 내부에서도 루프 중 EmptyCoroutineContext를 만났을 때는 예외 처리가 되어있는 것을 볼 수가 있습니다.
참고로 plus 함수는 현재 CoroutineContext와 전달된 CoroutineContext의 요소들을 모두 포함하는 CoroutineContext를 반환하며, 중복되는 요소는 제거됩니다.
이런 결과 덕분에 상위 CoroutineContext에서 launch를 통해 하위 CoroutineContext를 실행하면 특별한 EmptyCoroutineContext를 전달하지 않는 한 상위 CoroutineContext를 따르게 되는 하위 코루틴이 생성됩니다.
이렇게 생성된 하위 코루틴은 Job을 반환하게 되고, 이 Job이 상위 코루틴과 참조를 가지게 됩니다.
코루틴은 트리구조와 같은 상위-하위, 또는 부모-자식 구조를 형성하게 되며, 다음과 같은 특징을 가지게 됩니다.
첫번째, 상위(또는 부모) 코루틴은 하위(또는 자식) 코루틴이 완료될 때 까지 코루틴을 종료하지 않습니다.
// 1(부모)
CoroutineScope(context).launch {
// 2(1의 자식)
launch {
// do something 2
// 2-1(2의 자식)
launch {
// do something 3
}
}
// 3(1의 자식)
launch {
// do something 4
}
// do something 5
}
예를들어 위와 같은 코루틴이 있다고 해 봅시다.
1(부모) 코루틴은 자신의 작업을 실행하고 2, 3자식 코루틴이 완료될 때 까지 기다립니다.
2(1의 자식) 코루틴은 2-1(2의 자식) 코루틴이 완료될 때 까지 기다립니다.
두번째, 상위(또는 부모) 코루틴이 취소되면 하위 코루틴은 모두 취소됩니다.
첫번째 예시를 동일하게 가정하겠습니다.
1(부모) 코루틴이 취소되면 2(1의 자식), 3(1의 자식) 코루틴이 취소됩니다.
2(1의 자식) 코루틴이 취소되면 그의 자식인 2-1(2의 자식) 코루틴도 취소됩니다.
세번째, 하위 코루틴이 취소되더라도 부모 코루틴은 취소되지 않습니다.
첫번째 예시를 동일하게 가정하겠습니다.
2(1의 자식) 또는 3(1의 자식) 코루틴이 취소되더라도 1(부모) 코루틴은 취소되지 않습니다.
2-1(2의 자식) 코루틴이 취소되더라도 2(1의 자식) 코루틴은 취소되지 않습니다.
이러한 특징 덕분에 어떤 하나의 코루틴이 취소되어도 부모, 또 다른 자식 코루틴들은 동작할 수 있게 됩니다.
코루틴은 이런 특징들로 하여 구조화된 동시성을 제공하고 있습니다.
우리는 비동기 코드들을 마치 동기적인 것 처럼 위에서 아래로 작성할 수 있습니다. 이 덕에 읽기 쉽고 얼마든지 코루틴을 추가하고 제거할 수 있죠.
또한 하위 코루틴이 취소되더라도 상위 코루틴은 취소되지 않는 특징 덕분에 특별한 예외처리 없이도 안정적으로 비동기 코드를 실행할 수 있습니다.
코루틴의 예외 처리
앞서 설명했던 코루틴의 특징 중 하나는 자식 코루틴이 취소되어도 부모 코루틴이 취소되지 않는다는 것이 있었습니다.
그럼 자식 코루틴이 "예외"를 발생시켜도 동일하게 동작할까요?
예를들면 다음과 같습니다.
// 1(부모)
CoroutineScope(context).launch {
// 2(1의 자식)
launch {
// do something 2
// 2-1(2의 자식)
launch {
throw Exception("are parent coroutines safe?")
}
}
// 3(1의 자식)
launch {
// do something 4
}
// do something 5
}
안타깝게도, 위 코루틴은 부모 코루틴까지 취소되어 나머지 상황이 발생합니다.
코루틴의 취소는 Cancellationexception을 발생시키는데, 이것은 코루틴 내부적으로 예외처리가 되어 있습니다.
그리고 코루틴은 이 예외를 감지하여 취소처리를 하게 됩니다.
그렇기 때문에 코루틴이 에러 상황에 빠지지 않고 취소임을 인지하고 작업을 이어나갈 수 있습니다.
이것 외의 예외는 코루틴에서 에러가 발생했음을 의미하고 이것을 상위로 전파하게 됩니다.
그렇기 때문에 부모 코루틴도 에러가 발생하게 되어 코루틴이 취소가 되고, 부모 코루틴이 취소됨에 따라 하위 코루틴도 모두 취소가 되게 됩니다.
이러한 상황을 방지하기 위한 것으로 SupervisorJob을 사용할 수 있는데, 이것에 대한 포스팅은 다음으로 이어가겠습니다.
'개발 > Android' 카테고리의 다른 글
[Android] Jetpack Compose - Layout으로 커스텀 레이아웃 만들기 (0) | 2025.02.02 |
---|---|
Kotlin Coroutines, 에러 처리와 SupervisorJob (0) | 2025.01.29 |
[Android] Retrofit Call Adapter를 활용해서 효과적인 에러 핸들링 하기 (0) | 2025.01.24 |
[Android] Compose Multiplatform + Circuit + Koin (0) | 2025.01.12 |
[Android] APK 파헤치기 (0) | 2025.01.05 |