Kotlin/코틀린 코루틴의 정석

[Kotlin 코루틴] 구조화된 동시성

꾸준함. 2024. 11. 21. 01:25

구조화된 동시성의 원칙

  • 비동기 작업을 구조화함으로써 비동기 프로그래밍을 보다 안정적이고 예측할 수 있게 만드는 원칙
  • 코루틴은 구조화된 동시성의 원칙을 사용해 비동기 작업인 코루틴을 부모-자식 관계로 구조화함으로써 코루틴이 보다 안전하게 관리되고 제어하도록 지원
    • 부모 코루틴을 만드는 코루틴 빌더의 람다식 속에서 새로운 코루틴 빌더를 호출하면 코루틴을 부모-자식 관계로 구조화할 수 있음


 

구조화된 코루틴의 특징

  • 부모 코루틴의 실행 환경이 자식 코루틴에게 상속됨
  • 작업을 제어하는 데 사용됨
  • 부모 코루틴이 취소되면 자식 코루틴도 취소됨
  • 부모 코루틴은 자식 코루틴이 완료될 때까지 대기
  • CoroutineScope를 사용해 코루틴이 실행되는 범위를 제한 가능

 

실행 환경 상속

 

1. 부모 코루틴의 실행 환경 상속

  • 부모 코루틴은 자식 코루틴에게 실행 환경을 상속함
  • 부모 코루틴이 자식 코루틴을 생성하면 부모 코루틴의 CoroutineContext가 자식 코루틴에게 전달됨

 

 

2. 실행 환경 덮어씌우기

  • 부모 코루틴의 모든 실행 환경이 항상 자식 코루틴에게 상속되지는 않음
    • 만약 자식 코루틴을 생성하는 코루틴 빌더 함수로 새로운 CoroutineContext 객체가 전달되면 부모 코루틴에게서 전달받은 CoroutineContext 구성 요소들은 자식 코루틴 빌더 함수로 전달된 CoroutineContext 객체의 구성 요소들로 덮어씌워짐
    • 또한 다른 CoroutineContext 구성 요소들과 달리 Job 객체는 상속되지 않고 빌더 함수가 호출되면 새롭게 생성됨

 

 

3. 상속되지 않는 Job

  • launch나 async를 포함한 모든 코루틴 빌더 함수는 호출할 때마다 코루틴 추상체인 Job 객체를 새롭게 생성
    • 코루틴 제어에 Job 객체가 필요한데 Job 객체를 부모 코루틴으로부터 상속받게 될 경우 개별 코루틴의 제어가 어려워지기 때문
    • 단, 전혀 관계가 없는 것은 아니고 자식 코루틴이 부모 코루틴으로부터 전달받은 Job 객체는 코루틴을 구조화하는 데 사용됨

 

 

4. 구조화에 사용되는 Job

  • 코루틴 빌더가 호출되면 Job 객체는 새롭게 생성되지만 생성된 Job 객체는 내부에 정의된 parent 속성을 통해 부모 코루틴의 Job 객체에 대한 참조를 가짐
  • 부모 코루틴의 Job 객체는 Sequence 타입의 children 속성을 통해 자식 코루틴의 Job에 대한 참조를 가져 자식 코루틴의 Job 객체와 부모 코루틴의 Job 객체는 양방향 참조를 가짐

 

https://www.thedevtavern.com/blog/posts/structured-concurrency-explained/

Job 속성 타입 설명
parent Job? 최상 위에 있는 코루틴은 부모 코루틴이 없을 수 있기 때문에 parent 속성은 null이 될 수 있는 타입인 Job?

부모 코루틴이 있더라도 최대 하나
children Sequence<Job> 하나의 코루틴이 복수의 자식 코루틴을 가질 수 있음

 

 

 

코루틴의 구조화와 작업 제어

  • 코루틴의 구조화는 하나의 큰 비동기 작업을 작은 비동기 작업으로 나눌 때 일어남
  • 코루틴의 구조화는 큰 작업을 연관된 작은 작업으로 분할하는 방식으로 이루어짐
  • 코루틴을 구조화하는 가장 중요한 이유는 코루틴을 안전하게 관리하고 제어하기 위함
  • 구조화된 코루틴은 다음의 특징을 가짐
    • 코루틴으로 취소가 요청되면 자식 코루틴으로 전파됨
    • 부모 코루틴은 모든 자식 코루틴이 실행 완료돼야 완료될 수 있음

 

1. 취소의 전파

  • 코루틴은 자식 코루틴으로 취소를 전파하는 특성을 가지기 때문에 특정 코루틴이 취소되면 하위의 모든 코루틴이 취소됨
  • 특정 코루틴에 취소가 요청되면 취소는 자식 코루틴 방향으로만 전파되며 부모 코루틴으로는 취소가 전파되지 않음
    • 자식 코루틴으로만 취소가 전파되는 이유는 자식 코루틴이 부모 코루틴 작업의 일부이기 때문

 

https://blog.kinto-technologies.com/posts/2024-02-22-StructuredConcurrency-en/

 

2. 부모 코루틴의 자식 코루틴에 대한 완료 의존성

  • 부모 코루틴은 모든 자식 코루틴이 실행 완료돼야 완료될 수 있음
  • 코루틴의 구조화는 큰 작업을 연관된 여러 작업으로 나누는 방식으로 이뤄지는데 작은 작업이 모두 완료돼야 큰 작업이 완료될 수 있기 때문
    • 이를 부모 코루틴이 자식 코루틴에 대해 완료 의존성을 가진다고 함

 

 

부연 설명

  • 부모 코루틴은 마지막 코드를 8밀리초 정도에 실행했지만 자식 코루틴이 실행 완료되어야 실행 완료되므로 약 1초가 지난 시점에 실행 완료됨
  • invokeOnCompletion 콜백은 코루틴이 실행 완료됐을 때뿐만 아니라 취소 완료된 경우에도 동작함

 

2.1 실행 완료 중 상태

  • 코루틴 빌더와 Job 정리 게시글에서 코루틴 상태를 정리했었는데 `실행 완료` 상태는 스킵을 했었음
    • `실행 완료` 상태를 이해하기 위해서는 코루틴의 구조화에 대한 이해가 선행되어야 하기 때문
    • 여태까지 코루틴의 구조화에 대해 정리했으니 이제 `실행 완료` 상태에 대한 이해를 할 준비가 완료됨

 

  • `실행 완료(Completing)` 상태는 부모 코루틴의 모든 코드가 실행됐지만 자식 코루틴이 실행 중인 경우 부모 코루틴이 갖는 상태
    • 부모 코루틴은 더 이상 실행할 코드가 없더라도 자식 코루틴들이 모두 완료될 때까지 실행 완료될 수 없어 `실행 완료 중` 상태에 머묾

 

Completing

 

 

부연 설명

  • 위 결과에서 확인할 수 있다시피 `실행 완료 중` 상태는 `실행 중` 상태와 완전히 같은 Job 상태 값을 가짐
    • 따라서 일반적으로 두 상태를 구분하기 쉽지 않음
    • 하지만 코루틴의 실행 흐름을 이해하기 위해서는 자식 코루틴이 실행 완료되지 않으면 부모 코루틴도 실행 완료될 수 없다는 점을 이해하는 것이 중요

 

CoroutineScope 사용해 코루틴 관리하기

  • CoroutineScope 객체는 자신의 범위 내에서 생성된 코루틴들에게 실행 환경을 제공하고, 이들의 실행 범위를 관리하는 역할을 수행

 

1. CoroutineScope 생성하기 (1)

  • CoroutineScope 인터페이스는 코루틴의 실행 환경인 CoroutineContext를 가진 단순한 인터페이스
    • 해당 인터페이스의 구현체를 사용하면 CoroutineScope 객체 생성 가능

 

 

부연 설명

  • launch 코루틴이 CustomScopeThread 쓰레드를 사용해 실행되며 이를 통해 CustomCoroutineScope 객체로부터 코루틴 실행 환경을 제공받는 것을 확인 가능

 

2. CoroutineScope 생성하기 (2)

  • CoroutineScope 객체를 생성하는 또 다른 방법은 CoroutineScope 함수를 사용하는 것
  • CoroutineScope 함수는 CoroutineContext를 인자로 입력받아 CoroutineScope 객체를 생성하며, 인자로 입력된 CoroutineContext에 Job 객체가 포함돼 있지 않으면 새로운 Job 객체를 생성함

 

 

부연 설명

  • 위 코드에서 coroutineScope 변수는 CoroutineScope(Dispatchers.IO)가 호출돼 만들어진 CoroutineScope 객체를 가리킴
  • 따라서 CoroutineScope에 대해 launch를 호출해 코루틴을 실행하면 coroutineScope 범위에서 코루틴이 실행되며, coroutineScope 내부에 설정된 CoroutineContext가 launch 코루틴의 실행 환경으로 제공됨
  • 이에 따라 코드를 실행하면 launch 코루틴이 Dispatchers.IO에 의해 백그라운드 쓰레드인 DefaultDispatcher-worker-1으로 보내져 실행됨

 

3. 코루틴에 실행 환경을 제공하는 CoroutineScope

 

3.1 CoroutineScope가 코루틴에게 실행 환경을 제공하는 방식

  • launch 함수가 호출되면 다음 과정을 통해 CoroutineScope 객체로부터 실행 환경을 제공받아 코루틴의 실행 환경을 설정함
    • 수신 객체인 CoroutineScope로부터 CoroutineContext 객체를 제공받음
    • 제공받은 CoroutineContext 객체에 launch 함수의 context 인자로 넘어온 CoroutineContext를 더함
    • 생성된 CoroutineContext에 코루틴 빌더 함수가 호출돼 새로 생성되는 Job을 더하는데 이때 CoroutineContext를 통해 전달되는 Job 객체는 새로 생성되는 Job 객체의 부모 Job 객체가 됨

 

 

3.2 CoroutineScope로부터 실행 환경 상속받기

  • launch 함수가 호출돼 생성되는 코루틴의 CoroutineContext 객체는 launch 함수의 람다식에서 수신 객체인 CoroutineScope를 통해 제공됨
    • launch 함수의 람다식에서 this.coroutineContext를 통해 launch 함수로 생성된 코루틴의 실행 환경에 접근할 수 있었던 이유는 CoroutineScope가 수신 객체로 제공됨
    • launch 함수의 람다식 내부에서 launch 함수가 호출돼 새로 생성되는 자식 코루틴에 실행 환경이 상속될 수 있었던 이유 또한 이 CoroutineScope 객체로부터 부모 코루틴의 실행 환경을 상속받았기 때문
    • launch 함수뿐만 아니라 runBlocking이나 async 같은 코루틴 빌더 함수의 람다식도 CoroutineScope 객체를 람다식의 수신 객체로 제공하며 이를 통해 코루틴의 실행 환경이 상속됨

 

 

3.3 CoroutineScope에 속한 코루틴의 범위

  • 각 코루틴 빌더의 람다식은 CoroutineScope 객체를 수신 객체로 가짐
  • CoroutineScope 객체는 기본적으로 특정 범위의 코루틴들을 제어하는 역할을 수행
  • 코루틴 빌더 람다식에서 수신 객체로 제공되는 CoroutineScope 객체는 코루틴 빌더로 생성되는 코루틴과 람다식 내에서 CoroutineScope 객체를 사용해 실행되는 모든 코루틴을 포함시킴
    • 특정 코루틴만 기존에 존재하던 CoroutineScope 객체의 범위에서 벗어나게 만들려면 새로운 CoroutineScope 객체를 생성하고 해당 CoroutineScope 객체를 사용해 코루틴을 실행하면 됨

 

 

부연 설명

  • Coroutine-4 코루틴은 runBlocking 람다식의 CoroutineScope 객체의 범위에서 벗어나 새로운 CoroutineScope 객체의 범위에 속하게 됨
    • CoroutineScope 함수가 호출되면 새로운 Job 객체가 생성되고 코루틴은 Job 객체를 사용해 구조화됨 
    • CoroutineScope 함수를 사용해 새로운 CoroutineScope 객체를 생성하면 기존의 계층 구조를 따르지 않는 새로운 Job 객체가 생성돼 새로운 계층 구조를 만들기 때문

 

3.4 CoroutineScope 취소하기

  • CoroutineScope 인터페이스는 확장 함수로 cancel 함수를 지원함
  • CoroutineScope 인터페이스의 cancel 함수는 CoroutineScope 객체의 범위에 속한 모든 코루틴을 취소하는 함수로 CoroutineScope 객체에 cancel 함수가 호출되면 범위에서 실행 중인 모든 코루틴에 취소가 요청됨
  • CoroutineScope 객체에 cancel 함수가 호출되면 CoroutineScope 객체는 자신의 coroutineContext 속성을 통해 Job 객체에 접근한 후 cancel 함수를 호출
  • CoroutineScope 객체에 대한 취소 동작은 앞서 언급한 취소의 전파에서 다뤘듯이 부모 코루틴을 취소하면 모든 자식 코루틴들에게 취소가 전파되는 동작과 같은 원리로 발생

 

 

3.5 CoroutineScope 활성화 상태 확인하기

  • CoroutineScope 객체는 CoroutineScope 객체가 현재 활성화돼 있는지 확인하는 isActive 확장 속성을 제공
    • isActive 확장 속성은 coroutineContext에 설정된 Job 객체의 isActive 속성을 확인
    • Job 객체의 isActive 확장 속성은 Job 객체에 취소가 요청되면 false로 변경
    • isActive 사용 시 일시 중단 시점이 없는 코루틴을 안전하게 관리하는 데 사용 가능


 

구조화와 Job

  • 코루틴의 구조화의 중심에는 Job 객체가 있음

 

1. runBlocking과 루트 Job

  • 부모 Job 객체가 없는 구조화의 시작점 역할을 하는 Job 객체를 루트 Job이라고 지칭
    • 해당 Job 객체에 의해 제어되는 코루틴을 루트 코루틴이라고 함

 

 

