목표
- 코루틴 스코프(GlobalScope, coroutineScope, supervisorScope)의 사용법 및 차이점 파악
- 구조화된 동시성에 대한 심도 깊은 이해와 예제 실습
- 코루틴을 활용한 패턴과 모범 사례 공유
구조화된 동시성
구조화된 동시성(Structured Concurrency)의 원칙이란, 비동기 작업을 구조화함으로써 비동기 프로그래밍을 보다 안정적이고 예측할 수 있게 만드는 원칙입니다.
코루틴에서는 구조화된 동시성의 원칙을 사용해 비동기 작업인 코루틴을 부모-자식 관계로 구조화하여 보다 안전하게 관리될 수 있도록 합니다.
코루틴을 부모-자식 관계로 만드는 방법은 아래 예시처럼, 부모 코루틴을 만드는 코루틴 빌더의 람다식 내에 새로운 코루틴 빌더를 호출하면 됩니다.
구조화된 코루틴 특징
구조화된 코루틴의 대표적인 특징은 아래와 같으며, 하나씩 살펴보도록 하겠습니다.
- 부모 코루틴의 실행 환경이 자식 코루틴에게 상속됩니다.
- 작업을 제어하는데 사용이 가능합니다.
- 부모 코루틴이 취소되면 자식 코루틴도 취소됩니다.
- 부모 코루틴은 자식 코루틴이 모두 완료될 때까지 기다립니다.
- CoroutineScope를 통해 코루틴의 실행 범위를 제한할 수 있습니다.
부모 코루틴은 자식 코루틴에게 실행 환경을 상속한다
부모 코루틴은 자식 코루틴에게 실행 환경을 상속합니다.
부모 코루틴에 자식 코루틴을 생성하면, 부모 코루틴의 CoroutineContext가 자식 코루틴에게 전달됩니다.
위 예시를 보면, 바깥 launch에 적용한 coroutineContext가 안쪽 launch에도 적용된 것을 확인할 수 있습니다. 이렇게 자식 코루틴을 실행시킨 스레드와 코루틴 이름이 부모와 같은 이유는, 부모 코루틴의 실행 환경을 담은 coroutineContext 객체가 자식 코루틴에 상속되었기 때문입니다.
- 이전에 살펴보았던 launch 내부를 다시 한 번 보면, launch는 확장함수로 부모의 scope를 this로 가지고 있습니다.
- 그래서, newContext를 만들 때 this.coroutineContext와 본인(자식)의 context를 combine하여서 본인의 컨텍스트를 구성합니다.
부모 코루틴의 실행 환경을 자식 코루틴이 상속을 받긴 하지만, 모든 실행 환경이 상속되진 않습니다.
아래 예시처럼, 자식 코루틴 빌더 함수에 새로운 CoroutineContext 객체를 전달하면 해당하는 key에 대해서는 새로운 값으로 덮어씌워집니다.
상속되지 않는 Job
중요!
다른 coroutineContext 요소들과는 다르게 Job 객체는 상속되지 않고 코루틴 빌더 함수가 호출되면 새롭게 생성됩니다.
launch나 async를 포함한 모든 코루틴 빌더 함수는 호출 때마다 코루틴 추상체인 Job을 새롭게 생성합니다.
- 코루틴 제어 시, Job 객체를 사용하는데 Job 객체를 부모 코루틴으로부터 상속받으면 각각의 코루틴 제어가 어렵기 때문
부모 코루틴의 Job이 자식 코루틴으로 상속되지 않는 것은 맞지만, 자식 코루틴이 부모 코루틴으로부터 받은 부모의 Job은 코루틴 구조화 시 사용됩니다. 코루틴 빌더가 호출되면 Job 객체는 새롭게 만들어지지만, 해당 Job은 아래 그림처럼 Job 내부의 parent 프로퍼티를 통해 연결되며, 부모 Job에서는 Sequence 타입의 children 프로퍼티를 통해 자식 코루틴의 Job에 대한 참조를 갖게 됩니다.
자식 코루틴 Job 객체와 부모 코루틴 Job 객체 간에는 양방향 참조를 가지게 됩니다.
Job 프로퍼티 | 타입 | 설명 |
parent | Job? | 코루틴은 부모 코루틴이 없을 수 있고, 부모 코루틴이 있다면 한 개이다. |
children | Sequence<Job> | 하나의 코루틴이 복수의 자식 코루틴을 가질 수 있다. |
부모 코루틴이 없는 최상위에 정의된 코루틴은 루트 코루틴이라고 부릅니다.
루트 코루틴의 Job 객체는 parent 프로퍼티의 값으로 null을 갖습니다.
부모와 자식 코루틴간 Job 객체 참조는 아래 예제를 통해 확인할 수 있습니다.
Job 양방향 참조가 어떻게 이루어질까?
Job 양방향 참조는 아래 로직에 의해 이루어집니다.
- coroutine을 생성할 때 init 단계에서 Job을 구성하게 됩니다.
부모 코루틴의 취소는 자식 코루틴에 전파된다
코루틴의 구조화는 하나의 큰 비동기 작업을 작은 비동기 작업으로 나눌 때 일어납니다. 코루틴을 구조화하는 가장 중요한 이유는, 코루틴을 안전하게 관리하고 제어하기 위함입니다.
특정 코루틴에 취소가 요청되면, 취소는 자식 코루틴 방향으로만 전파되며, 부모 코루틴으로는 취소가 전파되지 않습니다.
부모 코루틴 취소 시, 자식 코루틴으로 취소가 전파되는 이유는 하나의 큰 비동기 작업을 여러 작은 비동기 작업으로 나눈 개념이기 때문에, 상위의 작업이 취소되면 불필요한 하위 작업으로 리소스를 낭비하지 않기 위함입니다.
코루틴 취소 전파는 위 순서로 진행이 됩니다. 부모 코루틴 취소를 진행할 때에 자식 코루틴에도 취소되었음을 알려서 취소가 하위로 계속 전파될 수 있도록 합니다.
부모 코루틴은 모든 자식 코루틴이 완료되어야 완료가 가능하다
코루틴의 구조화는 큰 작업을 연관된 여러 작은 작업으로 나누는 방식으로 이루어지므로 작은 작업이 모두 완료되어야 큰 작업이 완료될 수 있다고 봅니다. 이를 부모 코루틴이 자식 코루틴에 대해 완료 의존성을 가진다고 합니다.
- 위 예시 코드에서 확인할 수 있듯이, 부모 코루틴은 자식 코루틴이 모두 완료되어야 완료됩니다.
- invokeOnCompletion 함수: 코루틴이 실행 완료되거나 취소 완료되었을 때 실행되는 콜백을 등록하는 함수
Job 상태 중 실행 완료 중 상태란?
앞서 Job의 상태에 대해 공부할 때 실행 완료 중이라는 상태가 있음을 확인했었습니다.
실행 완료 중 상태란,
부모 코루틴이 마지막 코드를 실행한 시점부터 자식 코루틴의 실행 완료를 기다릴 때까지 가지고 있는 상태입니다.
실행 완료 중 상태의 Job 상태값은 위 예시를 통해 확인할 수 있습니다.
- 부모 코루틴의 마지막 출력은 완료했지만, 자식 코루틴을 기다리는 중이라 active는 true이고 최소/완료 여부는 false로 나오는 것을 확인할 수 있습니다.
실행 완료 중 상태와 실행 중 status는 isActive, isCancelled, isCompleted는 각각에 대해 모두 동일한 값을 가지고 있기 때문에 실행 완료 중과 실행 중 상태는 구분 없이 사용됩니다.
CoroutineScope를 사용해 코루틴 실행 범위를 관리할 수 있다
CoroutineScope 객체는 자신의 범위 내에서 생성된 코루틴들에게 실행 환경을 제공하고, 이들의 실행 범위를 관리하는 역할을 합니다.
CoroutineScope 생성하기
CoroutineScope를 생성하는 방법은 크게 2가지가 있습니다.
- CoroutineScope 인터페이스 구현을 통한 생성
- CoroutineScope 함수를 사용해 생성
위에서 설명한 두 가지 방법 중 어떤 것을 활용하던 CoroutineScope 내부에서 실행되는 코루틴은 CoroutineScope를 통해 coroutineContext를 제공받는 것을 확인할 수 있습니다.
CoroutineScope가 어떻게 코루틴에게 실행 환경을 제공할까?
launch나 async 코루틴 빌더 함수를 살펴보면, 위와 같이 CoroutineScope의 확장 함수로 구성되어 있으며, 아래 과정을 통해 CoroutineScope 객체로부터 실행 환경을 제공 받습니다.
- 수신 객체인 CoroutineScope로부터 CoroutineContext를 제공 받습니다.
- launch와 async의 마지막 인자를 보면, CoroutineScope의 확장 함수의 람다를 받습니다.
- 해당 람다는 CoroutineScope를 receiver로 받기 때문에 수신 객체 지정 람다라고 합니다.
- 제공 받은 CoroutineContext 객체에 launch, async의 context 파라미터로 넘어온 CoroutineContext를 더합니다.
- launch 기준 CoroutineScope.newCoroutineContext() 확장 함수의 foldCopies() 함수를 통해 합쳐집니다.
- 생성된 CoroutineContext에 해당 코루틴 빌더 호출로 인해 생겨난 Job을 더합니다. 이때, CoroutineScope가 제공해준 CoroutineContext를 통해 전달된 Job은 부모 Job 객체가 됩니다.
- launch 기준 AbstractCoroutine의 init 부분을 통해 부모 코루틴과 자식 코루틴간에 Job이 양방향 참조가 이루어집니다.
위 과정을 예제 코드에 적용하면 아래와 같은 흐름으로 이루어집니다.
CoroutineScope 범위
각 코루틴 빌더의 람다식은 CoroutineScope 객체를 수신 객체로 받으며, 아래 그림처럼 CoroutineScope 객체의 범위를 갖습니다.
- intelliJ와 같은 툴을 통해 로직을 작성하면, 위에서 보이는 것처럼 각 CoroutineScope 범위를 알 수 있습니다.
- 위 예시 기준으로, runBlocking의 CoroutineScope의 범위는 해당 코루틴 하위에서 실행되는 모든 코루틴을 포함합니다.
코루틴 빌더 람다식에서 수신 객체로 제공되는 CoroutineScope 객체의 범위는 코루틴 빌더로 생성되는 코루틴과 람다식 내에서 CoroutineScope 객체를 사용해 실행하는 모든 코루틴을 포함합니다.
CoroutineScope 범위를 독립적으로 가지고 싶다면?
특정 코루틴에 대해 기존 CoroutineScope 범위를 벗어나고 싶다면, 새로운 CoroutineScope 객체를 생성하고 해당 객체를 수신 객체로 갖도록 코루틴 빌더를 구성하면 됩니다.
- 코루틴은 Job 객체를 사용해 구조화를 이뤄내기 때문에, 새로운 CoroutineScope를 가지게 되면, 부모 Job으로 사용할 Job 객체를 찾을 수 없어 새로운 계층 구조를 만들어냅니다.
코루틴의 구조화를 깨는 것은 비동기 작업을 안전하지 않게 만들기 때문에 최대한 지양해야 합니다.
CoroutineScope 취소하기
CoroutineScope 인터페이스는 확장 함수로 cancel 함수를 지원합니다. 해당 함수를 사용하면 CoroutineScope 객체의 범위에 속한 모든 코루틴을 취소할 수 있습니다.
- 앞서 살펴본 구조화된 코루틴의 특징 중 하나인 부모 코루틴의 취소가 자식 코루틴에도 전파된다는 점과 동일하게 이해하면 됩니다.
- CoroutineScope.cancel() 확장 함수는 내부적으로 job.cancel() 호출을 통해 자식 코루틴 Job까지 취소를 전파합니다.
CoroutineScope 활성화 상태 확인하기
CoroutineScope 객체는 coroutineContext를 가지고 있기 때문에, 해당 context 내부의 Job 상태를 확인할 수 있습니다. isActive 프로퍼티를 호출하면 내부적으로 context의 Job의 상태를 조회하도록 커스텀 게터를 가지고 있습니다.
구조화된 Job
부모 Job 객체가 없는 구조화의 시작점 역할을 하는 Job 객체를 루트 Job이라고 하며, 이 Job 객체에 의해 제어되는 코루틴을 루트 코루틴이라고 합니다.
fun main() = runBlocking<Unit> { // 루트 Job 생성
println("[${Thread.currentThread().name}] 코루틴 생성")
}
- 위와 같이 작성하게 되면, 부모 Job을 가지지 않은 루트 Job이 생성됩니다.
runBlocking을 호출하면 왜 루트 Job을 생성할까?
runBlocking 내부에서 runBlocking 코루틴 빌더를 호출하면, 부모를 가진 코루틴을 생성할 것이라고 기대했습니다. 하지만, 위 예제에서 볼 수 있듯이 각 runBlocking에서 부모 Job이 없는 루트 Job이 생성되었었습니다.
그래서 궁금증이 생겨 runBlocking 내부 구조를 확인해보았습니다.
runBlocking은 위와 같이 구현이 되어, GlobalScope.newCoroutineContext()로 항상 최상단의 코루틴 컨텍스트를 만드는 것을 알 수 있었습니다. 따라서, runBlocking에 어떤 context 값을 넘기던 상관없이 GlobalScope 단의 코루틴 컨텍스트가 생성됩니다.
runBlocking 함수에서 context가 default 값이 없는데 어떻게 context 값 넘기지 않고 동작할까?
다만, 여기서 또 궁금증이 생긴 것은 runBlocking 호출 시, 파라미터로 정의된 context를 넘기지 않았고 default 값도 없는데 어떻게 EmptyContext가 들어갔는지 궁금해졌고, stackoverflow에서 아래와 같은 답을 얻었습니다.
앞서 봤던 runBlocking 함수는 actual fun이었고, Builders.concurrent.kt 파일에 보면 expect fun으로 context에 default 값이 들어간 runBlocking을 확인할 수 있었습니다.
- 멀티플랫폼(KMM, JVM)을 지원하기 위해 actual fun으로 수정되면서 일어난 변경사항
actual fun은 default argument value를 가질 수 없으며, default 값은 expect fun에 정의되어야 합니다.
expect fun VS actual fun?
KMM이 나오면서, 코틀린을 사용해 안드로이드, iOS 모두의 비즈니스 로직을 구현하기 위해 Kotlin에 class와 fun을 붙일 수 있는 expect modifier가 나왔습니다.
expect modifier는 abstract modifier와 비슷하게 동작하지만 의도가 좀 다릅니다.
- abstract class는 자신을 사용하는 클래스가 추상체로 포함된 함수나 변수 등을 구현하도록 만듬
- expect class는 KMM에서의 공통 모듈에서 선언된 선언체가 Android, iOS 각각에서 구현되도록 강제하기 위해 만들어짐
Job을 사용해 일부 코루틴만 구조화를 끊어 취소되지 않게 만들기
- 위 예시에서는 runBlocking 아래에 코드가 구현은 되어있지만, 사실상 runBlocking의 루트 Job과는 전혀 관련 없는 루트 Job로부터 구조화가 일어납니다.
- newRootJob에 의해 구조화된 launch1 ~ 4와 launch5 생성 시 부모 Job으로 선언된 Job()에 의해 구조화됨
- 따라서, newRootJob의 취소가 launch5 코루틴에는 영향을 주지 않습니다.
생성된 Job의 부모를 명시적으로 설정하여 구조화 끊어지지 않게 만들기
Job() 함수는 parent라는 파라미터를 받을 수 있으며, parent를 설정할 경우 부모 Job을 가진 Job을 만들어줄 수 있습니다.
다만, 위처럼 구현할 때는 문제가 생길 수 있습니다. 위 예시를 보면 프로그램이 끝나지 못하고 계속 수행중임을 확인할 수 있습니다.
생성된 Job은 자동으로 실행 완료되지 않는다.
기본적으로 launch 함수나 async 함수에 의해 생성된 Job 객체는 더 이상 실행할 코드가 없고, 자식 코루틴이 모두 완료되면 자동으로 실행 완료됩니다. 하지만, Job 생성 함수를 통해 직접 생성한 Job 객체는 자식 코루틴이 완료되어도 명시적으로 완료 함수(complete)를 호출해야 완료됩니다.
코루틴의 예외 전파
코루틴 실행 도중 예외가 발생하면, 예외가 발생한 코루틴은 취소되고 부모 코루틴으로 예외가 전파됩니다. 만약 부모 코루틴에서도 예외가 적절히 처리되지 않으면, 부모 코루틴도 취소되고 다시 상위 코루틴으로 전파됩니다. 이렇게 예외 전파, 취소가 반복되다보면 결국 루트 코루틴까지 예외가 전파될 수 있습니다.
코루틴이 예외를 전파받아 취소되면, 해당 코루틴만 취소되는 것이 아닌 코루틴의 구조화 특징으로 인해 해당 코루틴 하위 코루틴들도 모두 취소가 전파됩니다.
- 코루틴 예외는 부모 코루틴 방향으로 전파
- 코루틴 취소는 자식 코루틴 방향으로 전파
만약, 아주 작은 작업에서 발생한 예외로 인해 큰 작업이 취소되면 어플리케이션의 안정성에 문제가 생길 수 있습니다. 이런 문제를 해결하기 위해 코루틴은 예외 전파를 제한하는 여러 장치를 가지고 있습니다.
Job 객체를 사용한 예외 전파 제한
Job 객체를 사용한 예외 전파 제한은, 앞서 봤던 취소 전파 제한 했던 것처럼 코루틴의 구조화를 깨는 것입니다. 코루틴은 자신의 부모 코루틴으로만 예외를 전파하는 특징이 있으므로, 부모 코루틴과의 구조화를 깨면 예외를 전파하지 않을 수 있습니다.
Job 객체를 사용한 예외 전파 제한 한계
Job 객체를 사용한 예외 전파 제한은, 취소 전파도 제한하게 됩니다. 취소 전파를 제한하게 되면, 큰 작업 취소 시 해당 하위 작업이 취소가 안 될 수 있다는 것을 의미하기 때문에 비동기 작업을 불안정하게 만듭니다.
SupervisorJob 객체를 사용한 예외 전파 제한
코루틴 라이브러리에는 구조화를 깨지 않으면서 예외 전파를 제한할 수 있도록 SupervisorJob 객체를 제공합니다.
SupervisorJob 객체는 자식 코루틴으로부터 예외를 전파받지 않는 특수한 Job 객체로 자식 코루틴에서 발생한 예외가 다른 자식 코루틴에 영향을 미치지 못하게 하는데 사용됩니다.
SupervisorJob은 일반적인 Job 객체와 달리 예외를 전파받지 않아 취소되지 않습니다.
위 코드에서 유의할 점은 SupervisorJob()을 넘길 때, parent를 넘기지 않으면 구조화가 깨질 수 있는 점 유의해야 하며, Job을 명시적으로 생성했기 때문에 complete() 함수를 호출하지 않으면 프로그램이 종료되지 않습니다.
SupervisorJob 사용 시 흔히 하는 실수
SupervisorJob 사용 시, 코루틴 빌더 함수 context 인자에 SupervisorJob()을 넘길 때는 조심해야 합니다.
만약, 위 예시처럼 작성하면 상위 launch는 SupervisorJob을 부모로 갖는 Job을 생성하게 되어 사실상 launch 하위의 어떤 자식 코루틴이던 예외가 발생하면 전체적으로 취소 전파가 일어나게 됩니다.
SupervisorScope를 사용한 예외 전파 제한
supervisorScope 함수는 SupervisorJob 객체를 가진 CoroutineScope 객체를 생성하며, 이 SupervisorJob 객체는 supervisorScope 함수를 호출한 코루틴의 Job 객체를 부모로 갖습니다.
따라서, supervisorScope를 사용하면, 따로 추가 설정 없이, 구조화를 깨지 않고 예외 전파를 제한할 수 있습니다.
참고 자료
- 도서 - 코틀린 코루틴의 정석(조세영 저)
- Coroutines the proper way to add a job as child of another?
- How does runblocking supply a default value for context?
'PROGRAMMING LANGUAGE > KOTLIN' 카테고리의 다른 글
[Effective Kotlin] 3장 재사용성 (0) | 2024.06.30 |
---|---|
[Kotlin Coroutine] kotlinx-coroutines-test를 활용한 coroutine 테스트 (0) | 2024.06.29 |
[Effective Kotlin] 2장 가독성 (0) | 2024.06.27 |
[Effective Kotlin] 1장 안정성 (1) | 2024.06.23 |
[Kotlin Coroutine] 코루틴 컨텍스트와 디스패처의 이해 (0) | 2024.06.19 |