Kotlin Coroutines의 Job 동작을 알아보자



개인 광고 영역

Kotlin Coroutines을 컨트롤하기 위한 Job을 제공해준다. 이 Job은 N 개의 coroutines의 동작을 제어할 수도 있으며, 하나의 coroutines 동작을 제어할 수도 있다.

먼저 Job이 어떤 것인지 알아보고, exception 발생 케이스를 함께 알아보겠다.


Job

Coroutines의 Job은 결국 coroutines의 상태를 가지고 있는데, 아래와 같은 6가지 상태를 포함하고 있으며, active/completed/canceled 상태에 따라 값이 아래의 표와 같다.

State isActive isCompleted isCancelled
New (optional initial state) false false false
Active (default initial state) true false false
Completing (transient state) true false false
Cancelling (transient state) false false true
Cancelled (final state) false true true
Completed (final state) false true false

이러한 Job을 바탕으로 코루틴의 상태를 확인할 수 있고, 제어할 수 있다. job.cancel()을 호출하게 되면 즉시 취소 상태로 Job을 한다.

coroutine-job


job으로 할 수 있는 것들

job을 통해 할 수 있는 것은 아래와 같다.

  • start : 현재의 coroutine의 동작 상태를 체크하며, 동작 중인 경우 true, 준비 또는 완료 상태이면 false를 return 한다.
  • join : 현재의 coroutine 동작이 끝날 때까지 대기한다. 다시 말하면 async {} await처럼 사용할 수 있다.
  • cancel : 현재 coroutine을 즉시 종료하도록 유도만 하고 대기하지 않는다. 다만 타이트하게 동작하는 단순 루프에서는 delay가 없다면 종료하지 못한다.
  • cancelAndJoin : 현재 coroutine에 종료하라는 신호를 보내고, 정상 종료할 때까지 대기한다.
  • cancelChildren : CoroutineScope 내에 작성한 children coroutine들을 종료한다. cancel과 다르게 하위 아이템들만 종료하며, 부모는 취소하지 않는다.


Job이 다름을 확인해보려고 만든 잘못된 예제

이 샘플은 좀 억지로 만들어본 샘플이다. 필자도 이리저리 테스트하면서 만든 샘플이라서 명확하지는 않음을 미리 알려드린다.

우선 아래의 그림과 같이 생각하고 만들었다.

coroutine-job-new-scope

메인 CoroutineScope이 존재하고, 이는 job을 별도로 만들어 즉시 cancel 한다.(30ms를 준 이유는 로그를 확인하기 위함이다.) 그리고 이 scope 안에 이번엔 Job을 합치지 않은 새로운 CoroutineScope을 만든다.

결국 정리하면

  • Main CoroutineScope을 생성한다.
  • Main 안에 추가로 CoroutineScope을 추가한다.(아래 코드에서는 0..20까지 출력하는 코드이다.)
  • Main CoroutineScope 안에 기본으로 동작하는 jobTwo를 추가한다.(아래 코드에서는 0..1까지 출력하는 코드이다.)

보기 전에 이렇게 사용하시면 안 됩니다!!!!

@Test
fun testJob() = runBlocking {
    val job = Job()
    CoroutineScope(Dispatchers.Default + job).launch {
        CoroutineScope(Dispatchers.Default).launch {
            println("Job one scope start")
            for (index in 0..20) {
                if (isActive) {
                    println("Job one scope index $index")
                    delay(1)
                } else {
                    break
                }
            }
            println("Job one scope for end")
        }
        val jobTwo = launch {
            println("Job two scope for start")
            for (index in 0..10) {
                if (isActive) {
                    println("Job two scope index $index")
                    delay(1)
                } else {
                    break
                }
            }
            println("Job two scope for end")
        }
        jobTwo.join()
    }
    job.cancel()
    delay(30) // 30ms test only.
}

이를 사용하면 좀비를 만들 수 있다. 당연히 내부의 CoroutineScope은 좀비 가능성이 높아졌다. 제어할 수 없어졌기에

그럼 위 코드의 결과를 보도록 하자.

