Kotlin Coroutine (8) : Job과 Child Coroutine 기다리기

Giyun Kim·2026년 3월 4일

Kotlin Coroutine

목록 보기
8/8


0. 들어가며

저번 글에서 구조화된 동시성을 살펴보며 CoroutineBuilder의 기본적인 특징인 Parent-Child 관계를 다뤘었다.

구조화된 동시성의 특징은 Job Context와 관련이 있다. Job은 Coroutine을 취소하거나, 상태를 파악하는 등 다양하게 사용된다. 이처럼 Job은 정말 중요하고, 유용한 Context이므로 두 장에 걸쳐 소개하기로 한다.

이번 글에서는 마르친 모스카와의 Kotlin Coroutine 8장을 기반으로 Job Context 및 Job과 연관된 Coroutine의 작동방식을 보도록 한다.

1. Job이란?

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 프로퍼티를 사용할 수 있다.

2. Coroutine Builder의 Job 생성

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는 출력되지 않는다.

3. Child 기다리기

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() }
}

4. Job Factory 함수

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():Boolean
    • Job을 완료하는 데 사용한다.
      - 해당 메서드가 사용되면 Job은 모든 Child Coroutine이 완료될 때까지 실행상태를 유지하나, 해당 Job에서 새 Coroutine이 시작될 수는 없도록 한다.
    • Job이 완료되면 True, 아닐 시 False.
  • completeExceptionally(exception: Throwable): Boolean
    - 인자로 받은 예외로 Job을 완료시킨다. 모든 Children Coroutine은 주어진 예외를 래핑한 Cacellation Exception으로 즉시 취소된다.

complete 함수는 Job의 마지막 Coroutine을 시작한 후 주로 사용되는데, 사용한 이후에는 join 메서드로 Job이 완료되길 기다리기만 하면 된다.

또한, Job 메서드의 인자로 Parent Job의 참조값을 전달할 수 있는데, Parent Job이 취소되면 해당 Job도 함께 취소된다.

5. 마치며

이번 장에서는 Job을 중점적으로 다뤘다. 다음 장도 Job을 다룰 것이다. Coroutine의 취소와 예외 처리를 다룰 것이다.

profile
Android 개발자가 되기까지

0개의 댓글