코루틴 공식 가이드 자세히 읽기 — Part 5 — Dive 2

코루틴, 중단함수, 잡(Job) 이들의 관계는 무엇인가?

Myungpyo Shim
9 min readApr 7, 2020

Korean [English]

(목차로 돌아가기)

이번에는 앞서 살펴본 예제를 기반으로 코루틴의 내부 요소들 및 이들의 관계에 대해 조금 더 알아보겠습니다. 이 글을 읽고 난 후에는 📌 표시된 코루틴 요소들에 역할 및 관계에 대한 그림이 그려지는 것이 목표입니다.
(이번 내용은 앞선 Dive1 에 이어지는 내용으로 해당 파트에서 작성된 예제를 사용합니다.)

앞선 예제의 ViewModel 부분부터 살펴보면서 코루틴의 각 요소들에 대해서 살펴봅시다.

우선 SampleViewModel 클래스를 보면 📌CoroutineScope 인터페이스를 구현하고 있습니다. 이것은 이 클래스는 코루틴 스코프임을 나타내며, 클래스 내부에서 생성되는 코루틴들은 이것을 기반 스코프로 하여 생성 된다는 의미 입니다.

CoroutineScope 인터페이스는 내부적으로 📌CoroutineContext 를 정의합니다. 그래서 CoroutineScope 를 구현하는 이 뷰모델 클래스는 반드시 이를 구현하여야 하고, 여기서는 Main 스레드(Mock)와 SupervisorJob 으로 구성된 코루틴 컨텍스트를 구현함으로써 현재 스코프(뷰모델 클래스)에서 생성/실행되는 코루틴들은 이것을 기반으로 하게 됩니다. (코루틴 컨텍스트 및 컨텍스트 요소들에 관한 내용은 다른 Dive 를 참고하세요.)

그래서 예제의 뷰 모델에서 syncUserSetting() 함수가 호출 되고 launch { block } 코루틴 빌더가 호출 되면 block 을 수행하기 위한 코루틴이 새로 하나 생성되게 되고, 이 코루틴은 앞서 이야기 한 현재 스코프의 컨텍스트를 기반으로 한 새로운 컨텍스트를 생성하여 작업을 수행하게 됩니다.

이때 사용하는 launch 코루틴 빌더의 정의는 다음과 같습니다.

각 파라미터의 의미는 다음과 같습니다.

  • context : launch 빌더로 생성되는 코루틴은 생성 될 시점의 부모 스코프의 컨텍스트를 기반으로 생성되지만 이 context 파라미터가 제공되면 이 컨텍스트 요소들도 추가(override) 하여 코루틴의 실행 컨텍스트를 생성합니다.
  • start : launch 빌더로 생성되는 코루틴의 실행 방식을 결정합니다. (Ex> 바로 실행 요청, 지연 실행, 즉시 실행, 취소 불가 실행 등)
  • block : 생성되는 코루틴이 실행할 코드 블럭이며 CoroutineScope 에서 실행 될 📌중단함수 블럭 입니다.

그리고 이 빌더의 반환 타입은 Job 입니다.

launch 코루틴 빌더의 마지막 파라미터인 block 은 중단 함수인데 코루틴의 시작 함수가 됩니다. 그렇다면 지금까지 어렴풋이 이야기하고 있는 코루틴이란 도대체 무엇일까요?

이를 알아보기 위해 코루틴의 정의를 살펴봅시다. 우리가 사용하는 코루틴들은 대부분 AbstractCoroutine 의 구현체 입니다. 그러므로 이것의 정의를 살펴 보겠습니다.

주목할 부분은 📌 Job, 📌 Continuation, 📌 CoroutineScope 입니다.

각 인터페이스를 구현함으로써 코루틴은 그 자체적으로 Job 이며 Continuation 이고 CoroutineScope 입니다.

앞서 이야기 한 것 처럼 코루틴은 다른 코루틴 스코프에서 생성됩니다. 이 때 해당 스코프가 정의하는 컨텍스트 요소들을 상속하여 가져오게 되는데 이 컨텍스트가 위 정의의 parentContext 입니다.

