Kotlin Coroutines (1) - Structured Concurrency

강지혁·2022년 7월 24일
0

Kotlin

목록 보기
1/1
post-custom-banner

KotlinConf 2018 강연 내용을 토대로 정리한 글입니다.

Structured Concurrency

Kotlin 코루틴의 가장 큰 장점 중 하나는, CoroutineScope를 통한 Structured Concurrency의 구현입니다.
부모로부터 호출된 모든 자식 코루틴은 부모의 CoroutineScope에 종속됩니다.

어떤 리스트를 받아서, 시간이 오래 걸리는 작업을 하는 함수가 있다고 칩시다.

suspend fun processSomething(things: List<Thing>) {
	things.forEach { 
    	GlobalScope.launch { someTask(it) }
    }
}

위 코드에서는 코루틴 간의 위계가 존재하지 않습니다.
GlobalScope.launch 함수는 마치 데몬 스레드와 같아서, 애플리케이션 생명주기 전체에 걸쳐 실행됩니다.
위와 같은 구현에는 몇 가지 문제가 있습니다. [참고: Jetbrains Blog]

  1. 만약 작업들 가운데 하나가 실패하더라도, 이를 추적할 방법이 마땅치 않습니다.
  2. 작업들 가운데 하나가 비정상적으로 오래 걸리더라도 취소할 수 없습니다. 이로 인해 메모리 누수가 발생합니다.

위와 달리, coroutineScope 스코프 빌더 함수를 사용한 아래 코드를 봅시다.
아래 코드에서는 모든 작업(코루틴)을 하나의 스코프 안에서 제어하게 됩니다.
이제 자식 코루틴이 실패하는 경우, 예외를 부모 코루틴에 전파합니다.
원하는 경우 부모 코루틴에서 자식 코루틴을 취소할 수도 있습니다. (coroutineContext.cancelChildren())

suspend fun processSomething(things: List<Thing>) = coroutineScope {
	things.forEach { 
    	launch { someTasks }
    }
}

어떤 일이 일어나는 걸까?

강연에서는 CoroutineScope의 전파가 어떤 식으로 이루어지는지 상세하게 다루지 않고 있습니다.

코루틴의 작동을 이해하는 핵심은, CoroutineScope와 그 안에 존재하는 CoroutineContext입니다.
Kotlin Coroutine 공식 문서 & 라이브러리 코드 구현을 함께 따라가보며 좀 더 깊게 들어가봅시다.

CoroutineScope의 구성요소는 CoroutineContext 하나입니다.
CoroutineScope를 이해하기 위해서는, CoroutineContext부터 짚고 넘어갈 필요가 있습니다.

interface CoroutineScope { 
	val coroutineContext: CoroutineContext
}

CoroutineContext는 말 그대로 "코루틴의 실행 맥락"을 정의하는 요소들의 집합입니다.
코드를 까봅시다.

public interface CoroutineContext {

	// Context 조합에 쓰이는 Utility 연산자 느낌입니다.
    public operator fun <E : Element> get(key: Key<E>): E?
    public fun <R> fold(initial: R, operation: (R, Element) -> R): R
    public fun minusKey(key: Key<*>): CoroutineContext

    // Context 두 개를 조합합니다. 같은 key는 조합하는 context의 것으로 덮어써집니다.
    public operator fun plus(context: CoroutineContext): CoroutineContext =
        if (context === EmptyCoroutineContext) this else
            context.fold(this) { acc, element ->  ... }


    public interface Key<E : Element>

    // Context의 구성 요소는, 스스로도 싱글톤 컨텍스트입니다.
    public interface Element : CoroutineContext {
    
        public val key: Key<*>
        public override operator fun <E : Element> get(key: Key<E>): E? =
            @Suppress("UNCHECKED_CAST")
            if (this.key == key) this as E else null
        public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
            operation(initial, this)
        public override fun minusKey(key: Key<*>): CoroutineContext =
            if (this.key == key) EmptyCoroutineContext else this
    }
}