Job one scope start
Job one scope index 0
Job two scope for start
Job two scope index 0
Job one scope index 1
Job two scope for end // Job two는 0하나만 출력하고 즉시 종료되었다.
Job one scope index 2
Job one scope index 3
Job one scope index 4
Job one scope index 5
// 생략
Job one scope index 17
Job one scope index 18
Job one scope index 19
Job one scope index 20
Job one scope for end

job one은 좀비가 되어 30ms 동안 계속 돌아갔다.

이 코드는 테스트 환경에서 동작하였기 때문에 원래대로라면 메인 잡이 종료되면 서브 잡도 함께 종료한다. 그래서 Job two가 종료되었음을 확인하기 위해서 delay를 별도로 준 연습 코드이다.

하지만 android runTime에서는 위와 동일한 결과를 볼 수 있다.


Job을 잘 활용하려면?

위와 같은 결과물을 만들었을 때 당연히 좋은 방향의 코드가 아님은 분명하다. 그럼 어떻게 job을 활용해야 할까?

job은 단순히 launch의 return으로만 사용할 수 있는 것은 아니다. 먼저 일반적인 job은 1개의 CoroutineScope.launch에 대한 결과로 아래와 같이 사용할 수 있다.

// return job으로 컨트롤하기
val job = CoroutineScope(Dispatchers.Default).launch {
  // 생략...
}
job.join()

위와 같은 job은 언제나 1개만 컨트롤할 수 있다. 하지만 activity에서 사용하는 job이 1개 만 있는 것이 아니며, 그렇다고 액티비티 내에서 사용할 모든 job을 n 개로 다 들고 있어야 하느냐? 그것도 아니다.

다행히도 하나의 Job을 생성하고, N 개의 launch, actor 등에 영향을 주어 한 번에 종료할 수 있는데 Job을 별도로 초기화한다.(이미 앞글에서 activity에서 편하게 쓰는 부분에 코드가 있다.)

val job = Job()

그러고 나서 생성하는 CoroutineScope에 job을 함께 초기화하면, 이 CoroutineScope의 child까지 모두 영향을 받는 job으로 활용이 가능한데 아래와 같이 초기화에 사용할 수 있다.

CoroutineScope(Dispatchers.Default + job)

또는 위와 같이 하지 않고, 원하는 launch 만 적용할 수도 있다. 이 경우 GlobalScope에서 활용 가능한 형태이다.

.launch(Dispatchers.IO + job)

그래서 이 코드를 반영하여 아래와 같이 모든 CoroutineScope 초기화할 때 + job을 함으로써 깔끔하게 원하는 대로 하위 job까지 컨트롤이 가능하다.

그렇다 하더라도 job 별로 별도 컨트롤이 필요한 경우가 있을 것이다. 그러면 val job = CoroutineScope() 형태로 사용하는 게 맞다.

@Test
fun testJob() = runBlocking {
    val job = Job()
    CoroutineScope(Dispatchers.Default + job).launch {
        CoroutineScope(Dispatchers.Default + job).launch { // 여기에 job을 함께 초기화 한다.
            println("Job one scope start")
            for (index in 0..20) {
                if (isActive) {
                    println("Job one scope index $index")
                    delay(1)
                } else {
                    break
                }
            }
            println("Job one scope for end")
        }
        val jobTwo = launch {
            println("Job two scope for start")
            for (index in 0..10) {
                if (isActive) {
                    println("Job two scope index $index")
                    delay(1)
                } else {
                    break
                }
            }
            println("Job two scope for end")
        }
        jobTwo.join()
    }
    delay(1)
    job.cancel()
}

이번에도 원하는 대로 잘 동작하는지 확인하기 위해서 delay 1ms 정도 줬다.

결과는 아래와 같다.

Job one scope start
Job one scope index 0
Job two scope for start
Job two scope index 0
Job two scope index 1 // Job two는 1까지 출력하고 종료되었다.
Job one scope index 1
Job one scope index 2
Job one scope index 3 // Job one는 3까지 출력하고 종료되었다.


마무리

Job을 알아보았다. 다음 글에서 Job의 exception에 대해서 알아보도록 하겠다.



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments