코루틴 내부 상태 관리 알아보기

Coroutine state management internal

Myungpyo Shim
8 min readNov 29, 2020
picture from https://unsplash.com

(목차로 돌아가기)

코루틴은 항상 특정 코루틴 스코프 내에서 실행 됩니다. 코루틴 스코프에는 정의 된 스코프 내에서 실행되는 모든 코루틴들이 베이스로 사용할 코루틴 컨텍스트가 정의되어 있습니다. 코루틴 컨텍스트는 코루틴의 실행 환경 정보라고 할 수 있습니다. 결과적으로 코루틴 스코프는 코루틴들의 실행 환경에 대한 정의를 하고 이 실행환경이 미치는 범위를 정의하는 것이라고 할 수 있습니다. 한편 이렇게 실행되는 코루틴들은 코루틴 스코프 내에서 어떤 코루틴 빌더로 생성 되었는지에 따라서 “지연 실행”이나 “결과를 반환”하는 등의 여러가지 서로 다른 특성을 갖게 됩니다.

코루틴은 생성된 후 내부적으로 정의 된 스테이트 머신에 따라서 상태를 바꾸어 가면서 코루틴 함수 블록을 실행하고, 완료 된 후에는 자식 코루틴들까지 모두 완료된 후 소멸됩니다. 코루틴의 스테이트 머신을 살펴보고 이해하는 것은 보다 코루틴에 친화적이고 안전한 코드를 작성하는데 도움이 될 것이라 생각합니다.

코루틴이 구현하는 스테이트 머신에 대해 알아보기에 앞서 우리는 먼저 코루틴 월드에서 Job 이 무엇인지에 대해 알아 볼 필요가 있습니다.

Job 은 완료해야 할 어떤 일이라고 할 수 있습니다. 우리는 일상 생활에서 어떤 일들을 할 때 어떤 일들은 매우 단순하여 그 자체적으로 완료될 수 있는 것들도 있지만, 어떤 일들은 매우 복잡하여 여러개의 작은 일들로 쪼개어 개별적으로 수행한 후 결과물을 합쳐 완료해야 하는 일들도 있습니다. 우리가 프로그램을 개발할 때는 어떤가요? 앞서 이야기한 내용은 이 경우에도 동일하게 적용됩니다. 우리는 복잡하고 어려운 문제를 해결하기 위해서 커다란 문제를 여러가지 작은 문제들로 쪼개고 각각의 작은 문제들을 해결하고 그 결과물들을 합쳐 원래 목표하던 복잡한 문제를 해결하곤 합니다. 이 때 우리가 정의 한 문제들은 크기에 관계 없이 각각을 Job 이라고 볼 수 있습니다.

코루틴 프레임워크 내에서 실행되는 일련의 작업들도 모두 이러한 Job 이라고 볼 수 있으며 하나의 코루틴은 그 자체적으로 Job 입니다.

Job은 다음과 같은 특성을 갖습니다.

  • Job 자체적으로 생명 주기를 갖습니다. (시작 -> 실행중 -> 종료)
  • 다른 Job 과 1:n 의 부모-자식 관계를 맺어 최상위 Job 부터 최하위 Job 까지의 계층 구조를 이룹니다.
  • 기본적으로는 취소 가능하며 부모 Job 의 취소는 자식 Job 의 취소로 이어집니다.
  • 자식 Job 의 오류로 인한 종료는 부모 Job 이 취소되도록 만듭니다.
    (자식 Job 이 취소로 인해 종료되면 부모 Job 을 취소하지 않습니다).
  • 코루틴 자체도 하나의 Job 입니다 (CoroutineContext[Job] = this).

Job 의 생명 주기와 상태 별 동작 특성

Job 의 생명 주기는 위와 같이 도식화 할 수 있습니다.

Job 이 생성(NEW) 되면 기본적으로 활성(ACTIVE) 상태가 됩니다. 이를 비활성 상태로 시작하고 싶다면 launch { }, async { }등의 코루틴(Job) 빌더 사용 시 CoroutineStart.LAZY 파라미터를 전달하면 해당 코루틴이 명시적으로 start() 요청을 받거나 다른 코루틴으로부터 join() 요청을 받기 전까지 시작이 지연 됩니다.

이렇게 활성 상태로 실행 중이던 Job 이 작업을 성공적으로 완료하면 완료중(COMPLETING) 상태로 진입합니다. 이 상태에서는 모든 자식 Job 들의 완료를 대기하게 됩니다. 이후 모든 자식 Job 이 정상적으로 완료되면 최종적으로 완료(COMPLETED) 상태가 됩니다.

한편, 활성 상태에서 Job 이 취소 되거나 어떤 오류로 인해 실패하게 되면 취소중(CANCELLING) 상태로 진입합니다. 또한 현재 Job 이 정상적으로 완료되어 완료중(COMPLETING) 상태로 모든 자식들의 완료를 대기하던 중 어떤 자식 Job 에서 오류가 발생하면 현재 Job(부모) 역시 취소중(CANCELLING) 상태로 진입합니다. 이후 모든 자식 Job 들이 취소 될 때까지 대기한 후 취소(CANCELLED) 상태가 됩니다.

만약 부모 Job 이 자식 Job 의 오류로 인해 취소되지 않도록 하고 싶다면 부모 Job 을 SupervisorJob 으로 만들면 됩니다. 그러면 부모 Job 의 취소 및 오류는 기존과 동일하게 모든 자식 Job 들의 취소로 이어지지만, 자식 Job 의 오류는 부모로 전달되지 않게 됩니다.

