코루틴 : 구조화된 동시성

woga·2025년 3월 23일
0

코틀린 공부

목록 보기
55/56
post-thumbnail

코루틴은 가장 큰 장점이 있다. 바로 비동기 작업을 구조화함으로써 비동기 프로그래밍을 보다 안정적이고 예측할 수 있게 만든다. 이를 토대로 부모-자식 관계로 구조화함으로써 코루틴이 보다 안전하게 관리되고 제어될 수 있도록 한다.

코루틴에서 부모-자식 관계로 구조화하는 건 간단하다. 부모 코루틴을 만드는 코루틴 빌더의 람다식 속에서 새로운 코루틴 빌더를 호출하면 된다.

fun main() = runBlocking<Unit> {
	launch { // parent
    	launch { // child
        	println("Child Corutine")
        }
    }
}

구조화된 코루틴은 여러 특징을 갖는데 그 대표적인 특징은 다음과 같다

  • 부모 코루틴의 실행 환경이 자식 코루틴에게 상속된다.

  • 작업을 제어하는 데 사용된다.

  • 부모 코루틴은 자식 코루틴이 완료될 때까지 대기한다.

  • CoroutineScope를 사용해 코루틴이 실행되는 범위를 제한할 수 있다.

부모-자식 코루틴

그럼 부모와 자식은 어떻게 실행 환경을 상속하는 것이며 작업 제어가 되는 것일까?

먼저 부모 코루틴이 자식 코루틴을 생성하면 부모 코루틴의 CoroutineContext가 자식 코루틴에게 전달된다.

  • newSingleThreadContext("MyThread") : CoroutineDispacher 키
  • CoroutineName("CoroutineA") : CoroutineName 키