코루틴은 그 자체적으로 코루틴 스코프가 됩니다. 앞서 이야기 한 것과 같이 코루틴 스코프는 코루틴 컨텍스트를 정의해야 합니다. 코루틴은 파라미터로 제공 된 parentContext 에 추가로 Job 컨텍스트 요소를 만들어 추가한 후에 코루틴 스코프의 컨텍스트로 정의합니다. AbstractCoroutine 의 정의를 보면 코루틴은 Job 인터페이스 역시 구현하고 있는데 그렇기 때문에 현재 코루틴 컨텍스트는 아래와 같이 만들어 집니다.
coroutine context = parent context + this (job)

Job 은 코루틴의 스테이스 머신 이며 그 세부 구현은 AbstractCoroutine 에 있는 것이 아니고 JobSupport 에서 제공합니다. Job 은 계층 구조를 이룰 수 있으며 ParentHandle, ChildHandle 을 통해 취소(혹은 오류)에 대한 전파를 수행합니다.

우리가 다음의 코드를 수행하면

launch { ... }

코루틴 내부적으로는 크게 아래의 과정을 거치게 됩니다.

- 부모 코루틴 컨텍스트 생성 : 부모 코루틴 컨텍스트 + 추가 요소 + Debug ID
- 생성 코루틴 시작
- 부모 코루틴 Job 시작
- 부모 Job 과 현재 (자식) Job 연결 (Handle)
- 코루틴 중단 함수 시작

위 과정 중 마지막에 생성된 코루틴 중단 함수 시작 코드는 다음과 같습니다.

위 코루틴 시작 코드를 보면 코루틴의 생성 및 시작은

createCoroutineUnintercepted -> intercepted -> resumeCancellable 의 과정으로 진행되는데 하나씩 살펴보겠습니다.

* createCoroutineUnintercepted

이 함수를 통해 코루틴 생성 시 전달 된 중단 함수를 이용한 다음과 같은 스테이트 머신이 만들어 집니다. (실제 코드의 간략화 버전 입니다.)

위 코드는 처음 볼때는 조금 복잡해 보일 수 있지만 크게 resumeWith, invokeSuspend 두 함수로 나누어 살펴보면 이해하기가 쉬워집니다.

resumeWith
중단함수는 중단함수가 실행되는 코루틴 컨택스트를 참조하게 되며 생성된 후에 resumeWith() 함수가 호출되면 중단 함수 내부의 로직을 수행합니다. 이 때, 중단 함수 안에 또다른 중단함수 호출 (Ex> delay(), yield(), …) 이 있다면 해당 함수 호출은 COROUTINE_SUSPENDED 라는 예약된 값을 반환하게 되고 다시 resumeWith() 가 호출될때까지 해당 지점에서 대기하게 됩니다. 그 외의 값을 반환하면 마지막 중단 지점까지 계속해서 invokeSuspend() 함수의 호출을 하다가 마지막 중단 지점까지 완료되면 스테이트 머신에 전달 되었던 completion 으로 resumeWith(result) 호출을 통해 결과를 전달하게 됩니다.

invokeSuspend
invokeSuspend() 함수는 중단함수 내부의 중단점들을 나타낸 스테이트 머신 입니다. 최초 label 은 0 이며 중단점의 개수에 따라 필요 label 이 늘어납니다. 예를들어 앞서 살펴본 사용자 설정 동기화 예제에서 launch { } 코루틴 빌더에 전달된 중단 함수 블록은 Repository 에 syncUserSetting 중단 함수만 호출하므로 Label 은 0, 1 로 끝날 것입니다. 하지만 Repository 의 SyncUserSetting 중단 함수는 다음과 같이 내부적으로 3개의 중단점을 갖기 때문에 0, 1, 2, 3 의 Label 이 생성 될 것입니다.

위 내용을 도식화 해보면 다음과 같습니다.

좌측에 있는 코루틴은 ViewModel 스코프에서 launch { } 코루틴 빌더를 통해 생성된 코루틴이며, ViewModel 스코프는 Dispatcher = Main, Job = SupervisorJob 으로 컨텍스트를 정의합니다. 이후 withContext(Dispatchers.IO) { } 코루틴 빌더를 통해 자식 코루틴을 실행하게 됩니다. 이때 Dispatcher 를 재정의 했으므로 새로 생성된 코루틴은 컨텍스트 요소로써 디스패처는 IO Dispatcher 를 사용하게 되고, Job 은 코루틴이 생성시마다 새로 정의하게 되므로 새로운 Job 을 갖게 됩니다.

그리고 syncUserSetting() 중단 함수를 호출하면 실제 사용자 설정 정보를 동기화하는 중단 함수들로 구성된 스테이트 머신을 호출하게 되고 각 중단점을 수행하면서 최종 결과를 다시 syncUserSetting() 함수의 결과로 반환합니다. 이 반환 값은 결국 최종 호출자까지 전달 됩니다.
(위 그림에서 각각의 중단점(Suspend task #n) 또한 Continuation 이지만 표기하진 않았습니다.)

📌 Continuation 은 결국 그 사전적 의미(계속) 처럼 중단 되었다가 재개 될 지점을 나타내는 장치 입니다. 그리고 Continuation 의 구현체들은 CoroutineStackFrame 또한 구현하는데 이를 통해 호출자 체인을 유지하고 예외 발생 시 코루틴의 스택 트레이스를 생성합니다.

이는 마치 스레드에서 함수 A -> B -> C 호출 뒤 C -> B -> A 로 반환되는 (Stack unwinding) 모습을 연상케 합니다. 코루틴을 경량의 스레드라고 부르는데 (사실 실행 가능한 코드 블록) 그렇게 생각하면 이런 스택 프레임 동작을 제공하는 것이 이치에 맞아 보이기도 합니다. 😁

* intercepted

코루틴을 시작시킬 때 createCoroutineUnintercepted() 함수 이후에는 intercepted() 함수가 호출 됩니다. 이것을 이해하기 위해서는 코루틴에서 interceptor 가 무엇인가를 알아야 합니다. 우리가 지금까지 사용한 디스패처들(Ex> Main, IO 등) 은 모두 ContinuationInterceptor 인터페이스를 구현합니다. ContinuationInterceptor 는 코루틴의 컨텍스트 요소입니다.

다시 말해, 코루틴 컨텍스트를 생성할 때 Dispatchers.IO 를 디스패처로 전달하면 컨텍스트 요소 맵에 ContinuationInterceptor 로써 등록 된다는 것입니다.

그렇다면 intercepted() 함수의 역할은 무엇일까요? intercepted() 함수가 호출되면 Continuation<T> 을 DispatchedContinuation<T> 으로 변환합니다. DispatchedContinuation<T> 는 resume 시점에 등록된 디스패처와 현재 컨텍스트의 디스패처를 비교하여 일치 하지 않으면 (스레드가 달라 스레드 전환이 필요하면) 해당 스레드로 디스패치 후 실행이 재개 되도록 합니다. intercept 되지 않은 코루틴을 실행하면 현재 실행 흐름에서 곧바로 실행합니다.

* resumeCancellable

코루틴 시작의 마지막은 resumeCancellable() 함수를 호출하면서 이루어 집니다. 이 함수는 생성 된 코루틴의 시작 중단함수(Continuation)를 호출함으로써 코루틴 스테이트 머신을 시작 시키는 역할을 합니다. 초기에는 결과가 없으므로 Unit 을 파라미터로 하여 코루틴을 시작합니다.

지금까지 코루틴을 이루는 요소들은 어떤 것이 있는지, 또 그것들이 서로 어떤 관계를 갖고 있는지 알아 보았습니다. 사실 스테이트 머신은 결국 코루틴이나 suspend 키워드가 붙은 함수에 대해서 컴파일러가 생성해 내는 코드 입니다. 이러한 컴파일러의 도움으로 우리는 코루틴을 사용하고 비동기 함수에 suspend 키워드를 붙이는 것 만으로 순차적이고 직관적인 프로그래밍을 할 수 있게 됩니다.

끝.

--

--