이것이 코루틴 프레임워크에서 정의하는 Job 입니다. 앞서 코루틴도 Job 의 하나라고 이야기 하였습니다. 그래서 모든 종류의 코루틴들은 이러한 Job 프로토콜(Interface) 을 따르도록 만들어 졌습니다. 더 구체적으로 이야기하면 모든 코루틴들은 JobSupport 라는 Job 프로토콜 구현을 통해 코루틴 Job 의 상태 관리를 수행합니다.

코루틴의 JobSupport

앞서 이야기 한 것과 같이 코루틴은 Job 의 구현을 JobSupport 라는 클래스로 정의하며 위와 같은 생명 주기를 갖습니다. JobSupport 의 생명 주기가 위에서 살펴보았던 Job 생명주기보다 복잡한 것은 실제로 Job 계층구조를 관리하기 위한 상태까지 정의 되었기 때문입니다.

Kotlin 1.4.1 까지 코루틴의 Job 구현체인 JobSupport 는 Deprecated (level=ERROR) 된 상태이며 이후에 구현이 변경될 수 있습니다.

코루틴은 CoroutineStart 파라미터에 따라 EMPTY_N 혹은 EMPTY_A 상태로 시작합니다. 여기서 EMPTY 는 자식 코루틴이 없음을 의미하며 _N 과 _A 는 각각 New 와 Active 상태를 말합니다. 코루틴 시작 시 CoroutineStart 파라미터에CoroutineStart.LAZY 파라미터가 전달된 경우 EMPTY_N 상태로 시작하여 start(), join() 등이 요청되면 EMPTY_A 상태로 전환되며, 그 이외의 CoroutineStart 파라미터가 전달 된 경우 바로 EMPTY_A 상태로 시작하게 됩니다.

위 JobSupport 스테이트머신을 보면 incomplete(미완료) 상태 중 하나 인 new 상태 에는 EMPTY_N 과 LIST_N 이 있습니다. EMPTY_N 은 앞서 살펴본 것과 같이 자식 코루틴이 없고(EMPTY) 활성화 되지 않은(N) 상태 입니다. LIST_N 은 EMPTY_N 상태에서 자식 코루틴이 추가될 수 있도록 InactiveNodeList 로 전환된 상태입니다.

EMPTY_A 상태(자식X,실행중) 에서 자식 코루틴이 추가되면 자식 코루틴은(ChildJob) Node 의 형태로 부모 스테이트머신에 추가되면서 부모 스테이트 머신은 SINGLE 상태가 됩니다. 이것은 자식 코루틴은 하나 존재함을 나타내는 상태입니다. SINGLE+ 상태는 자식 코루틴이 하나 존재하는 상태에서 LIST_A 상태로 변경 되기 전에 상태로 SINGLE + EmptyNodeList 상태 입니다. 결국 두 개 이상의 자식 코루틴이 존재하면 LIST_A 상태가 됩니다.

코루틴 스테이트머신이 New 혹은 Active 상태에서 사용자의 취소 혹은 예상치 못한 오류로 인해서 CANCELLING 상태로 진입하게 되면 새롭게 추가되는 자식 코루틴들은 곧 장 취소 통지를 받게 됩니다. 또한 현재 코루틴에 존재하는 모든 자식 코루틴들에게 취소 되었음을 통지 후에 부모 코루틴에게도 취소를 통지합니다.

코루틴 스테이트머신이 Active 상태들 혹은 CANCELLING 상태에서 COMPLETING 상태로 진입하게 되면 더 이상 자식 코루틴들이 새롭게 추가될 수 없으며, 현재 코루틴에 속한 모든 자식 코루틴들이 종료될 때 까지 대기합니다. 모든 자식 코루틴이 종료되면 최종적으로 마무리 작업을 수행하며 COMPLETED 상태로 진입합니다. 마무리 작업은 현재 예외로 인한 종료이면 최종 예외를 판별하거나 또는 예외의 중첩이 필요하면 중첩된 예외를 생성하는 등의 예외 관련 처리들과 부모 핸들 해제 등 리스너 해제 등의 것들 입니다.

특히 마무리 과정에서 최종 예외가 확정 된 후 아래와 같은 코드가 실행되는데,

val handled = cancelParent(finalException) 
|| handleJobException(finalException)

먼저 부모 코루틴에 예외 처리를 요청한 후 부모 코루틴이 예외를 처리하지 않았다고 응답하면 (return false) 현재 코루틴에 예외 처리 기회를 제공합니다. launchactor 같은 코루틴들은 async 같은 코루틴과 다르게 반환 값이 없는 코루틴(fire-and-forget) 이기 때문에 예외가 처리될 포인트가 없습니다. 그래서 이러한 코루틴들은 예외 처리를 구현하고 있으며 다음의 순서로 예외 처리를 수행합니다.

  • CoroutineContext[CoroutineExceptionHandler] 가 존재할 경우 이를 통해 예외 전달
  • 위에서 예외가 처리되지 않으면 Thread.uncaughtExceptionHandler 를 통해 예외 전달 (Android 는 이 경우 기본적으로 Application crash 가 발생합니다).

지금까지 코루틴(:=Job)의 스테이트머신에 대해 알아보고, 각 상태 전환 과정에 대해서 살펴보았습니다. 코루틴 프레임워크를 사용할 때에는 프레임워크 가이드에 따라서 사용하면 되지만 내부 상태 관리 매커니즘을 이해한다면 예상치 못한 문제가 발생할 때 문제를 해결하기 위한 직관을 높여줄 것이라 생각합니다.

끝.

--

--