코루틴 관련 포스팅은 아래의 포스팅들을 같이 순차적으로 참고하시면 좋습니다. 클릭하면 해당 포스팅으로 이동합니다.
1. 코루틴 기본 개념
2. 코루틴 빌더
3. Job과 코루틴의 라이프 사이클
4. 코루틴 컨텍스트와 스코프
5. Flow 기본 개념
6. Flow 핵심 API 정복하기
7. Flow 제어하기 (예외, 완료 등)
이전 포스팅에서 코루틴 빌더 중 하나인 `launch`에 대해서 다루었었고, 이 빌더가 반환하는 값인 `Job`에 대해서 간단히 코루틴을 제어하는 역할이라고 설명했었다. 코루틴을 제어한다는 것은 코루틴의 시작과 취소, 완료 전반을 제어한다는 말과 같다. 즉, 코루틴의 라이프사이클은 `Job`을 통해 관리되고 있으며, 코루틴의 상태는 `Job`의 상태를 통해 관리된다. 코루틴의 시작되고 완료되기까지의 전체 라이프사이클을 `Job`을 살펴보면서 알아보도록 하자.
Job으로 코루틴 제어하기
`Job`은 `start`, `join`, `cancel`을 통해 코루틴을 제어한다. 한번 `launch`함수로 코루틴을 생성하고 `Job` 객체로 이를 제어해보자.
cancel
cancel을 코루틴을 취소시키는 역할을 한다. 코드를 보며 알아보자.
fun main() = runBlocking {
val job = launch {
delay(500)
log("launch")
}
delay(100)
job.cancel()
log("main")
}
위 코드의 결과는 어떻게 될까?
[main @coroutine#1] main
코드 상으로는 `launch`라는 로그가 출력될 것 같지만 그렇지 않다. `cancel`을 호출했기 때문이다.
`launch` 블록은 코루틴을 생성하며 생성된 코루틴을 제어하는 `Job` 객체를 반환한다. 해당 `Job` 객체의 `cancel`을 호출하게 되면 생성된 코루틴은 취소되게 된다. 위 코드에서는 `launch` 블록에서 0.5초간 대기 후 로그를 찍게 했지만, 0.1초 뒤에 `Job`을 통해 `cancel` 했기 때문에 main 로그만 찍히게 된 것이다.
start
`Job`은 `start`를 통해 코루틴을 시작할 수 있다. 이전 포스팅에서 잠깐 언급했는데, `launch`는 기본적으로 `start` 파라미터를 제공하지 않으면 바로 실행된다. 따라서 바로 실행되지 않게 하기 위해서는 `CoroutineStart.LAZY`를 파라미터로 넘겨주어야 한다.
fun main() = runBlocking {
val now = System.currentTimeMillis()
val job = launch(start = CoroutineStart.LAZY) {
delay(500)
log("end in ${System.currentTimeMillis() - now}ms")
}
delay(500)
job.start()
}
위 코드는 `launch`를 통해 코루틴을 생성한 후, 0.5초간 딜레이를 준 후에 `start`를 호출했다. 만약 즉시 시작했다면 소요시간은 0.5초 정도가 나올 것이고, `start`를 통해 시작된 것이라면 1초 정도 나올 것이다. 결과는 1초 정도가 나온다.
join
`join`은 해당 코루틴이 완료될 때까지 해당 스레드의 다른 코루틴을 중단시킨다.
fun main() = println(measureTimeMillis {
runBlocking {
val job1 = launch { delay(500) }
val job2 = launch { delay(500) }
}
})
위 코드는 두 개의 하위 코루틴을 만들었고 둘 다 0.5초간 실행되도록 했다. 하지만 2개의 코루틴을 만들어서 각각 소요되기 때문에 전체 소요시간은 대략 0.5초 정도 소요된다.
그런데 `job1`에 대해서 `join`을 호출하면 어떻게 될까?
fun main() = println(measureTimeMillis {
runBlocking {
val job1 = launch { delay(500) }
job1.join()
val job2 = launch { delay(500) }
}
})
`join`을 호출 하는 순간 다른 코루틴을 중단해버리기 때문에 두 번째 `launch`를 통해 생성된 코루틴의 경우에는 첫 번째 `launch`를 통해 생성된 코루틴이 완료될 때 까지 중단된다. 그리고 `job1`이 완료된 이후에 재개한다. 따라서 총 소요시간은 1초가 넘게 걸린다.
만약 코루틴을 생성할 때 `start` 인자로 `CoroutineStart.LAZY`가 들어가 즉시 시작되지 않는 경우, `start` 함수 뿐만 아니라 `join` 함수를 호출해도 코루틴이 시작된다.
코루틴이 취소되는 원리
코루틴은 `CancellationException` 내부적으로 예외를 통해 취소를 처리한다.
fun main() = runBlocking {
val job = launch {
try {
delay(500)
} catch (e: Throwable) {
log(e::class.simpleName!!)
}
}
delay(100)
job.cancel()
}
새로운 코루틴을 만들고, 해당 코루틴은 예외를 잡아서 클래스 이름을 출력해보도록 했다. 그리고 해당 코루틴을 취소시켜보았다.
[main @coroutine#2] JobCancellationException
보면 `CancellationException`의 자식인 `JobCancellationException` 이 던져진 것을 확인할 수 있다. `CancellationException`이 아니라면, 다른 예외처럼 스택 트레이스를 남기며 예외가 던져지게 된다.
위 코드를 보면 코루틴은 쉽게 취소가 가능해보이는데, 사실은 취소하려는 코루틴이 취소에 협조가 가능한 코루틴이어야 가능하다. 한번 아래의 코드를 보자.
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
var nextPrintTime = System.currentTimeMillis()
var i = 1
while (i <= 5) {
if (System.currentTimeMillis() >= nextPrintTime) {
println("launch ${i++}")
nextPrintTime += 500L
}
}
}
delay(1200L)
job.cancel()
println("cancel complete")
}
위 코드는 0.5초마다 로그를 출력하는 코루틴이 하나 있고, 1.2초 뒤에 해당 코루틴을 취소시키는 코드이다. 일단 코루틴을 생성할 때 `Dispatchers.Default`를 통해 다른 스레드에서 실행되도록 했다. 다른 스레드에서 실행되지 않는다면, `runBlocking`을 통해 생성된 코루틴과 같은 스레드에서 실행되기 때문에 `job.cancel`은 `launch`로 생성된 코루틴이 종료되지 않는 이상 실행되지 않아버린다. 따라서 다른 스레드에서 실행되도록 한 것이다. `Dispatchers`와 관련해서는 추후에 자세히 다룰 예정이다.
위 코드를 그대로 보면, launch 3 까지만 로그가 출력되고 끝나야 할 것 같다. 하지만 결과는 아래와 같다.
launch 1
launch 2
launch 3
cancel complete
launch 4
launch 5
분명 launch 3 까지 호출되고 취소 역시 호출되었지만, 코루틴은 종료되지 않았다. 이는 모든 코루틴이 취소가 가능한 것이 아니라는 소리고, 해당 코루틴이 취소에 협조하지 않는다는 소리이다.
취소에 협조가 가능한 코루틴을 만들기 위해서는 `kotlinx.coroutines` 패키지에 선언된 `suspend` 함수를 사용하는 방법과 직접 `Job`의 상태를 확인하여 취소하는 방법이 있다. 이전 예시에서 취소가 가능했던 이유는, `kotlinx.coroutines`에 선언된 `delay` 함수를 사용했기 때문이다. `delay`, `yield` 함수를 코루틴 블럭 안에서 사용하면 해당 코루틴은 취소가 가능하다.
그렇다면 `Job`의 상태를 확인해서 취소하는 방법을 알아보자.
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
var nextPrintTime = System.currentTimeMillis()
var i = 1
while (i <= 5) {
if (System.currentTimeMillis() >= nextPrintTime) {
println("launch ${i++}")
nextPrintTime += 500L
}
if (!isActive) {
throw CancellationException()
}
}
}
delay(1200L)
job.cancel()
println("cancel complete")
}
이전에 취소가 되지 않았던 예시이다. 중간에 `isActive`라는 프로퍼티를 통해 해당 코루틴이 활성화가 안되어 있다면 `CancellationException`을 던져서 취소하는 코드가 추가되었다. `isActive`는 `CoroutineScope`의 확장프로퍼티인데, `Job`의 상태가 활성화되었다면 true를 반환한다. 따라서 활성화가 안된 경우에 `CalcellationException`을 던지면 코루틴은 취소 처리가 되어버린다.
그렇다면 코루틴이 활성화 되어 있는 상태가 상세하게 어떤 상태인 것일까? `Job`의 상태를 상세하게 알아보면서 코루틴의 전반적인 라이프 사이클에 대해서 살펴보자.
Job의 상태와 코루틴의 라이프사이클
`Job`은 총 6개의 상태를 가지고 있으며, 이것이 코루틴의 전체 라이프사이클이라고 보면 된다. 아래는 `Job`의 상태와, 상태 별로 `CoroutineScope`의 확장프로퍼티 반환 값을 정리한 표이다.
상태 | isActive | isCompleted | isCancelled |
New | false | false | false |
Active | true | false | false |
Completing | true | false | false |
Cancelling | false | false | true |
Cancelled | false | true | true |
Completed | false | true | false |
이전에 `isActive`를 통해 취소가 가능했던 이유는, `cancel`을 통해 Cancelling 상태로 만들었기 때문에 false를 반환했기 때문이다.
상태 변화를 그림으로 표현하면 위와 같다.
일반적으로 코루틴을 생성하면 Active 상태로 생성이 되는데, 이전에 봤던 `CoroutineStart.LAZY` 를 코루틴 빌더의 인자로 주게 된다면 New 상태로 생성이 된다.
Active 상태에서는 코루틴이 완료된 경우 Completing 상태로, 취소되거나 예외가 발생하여 실패한 경우 Cancelling 상태로 변한다. 여기서 바로 Completed, Cancelled로 변하지 않는 이유는 코루틴이 Structured Concurrency 원칙을 따르기 때문이다.
Structured Concurrency
Structured Concurrency는 동시성 프로그래밍을 구조적으로 작성하기 위해 등장한 패러다임이다. 이에 대해 해당 포스팅에 자세히 설명되어 있어 공유한다.
코루틴이 Structured Concurrency 패러다임을 따름으로서 지니는 특징이 있다.
1. 새로운 코루틴은 오직 특정한 코루틴 스코프 안에서 생성할 수 있다.
2. 부모 코루틴은 자식 코루틴(자신의 내부에서 생성한 코루틴)이 종료되기 전까지 종료되지 않는다.
3. 자식의 예외는 부모에게 전파되고, 부모는 예외를 자식에게 전파한다.
이러한 특징 덕분에 코루틴이 유실되거나 누수되는 문제, 코드 내에서 발생한 모든 예외의 유실을 방지하고 보고할 수 있게 된다.
자 그럼 다시 상태로 넘어가서, Completing 상태일 때 부터 살펴보자. 코루틴은 완료되면 Completed 상태로 바로 완료되는 게 아니라 자식 코루틴의 완료까지 기다린다. 그리고 모든 자식 코루틴이 Completed 상태가 되는 경우 부모도 Completed 상태로 변경 된다.
Cancelling도 마찬가지이다. 부모가 취소되거나 예외가 발생하는 경우, 자식에게도 이를 전파하여 자식 코루틴을 취소시킨다. 최종적으로 자식들이 모두 Cancelled 상태가 되면 부모도 Cancelled가 된다. 반대로 자식에게서 예외가 발생하는 경우, 부모 코루틴으로 예외가 전파된다. 이후의 과정은 전자의 흐름(부모가 취소되거나 예외가 발생하는 경우)과 동일하게 진행된다.
예외적으로 `CalcellationException`은 예외를 전파하지 않는다. 발생한 코루틴만 중지시킨다.