

저번 글에서 구조화된 동시성을 살펴보며 CoroutineBuilder의 기본적인 특징인 Parent-Child 관계를 다뤘었다.
구조화된 동시성의 특징은 Job Context와 관련이 있다. Job은 Coroutine을 취소하거나, 상태를 파악하는 등 다양하게 사용된다. 이처럼 Job은 정말 중요하고, 유용한 Context이므로 두 장에 걸쳐 소개하기로 한다.
이번 글에서는 마르친 모스카와의 Kotlin Coroutine 8장을 기반으로 Job Context 및 Job과 연관된 Coroutine의 작동방식을 보도록 한다.
Job은 수명을 가지며, 취소 가능한 인터페이스다. 다만, 구체적인 사용법과 상태를 가지고 있기에 추상 클래스처럼 다룰 수도 있다.
Job의 수명은 상태로 나타낸다.

출처 : Kotlin Coroutine, 마르친 모스카와 저.
위 이미지는 Job의 상태와 상태 변화를 나타낸 도식도이다. Active 상태에서 실행되고, Coroutine은 Job을 수행한다. Job이 Coroutine Builder에 의해 생성되었을 때가, Coroutine의 본체가 실행되는 상태라고 할 수 있다.
해당 상태에서 Child Coroutine을 시작할 수 있다.
대부분의 Coroutine이 Active 상태로 시작하며, 지연 시작되는 경우에만 New 상태에서 시작한다. New 상태의 Coroutine이 Active 상태가 되려면 작업이 실행되어야 한다. 즉, Coroutine이 본체를 실행하면 그 때 Active 상태로 가는 것이다.
실행 완료 시 Completing으로 바뀌며 Child를 기다린다. Child까지 올바르게 실행이 종료되면 Completed 상태로, 만약 실행 도중 취소/실패될 시 Cancelling 상태로 간다. Cancelling 상태에선 연결을 끊거나 자원을 반납하는 등 소위 후처리 작업을 진행할 수 있으며, 해당 작업이 완료되면 Cancelled 상태로 최종 변경된다.
suspend fun main() = coroutineScope {
val job = Job() /* job.complete() 메서드로 완료시킬 때까지 Active 상태 */
...
val activeJob = launch{...} /* launch는 기본적으로 Active 상태 */
/**
* New 상태로 시작.
* Active 상태가 되려면 lazyJob.start()를 호출해야만 함.
*/
val lazyJob = launch(start = CoroutineStart.LAZY) {...}
}
Job의 상태를 확인하려면 isActive, isCompleted, isCancelled 프로퍼티를 사용할 수 있다.