부연 설명

  • runBlocking을 통해 루트 코루틴이 생성됨
  • runBlocking 람다식 내부에서는 launch 함수가 호출돼 Coroutine1과 Coroutine2가 실행되는데 Coroutine1 내부에서 다시 Coroutine3과 Coroutine4가 실행되고, Coroutine2 내부에서는 Coroutine5가 실행됨
  • runBlocking 코루틴을 루트 코루틴으로 해서 하위에 모든 코루틴들이 구조화됨

 

2. Job 구조화 깨기

 

2.1 CoroutineScope 사용해 구조화 깨기

  • CoroutineScope 객체는 코루틴 실행 환경으로 CoroutinContext 객체를 갖기 때문에 코루틴과 마찬가지로 Job 객체를 가질 수 있음
  • CoroutineScope 함수를 통해 CoroutineScope 객체가 생성되면 새로운 루트 Job이 생성되며, 이를 사용해 코루틴의 구조화를 깰 수 있음

 

 

부연 설명

  • runBlocking 함수를 통해 루트 Job이 생성되지만 CoroutineScope(Dispatchers.IO)가 호출돼 새로운 루트 Job을 가진 newScope가 생성됨
  • 이후 newScope는 launch 함수를 호출해 Coroutine1과 Coroutine2를 실행하는데 Coroutine1의 자식 코루틴으로 Coroutine3과 Coroutine4, Corutine2의 자식 코루틴으로 Coroutine5가 실행됨
  • runBlocking 람다식 마지막에 일정 시간 동안 대기하는 코드를 넣는 이유는 newScope로 인해 구조화가 깨졌기 때문에 runBloocking 코루틴이 다른 코루틴들의 완료를 기다리지 않고 메인 쓰레드 사용을 종료할 수 있기 때문에 newScope 하위의 모든 코루틴이 실행돼 결과가 출력하도록 처리하기 위해 추가
  • 단, 코루틴의 구조화를 깬 후 delay 함수 등을 통해 구조화가 깨진 코루틴이 실행 완료되는 것을 기다리는 것은 코드를 불안정하게 만들 수 있으므로 지양해야 함

 

2.2 Job 사용해 구조화 깨기

  • 루트 Job은 부모가 없는 Job 객체로 Job()을 통해 생성 가능

 

 

부연 설명

  • Job()을 통해 새로운 루튼 Job인 newRootJob이 생성되며 말 그대로 newRootJob 자체가 루트 Job이 됨
  • 따라서 newRootJob.cancel()이 호출되면 취소 전파에 의해 하위의 모든 Job 객체에 취소가 전파돼 코루틴이 취소됨

 

 

2.3 Job 사용해 일부 코루틴만 취소되지 않게 만들기

  • 새로 Job 객체를 생성해 계층 구조를 끊음으로써 일부 코루틴만 취소되지 않도록 설정 가능

 

 

부연 설명

  • Coroutine5의 계층 구조만 끊기 위해 Coroutine5의 인자로 Job()을 추가로 넘김
  • Coroutine5가 생성되기 전에 Coroutine2가 취소되면 실행이 안되므로 Coroutine2가 취소되는 상황 방지를 위해 delay(50L) 호출

 

2.4 생성된 Job의 부모를 명시적으로 설정하기

  • Job()을 통해 Job 객체를 생성할 경우 parent 속성이 null이 돼 부모가 없는 루트 Job이 생성됨
  • 만약 Job 생성 함수의 parent 인자로 Job 객체를 넘기면 해당 Job을 부모로 하는 새로운 Job 객체를 생성할 수 있음

 

 

 

부연 설명

  • Job 생성 함수를 통해 Job 객체를 생성할 경우 자동으로 실행 완료되지 않는 것을 확인 가능

 

2.5 생성된 Job은 자동으로 실행 완료되지 않는다

  • launch 함수를 통해 생성된 Job 객체는 더 이상 실행할 코드가 없고, 모든 자식 코루틴들이 실행 완료되면 자동으로 실행이 완료됨
  • 하지만 Job 생성 함수를 통해 생성된 job 객체는 자식 코루틴들이 모두 실행 완료되더라도 자동으로 실행 완료되지 않으며, 명시적으로 완료 함수인 complete을 호출해야 완료됨

 

 

비고

 

1. runBlocking vs launch

  • runBlocking은 쓰레드를 차단하고 모든 작업이 완료될 때까지 기다리며, 테스트 또는 초기화 용도로 주로 사용
  • launch는 쓰레드를 차단하지 않으며 비동기적으로 작업을 실행하는 데 적합하며, 실제 비동기 코루틴 실행에 주로 사용

 

참고

코틀린 코루틴의 정석 (조세영 저)

반응형