코드에는 컨텍스트를 구성하는 요소와 이를 조합하기 위한 연산자가 들어있습니다.
이것만 보면, Element가 추상화 되어있어서 구체적으로 어떤 것들이 Context에 관여하는지 파악하기 힘듭니다.

아래 그림이 이해를 돕는 데 큰 도움이 됩니다. (출처)

사진에 표시된 CoroutineId, CoroutineName, ContinuationInterceptor, CoroutineExceptionHandler 등은 모두 각자의 고유한 기능을 가지는 실행 컨텍스트의 구성 요소입니다. 자세한 분석은 TODO로 남겨두고, 가장 중요한 CoroutineDispatcher에 대해서도 알아봅시다. (그림에는 빠져있음..)

공식 문서에서는 CoroutineDispatcher를 두고 아래와 같이 설명하고 있습니다.

The coroutine context includes a coroutine dispatcher (see CoroutineDispatcher) that determines what thread or threads the corresponding coroutine uses for its execution. The coroutine dispatcher can confine coroutine execution to a specific thread, dispatch it to a thread pool, or let it run unconfined.

공식문서에서는 Uncofined Dispatcher vs Default Dispatcher의 비교를 통해 실제로 코루틴이 어떻게 스레드에 스케쥴 되는지 소개하고 있습니다.

공식문서에서는 또한, Coroutine의 실행 단위인 Job 역시 CoroutineContext의 구성 요소임을 소개합니다. (만능 컨텍스트..)

CoroutineScope의 Parent-Child 관계는 사실 Job 객체의 전달을 통해 이루어지는군요!

 public interface Job: CoroutineContext.Element {
 	public fun start(): Boolean
    public fun cancel(..)
    public val children: Sequence<Job>
    public fun attachJob(child: ChildJob): ChildHandle << see here!
    public suspend fun join()
    ...
 }

CoroutineContext는 코루틴의 실행을 위해 필요한 모든 정보를 가지고 있는 것처럼 보입니다.
어떤 작업 (Job)이 어디서 (Dispatcher) 어떻게 (etc) 실행되어야 할 지가 모두 CoroutineContext 안에 들어가게 됩니다.

그러면.. CoroutineScope는 뭐하러 있는 걸까요?

[공식 문서 참고]

Real-World Application을 만든다고 가정해봅시다.
코루틴 작성이 필요한, "무거운" 작업을 담당하는 친구들은 항상 라이프사이클을 가지고 있습니다.
(ex - 안드로이드 앱의 액티비티, 백엔드 서버의 외부 요청 담당 컴포넌트 등)

그러한 컴포넌트 안에 작성된 수많은 코루틴의 생명주기를 수동으로 추적한다면, 얼마나 힘들까요?
(가령, 컴포넌트가 닫힐 때마다 모든 잡을 수동으로 취소해야 된다고 생각해보세요..)

CoroutineScope를 사용하면, 이러한 작업에 대해 걱정할 필요가 없습니다.

CoroutineScope API를 통해 코루틴을 관리하는 경우, 상위 스코프를 닫는 행위만으로도 메모리 누수 없이 안전하게 모든 코루틴을 종료할 수 있습니다.

그 덕분에 Kotlin Coroutine에서는 Structured Concurrency를 보장할 수 있는 것이지요 👏


Kotlin Coroutine을 잘 알고 싶은데, 실무에서 사용하지 않다 보니.. 손이 가지를 않았습니다. ㅠㅠ
블로그도 언젠가 시작해야지 다짐만 하고, 바쁘단 이유로 계속 미뤄왔었는데 😅
이러다가는 둘 다 관심만 가진 채로 영원히 건들지 않겠다 싶어.. 코루틴 공부를 블로그 운영의 시작으로 삼고자 합니다.

다음 포스팅에서는 아래와 같은 것들을 다뤄보겠습니다.

post-custom-banner

2개의 댓글

comment-user-thumbnail
2022년 8월 1일

잘 읽었습니다~ 다음 글이 기대되네요 ^^7

1개의 답글