fun main() = runBlocking<Unit> {
	val corutineContext = newSingleThreadContext("MyThread") + CoroutineName("CoroutineA")
    launch(corutineContext) {
    	println("${Thread.currentThread().name} Parent")
        launch {
        	println("${Thread.currentThread().name} Child")
        }

result:

MyThread @CoroutineA#2 Parent
MyThread @CoroutineA#3 Child

코드처럼 부모 코루틴을 따로 Coroutine Context(*코루틴의 실행과 관련된 모든 설정은 이 객체를 통해 이뤄진다) 설정했는데 자식에서도 같은 코루틴 컨텍스트를 사용되는 걸 볼 수 있다.
부모 코루틴의 실행 환경을 담는 CoroutineContext 객체가 자식 코루틴에게 상속되기 때문이다.
하지만 항상 모든 실행환경을 상속하는 것은 아니다. 따로 선언해서 환경을 덮어쓰기 / 수정할 수 있다.

fun main() = runBlocking<Unit> {
	val corutineContext = newSingleThreadContext("MyThread") + CoroutineName("ParentCoroutine")
    launch(corutineContext) { // create parent coroutine
    	println("${Thread.currentThread().name} Parent")
        launch(CoroutineName("ChildCoroutine")) { // create child corutine
        	println("${Thread.currentThread().name} Child")
        }

result:

MyThread @ParentCoroutine#2 Parent
MyThread @ChildCoroutine#3 Child

자식 코루틴 빌더에 context 인자로 전달된 CoroutineContext 구성 요소들은 부모 코루틴에게 전달받은 코루틴 컨텍스트 구성 요소들을 덮어씌운다. 따라서 전달받은 코루틴 컨텍스트 객체와 자식 코루틴 빌더로 전달된 코루틴 컨텍스트 객체에 코루틴네임 객체가 중복으로 포함되어 있다면 자식 코루틴 빌더의 코루틴 네임 객체가 사용된다. 반대로 코루틴 디스패처는 자식 코루틴 빌더로 전달되지 않았기 때문에 부모 코루틴으로부터 상속된다.

여기서 구조화된 코루틴을 사용하면서 주의할 점은 다른 CoroutineContext 구성 요소들과 다르게 Job 객체는 상속되지 않고 코루틴 빌더 함수가 호출되면 새롭게 생성된다는 것이다.

상속되지 않는 Job

launch나 async를 포함한 모든 코루틴 빌더 함수는 호출 때마다 코루틴 추상체인 Job 객체를 새롭게 생성한다. 코루틴 제어에 Job 객체가 필요한데 Job 객체를 부모 코루틴으로부터 상속받게 되면 개별 코루틴의 제어가 어려워지기 때문이다. 따라서 코루틴 빌더를 통해 생성된 코루틴들은 서로 다른 Job을 가진다.

fun main() = runBlocking<Unit> { // create Parent coroutine
	val runBlockingJob = coroutineContext[Job] // 부모 코루틴의 CoroutineContext로부터 부모 코루틴의 Job 추출
    launch { // create Child Couroutine
    	val launchJob = coroutineContext[Job] // 자식 코루틴의 CoroutineContext로부터 자식 코루틴의 Job 추출
        if (runBlockingJob == launchJob) {
        	println("두개의 job이 동일")
        } else {
        	println("두개의 job이 동일 X")
        }   	
    }
}

coroutineContext[Job]을 호출하는 것은 실제로는 coroutineContext[Job.Key]를 호출하는 것

result:

두개의 job이 동일 X

위 결과처럼 두 개의 Job이 동일하지 않은 것을 확인할 수 있도 서로 다른 Job 객체를 가진다. 그러면 두 개는 아무런 관계도 없는 것일따? 그렇진 않다. 이 역시 코루틴을 구조화하는데 사용된다.

구조화에 사용되는 Job

코루틴 빌더가 호출되면 Job 객체는 새롭게 생성되지만 생성된 Job 객체는 내부에 정의된 parent 프로퍼티를 통해 부모 코루틴의 Job 객체에 대한 참조를 가진다. 또한 부모 코루틴의 Job 객체는 Sequence 타입의 Children 프로퍼티를 통해 자식 코루틴의 Job에 대한 참조를 가져 자식 코루틴의 Job 객체와 부모 코루틴의 Job 객체는 양방향 참조를 가진다.

참고로 부모 코루틴이 없는 최상위에 정의된 코루틴은 루트 코루틴이라고 하는데, 루트 코루틴의 Job 객체는 parent 프로퍼티 값으로 "null을 가진다"

그렇기 때문에 Job은 코루틴 구조화에 핵심적인 역할을 한다.

구조화된 코루틴

우리가 만약 서버로부터 3개의 작업이 필요하다 해보자

  • 여러 서버로부터 데이터를 다운로드 후 변환하는 작업
  • 여러 서버로부터 데이터를 다운로드하는 작업
  • 데이터 변환하는 작업

이 작업이 코루틴으로 바꾸면 데이터를 다운로드 하는 코루틴, 변환하는 코루틴 등이 필요하게 되는 것이다. 큰 작업인 "다운로드 후 변환하는" 코루틴으로부터 "다운" 코루틴과 변환 코루틴이 분할되서 관리되며 구조화가 되는 것이다.
구조화된 코루틴은 안전하게 제어되기 위해 몇 가지 특성을 갖는다

1) 코루틴으로 취소가 요청되면 자식 코루틴으로 전파된다

2) 부모 코루틴을 모든 자식 코루틴이 실행 완료돼야 완료 될 수 있다.

코루틴은 자식 코루틴으로 취소를 전파되기 때문에 특정 코루틴이 취소되면 하위의 모든 코루틴이 취소되게 된다.

만약 R 코루틴 아래에 A, B, C가 있고 A-1, A-2, B-1, B-2 등 자식 작업들이 있다면 A 취소 요청이 들어올 시 A-1, A-2까지 취소가 되는 것이다. 이유는 부모가 취소됐는데도 자식 코루틴이 계속해서 실행된다면 자식 코루틴이 반환하는 결과를 사용하는 곳이 없기 때문에 리소스 낭비가 된다.
참고로 여기서 A 코루틴 취소를 해도 R로는 전파되지 않는다. R은 A의 부모 코루틴이기 때문이다.

또한, 부모 코루틴은 자식 코루틴이 실행 완료돼야 완료될 수 있는데, 본인이 실행한 코드가 없더라도 자식 코루틴이 완료되지 않으면 "실행 완료 중" 이라는 상태가 된 채 자식 코루틴 작업이 끝날 때까지 기다린다.

  • Job 상태값

실행중 : isActive - true, isCancelled - false, isCompleted - false
실행 완료중 : isActive - true, isCancelled - false, isCompleted - false

실제로 실행중과 실행 완료 중 상태값으로 비교하면 구분 없다. 이 둘의 상태값은 구분되지 않지만 코루틴의 실행 흐름을 이해하기 위해서는 자식 코루틴이 실행 완료되지 않으면 부모 코루틴도 실행완료될 수 없다는 점을 이해하는게 중요하다.

또한, 하나 알아둬야할게 있다 "CoroutineScope"다.
자식 코루틴처럼 부모 코루틴 람다식 (코루틴 스콥) 내에서 다른 코루틴 스콥을 선언해서 사용했다면 해당 코루틴은 자식 코루틴은 아니다. 두 개의 코루틴 스콥에서 진행되므로 구조화된 코루틴이 되지 않는다.
코드는 구조화된거처럼 보일지라도 CoroutineScope 객체를 생성하면 기존의 계층 구조를 따르지 않는 새로운 Job 객체가 생성돼 새로운 계층 구조를 만들게 된다.

그래서 CoroutineScope 객체로부터 실행 환경을 제공받아 부모 코루틴과 아무런 관련이 없어진다.

물론 코루틴의 구조화를 깨는 것은 비동기 작업을 안전하지 않게 만들기 때문에 최대한 지양해야한다.


마치며..

이처럼 간단하게 코루틴의 구조화된 동시성에 대해 알아봤다. 코루틴 관련해서 용어가 많고 코루틴에 대해 알고 있던 지식이라도 다시금 말로 정리되는 부분들이 있어서 코루틴을 공부해도 계속 계속 정리해도 좋을 거 같다.

자세한 이야기는 "코틀린 코루틴의 정석" 책의 챕터 7을 보자. 더 자세하고 확실한 설명이 도표와 그림으로 알려주고 있다.

profile
와니와니와니와니 당근당근

0개의 댓글

관련 채용 정보