코루틴 스터디를 하며 정리 중인 시리즈 글
- 왜 코루틴을 써야할까?
- [Coroutine] 코루틴 빌더, 코루틴 컨텍스트
- [Coroutine] 구조화된 동시성
- [Coroutine] 코루틴 스코프 함수
- [Coroutine] 디스패처
- [Coroutine] 코루틴과 공유상태
🎄 구조화된 동시성
'왜 코루틴을 써야할까?'에서 구조화된 동시성에 대해 아주 잠깐 설명했었다.
이 포스팅을 통해서 좀 더 자세한 이야기를 다뤄보려고 한다.
구조화된 동시성(Structured Concurrency)이란, 비동기 작업을 구조화함으로써 비동기 프로그래밍을 보다 안정적이고 예측 가능하게 만드는 원칙이다. 코틀린 코루틴은 비동기 작업인 코루틴을 부모-자식 관계로 구조화함으로써 코루틴을 안전하게 관리 및 제어하게 만들어졌다. 구조화된 코루틴의 특징을 하나하나 알아보자.
🌱 부모 코루틴의 실행 환경은 자식 코루틴에게 상속된다.
'[Coroutine] 코루틴 빌더, 코루틴 컨텍스트'에서 학습했던 내용을 상기시켜 보자. CoroutineContext는 코루틴의 데이터를 저장하고 전달하는 방법으로 쓰였다. CoroutineContext에서는 코루틴의 이름인 CoroutineName, 코루틴의 실행 환경을 결정하는 CoroutineDispatcher, 코루틴의 생명주기를 관리하는 Job 등을 가질 수 있다.
그리고 이 CoroutineContext는 코루틴 빌더에 인자로 넘길 수도 있었다. 코루틴 빌더에 명시적으로 CoroutineContext를 넘기지 않으면, 부모에서 자식으로 컨텍스트가 전달됐다. 아래는 이를 확인하는 예제 코드다. 실행 결과를 살펴보면 다른 요소들은 동일해도 Job은 다른 것을 확인할 수 있다. Job은 서로를 참조할 수 있는 부모-자식 관계를 가질 뿐, 상속되지는 않는다.
@Test
fun 부모_코루틴에서_Context_상속() = runTest {
println("부모 코루틴")
printCoroutineContext(coroutineContext)
launch {
println("자식 코루틴")
printCoroutineContext(coroutineContext)
println("parent's Job -> ${coroutineContext[Job.Key]?.parent}")
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun printCoroutineContext(context:CoroutineContext) {
println("CoroutineName -> ${context[CoroutineName.Key]}")
println("CoroutineDispatcher -> ${context[CoroutineDispatcher.Key]}")
println("CoroutineExceptionHandler -> ${context[CoroutineExceptionHandler.Key]}")
println("Job -> ${context[Job.Key]}")
}
🌱 작업을 제어할 수 있다.
하나의 커다란 작업을 여러 개의 작은 작업으로 쪼갠다면 아래와 같은 그림이 만들어질 수 있다. (하나의 작업이 하나의 코루틴 내에서 동작한다고 가정했다.)
이 많은 코루틴들을 어떻게 안전하게 관리할 수 있을까? 자식 코루틴에서 예외나 취소가 발생한다면 부모 코루틴은 어떻게 해야 하지? 구조화된 코루틴은 아래 특성을 통해 코루틴들을 안전하게 관리한다.
- 코루틴에 취소가 요청되면 자식 코루틴으로 취소를 전파한다.
- 부모 코루틴은 모든 자식 코루틴이 실행 완료되어야 완료할 수 있다.
🌱 코루틴이 실행되는 범위를 제한할 수 있다.
CoroutineScope 객체를 이용하면 코루틴이 실행되는 범위를 제한할 수 있다. 이에 대해서 이해하기 위해서는 CoroutineScope에 대해 알아야 한다.
🎁 CoroutineScope
Scope는 '범위'라는 뜻을 갖고 있다. CoroutineScope 객체는 자신의 범위 내에서 생성된 코루틴들에게 실행 환경을 제공하고, 이들의 실행 범위를 관리하는 역할을 한다. CoroutineScope 코드를 살펴보면 CoroutineContext를 인자로 가진 인터페이스다.
생성 시에는 CoroutineScope() 메서드를 이용하면 되는데, 코루틴 실행 환경인 CoroutineContext을 인자로 전달해야 한다.
CoroutineScope의 확장 함수인 launch를 통해서 CoroutineScope가 어떻게 이용되는지, 코루틴의 실행 환경은 어떻게 설정되는지 살펴보자. 아래는 launch의 로직에 주석을 추가한 코드다.
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) // 새로 만든 컨텍스트를 이용해 Job을 생성한다
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine // Job을 반환한다
}
우리는 위 코드를 통해 두 가지 사실을 알 수 있다.
💥 1. 코루틴 빌더의 람다에서 coroutineContext에 접근이 가능한 이유
launch 함수의 람다식에서는 coroutineContext에 접근할 수 있다. 이는 CoroutineScope가 수신 객체, 즉 receiver로 제공되었기 때문이다. (수신 객체에 대해 모른다면 코틀린 공식 문서를 참고하길 바란다.)
@Test
fun launchTest() = runTest {
launch {
println(coroutineContext[CoroutineName])
}
}
💥 2. 자식 코루틴에 실행 환경이 상속되는 이유
코루틴 빌더에서 coroutineContext 인자를 명시적으로 넘겨주지 않는다면, 부모 코루틴의 CoroutineContext를 넘겨주게 된다. CoroutineContext가 상속되는지 코드로 살펴보자.
launch의 코드를 보면 newCoroutineContext() 메서드를 통해 인자로 주어진 컨텍스트를 이용해 새로운 컨텍스트를 만든다. 해당 메서드의 내부를 보니, this.coroutineContext와 인자로 주어졌던 coroutineContext가 폴드되는 것을 볼 수 있다.
📌 (번외) launch 함수가 반환하는 값은 Job이 맞을까?
launch 함수의 반환 값을 보면 LazyStandaloneCoroutine() 또는 StandaloneCoroutine()의 결괏값을 반환하고 있다. 각 값의 타입이 Job이 맞는지 확인하고자 한다.
LazyStandaloneCoroutine()과 StandAloneCoroutine()의 반환 타입은 모두 AbstractCoroutine을 상속받는다.
그리고 AbstractCoroutine은 Job을 구현하고 있다. (IntelliJ의 ctrl+H 단축키를 이용하면 상속 관계를 확인할 수 있다.)
📌 (번외) CoroutineScope와 coroutineScope는 다르다.
CoroutineScope는 코루틴 범위에 대한 개념을 정의하는 인터페이스이고, coroutineScope는 CoroutineScope 내에 선언되어 있는 중단 함수로, 내부적으로 CoroutineScope를 생성한다. corouteinScope에 대해서는 '[Coroutine] 코루틴 스코프 함수'에서 더 자세히 설명하기로 한다.
🌠 구조화를 돕는 Job
'[Coroutine] 코루틴 빌더, 코루틴 컨텍스트'에서 Job에 대해 학습했었다. Job은 코루틴의 구조화에도 중요한 역할을 한다.
🔹 부모-자식 코루틴의 연결고리
부모 코루틴과 자식 코루틴의 CoroutineContext를 살펴봤을 때, CoroutineName이나 Dispatcher 등은 동일했지만 Job은 다른 값을 가진 것을 확인할 수 있다. Job은 부모 코루틴의 것을 그대로 상속하지는 않지만, Job의 parent, children 속성을 이용해 부모/자식 코루틴을 참조할 수 있다.
@Test
fun 부모_자식_코루틴_확인() = runTest(CoroutineName("coroutineTest")) {
val parentJob = coroutineContext[Job.Key]
launch {
val childJob = coroutineContext[Job.Key]
println("parentJob -> ${parentJob}")
println("childJob -> ${childJob}")
println("parentJob.children ${parentJob?.children?.iterator()?.next()}")
println("childJob.parent ${childJob?.parent}")
}
}
🔹 코루틴 스코프와 cancel()
CoroutineScope 객체의 범위에 속한 모든 코루틴을 취소하려면 CoroutineScope.cancel이 호출된다. cancel 내부 코드를 보면 Job의 cancel()이 호출된다.
🔻 코루틴 스코프의 취소 과정 그림으로 살펴보기
1. CoroutineScope에서 cancel() 호출
2. Job의 cancel() 호출
3. 자식 코루틴들의 cancel() 호출
4. 자식 코루틴 취소 완료
5. 부모 코루틴 취소 완료
6. CoroutineScope 내의 모든 코루틴 취소 완료
참고
'Develop > Reactor+Coroutine' 카테고리의 다른 글
[Coroutine] 코루틴 디스패처 (0) | 2024.07.08 |
---|---|
[Coroutine] 코루틴 스코프 함수 (0) | 2024.06.29 |
[Coroutine] 코루틴 빌더, 코루틴 컨텍스트 (0) | 2024.06.23 |
왜 코루틴을 써야할까? (11) | 2024.06.09 |
[Reactor] 서버 느리게 만드는 API 개발하기 (feat: block) (6) | 2024.05.06 |