Kotlin Coroutine 라이브러리의 모든 Coroutine Builder는 자신만의 Job을 생선한다. 대부분의 Builder는 Job을 반환하므로 어디서든 쓸 수 있으며, launch의 명시적 반환타입이 Job이라는 것도 알 수 있다.
Job은 CoroutineContext이므로, CoroutineContext[Job]으로 접근하는 것도 가능하다. 다만, 더 편한 접근법을 갖춘 확장 프로퍼티 job이 존재한다.
/* 확장 프로퍼티 */
val CoroutineContext.job: Job
get() = get(Job) ?: error("Current context doesn't ...")
/* 사용례 */
fun main(): Unit = runBlocking {
print(coroutineContext.job.isActive) /* True */
}
Job은 Coroutine이 상속하지 않는 유일한 Coroutine Context다. 이건 정말 중요한 법칙인데, 모든 Coroutine은 자신만의 Job을 생성하며, 인자나 Parent Coroutine으로부터 온 Job은 새 Job의 Parent로 사용된다. 아래 예시를 보자.
fun main() : Unit = runBlocking {
val name = CoroutineName("AA")
val job = Job()
launch(name + job) {
val childName = coroutineContext[CoroutineName]
println(childName == name) /* true */
val childJob = coroutineContext[Job]
println(childJob == job) /* false! */
println(childJob == job.children.first()) /* true */
}
}
Parent Job은 모든 Child Job을 참조할 수 있다. 그 역도 성립한다. Parent-Child 관계가 있기에, CoroutineScope 내에서 취소와 예외 처리를 구현할 수 있다.
그렇다면, 만약 Parent Job이 새로운 Job Context로 대체된다면 어떻게 될까? 그 순간 구조화된 동시성의 작동방식은 무효화 된다. Parent-Child 관계가 소멸하기 때문이다. 아래 예제를 보자.
fun main() : Unit = runBlocking {
launch(Job()) {
delay(1000)
println("Never printed")
}
}
Child는 파라미터로 들어온 Job()을 부모로 사용하게 되므로, runBlocking과는 아무런 관계가 없게 된다. 따라서 Parent-Child 관계가 성립하지 않기에 부모가 자식 Coroutine을 기다려주지 않으므로 Never printed는 출력되지 않는다.
Job의 이점은 Coroutine이 완료될 때까지 기다리는 데 사용될 수 있다는 것이다. 해당 기능을 위해 join 메서드를 사용하는데, join은 지정한 Job이 Completed나 Cancelled 같은 마지막 상태에 도달할 때까지 기다리는 중단함수라고 볼 수 있다.
fun main() : Unit = runBlocking {
val job1 = launch {
delay(1000)
println("Hi")
}
job1.join()
}
상기 예시처럼 join을 활용할 경우 Hi가 출력된다.
children이라는 프로퍼티도 존재하는데, 해당 Job Interface는 모든 자식을 참조할 수 있게 해주는 기능을 갖고 있다. 이를 활용해 모든 자식이 마지막 상태로 전환될 때까지 기다리게 하는 데 사용할 수도 있다. 말이 어렵다면 아래 예시를 보자.
fun main() : Unit = runBlocking {
launch {
delay(1000)
println("Hi1")
}
launch {
delay(1000)
println("Hi2")
}
val children = coroutineContext[Job]?.children /* 이렇게 노출시킬 수 있다! */
children?.forEach { it.join() }
}
Job() 팩토리 함수를 사용하면 Coroutine 없이도 Job을 만들 수 있다.
팩토리 함수로 생성된 Job은 그 어떠한 Coroutine과 연관되지 않고, Context로 사용될 수 있다. 즉, 다시 말해, 스스로가 Parent Job으로 사용되며 Child Coroutine을 가질 수도 있다는 말이다.
우리가 실수할 수도 있는 점이 있다. Job() 팩토리 함수로 만들 Job을 다른 Coroutine의 Parent로 설정할 수 있다고 앞서 말했는데, 그렇게 Parent로 설정해놓고는 Parent가 된 해당 Job을 join하게 되면 어떤 일이 생길까? 프로그램이 종료되지 않는다.
팩토리 함수로 만들어진 Job은 다른 Coroutine에 의해 사용될 가능성이 여전히 있기 때문이다. 해법은 간단하다. Parent가 된 Job에 Join을 걸 것이 아니라, 해당 Job의 Children으로 설정된 Children Coroutine에서 join을 호출하면 된다. 아래 예시를 참고하면 좋다.
fun main() : Unit = runBlocking {
` val job = Job()
launch(job) {
delay(1000)
println("Hi1")
}
launch(job) {
delay(1000)
println("Hi2")
}
job.join() /* X. 이러면 안 된다! 여기서 영원히 대기하게 됨. */
job.children.forEach { it.join() } /* O. 이렇게 Children Coroutine에서 join을 호출해야 한다. */
}
그런데 잠시 생각해봐야 할 것이 생겼다. 본 글의 들어가며에서 Job()을 소개할 때, 난 분명히 Job을 인터페이스라고 소개했다. Job()이라는 팩토리 함수를 보게 되면 어딘가 생성자 같다는 느낌이 들지 않는가? 인터페이스는 생성자를 가질 수 없는 데 말이다.
Job()은 생성자처럼 보이는 간단한 함수, 즉, 가짜 생성자다. 해당 함수가 반환하는 타입은 실제로 Job이 아닌, 하위 인터페이스인 CompletableJob이다.
public fun Job(parent: Job? = null): CompletableJob
CompletableJob 인터페이스는 두 가지의 메서드를 추가해 Job 인터페이스의 기능을 확장한 것이다. 그 두 가지느 메서드란, 아래와 같다.
complete 함수는 Job의 마지막 Coroutine을 시작한 후 주로 사용되는데, 사용한 이후에는 join 메서드로 Job이 완료되길 기다리기만 하면 된다.
또한, Job 메서드의 인자로 Parent Job의 참조값을 전달할 수 있는데, Parent Job이 취소되면 해당 Job도 함께 취소된다.
이번 장에서는 Job을 중점적으로 다뤘다. 다음 장도 Job을 다룰 것이다. Coroutine의 취소와 예외 처리를 다룰 것이다.