[코틀린 코루틴] 9장. 취소

berry·2024년 7월 17일

코틀린 코루틴에서 취소는 아주 중요한 기능이다.( 중단 함수를 사용하는 몇몇 클래스와 라이브러리는 반드시 취소를 지원하고 있다. )

코루틴 취소 문제에 대한 좋은 해결책이 오랫동안 연구되었다. 그 중 코틀린 코루틴이 제시한 방식은 아주 간단하고 편리하며 안전하다. 코루틴을 어떻게 취소할 수 있는지 살펴보자!!

기본적인 취소

Job 인터페이스는 취소하게 하는 cancel 메서드를 가지고 있다. cancel 메서드의 호출의 효과는 다음과 같다.

  • cancel 메서드를 호출한 코루틴은 첫 번째 중단점(아래 예제에서는 delay)에서 잡을 끝낸다.
  • 잡이 자식을 가지고 있다면, 그들 또한 취소된다. 하지만 부모는 영향을 받지 않는다.
  • 잡이 취소되면, 취소된 잡은 새로운 코루틴의 부모로 사용될 수 없다. 취소된 잡은 Cancelling 상태가 되었다가 Cancelled 상태로 바뀐다.
suspend fun main(): Unit = coroutineScope {
    val job = launch {
        repeat(1_000) { i ->
            delay(200)             
            println("Printing $i")
        }
    }

    delay (1100L)
    job.cancel()
    job.join()
    println("Cancelled successfully")
}

// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfully

cancel 함수에 예외를 인자로 넣는 방법을 사용하면 취소된 원인을 명확하게 할 수 있다.

코루틴을 취소하기 위해서 사용되는 예외는 CancellationException 이어야 하기 때문에 인자로 사용되는 예외는 반드시 CancellationException의 서브타입이어야 한다.

cancel 이 호출된 뒤 다음 작업을 진행하기 전에 취소 과정이 완료되는 걸 기다리기 위해 join을 사용하는 것이 일반적이다. join을 호출하지 않으면 경쟁 상태(race condition)가 될 수도 있다.

아래 예제를 살펴보면 join 호출이 없기 때문에 job.cancel()이 호출된 후에도 “Printing 4”가 출력되는 걸 확인할 수 있다.

suspend fun main(): Unit = coroutineScope {
    val job = launch {
        repeat(1_000) { i ->
            delay(100)
            Thread.sleep(100) // 오래 걸리는 연산이라 가정            
            println("Printing $i")
        }
    }

    delay (1000)
    job.cancel()
    println("Cancelled successfully")
}

// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Cancelled successfully
// Printing 4

job.join()을 뒤에 추가하면 코루틴이 취소를 마칠 때까지 중단되므로 경쟁 상태가 발생하지 않는다.

suspend fun main(): Unit = coroutineScope {
    val job = launch {
        repeat(1_000) { i ->
            delay(100)
            Thread.sleep(100) // 오래 걸리는 연산이라 가정            
            println("Printing $i")
        }
    }

    delay (1000)
    job.cancel()
    job.join()
    println("Cancelled successfully")
}

// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfully

kotlinx.coroutines 라이브러리는 call과 join을 함께 호출할 수 있는 cancelAndJoin이라는 편리한 확장 함수를 제공한다.

public suspend fun Job.cancelAndJoin(){
		cancel()
		return join()
}

Job() 팩토리 함수로 생성된 잡은 같은 방법으로 취소될 수 있다. 이 방법은 Job에 딸린 수많은 코루틴을 한번에 취소할 때 자주 사용된다.

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        repeat(1_000) { i ->
            delay(200)
            println("Printing $i")
        }
    }

    delay (1100)
    job.cancelAndJoin()
    println("Cancelled successfully")
}

// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfully 

한꺼번에 취소하는 기능은 아주 유용하다.

안드로이드를 예로 들면 사용자가 뷰 창을 나갔을 때 뷰에서 시작된 모든 코루틴을 취소하는 경우에 사용할 수 있다.

class ProfileViewModel: ViewModel() {
		private val scope = 
				CoroutineScope(Dispatchers.Main + SupervisorJob())
				
		fun onCreate() {
				scope.launch { loadUserData() }
		}
		
		override fun onCleared() {
				scope.coroutineContext.cancelChildren()
		}
}

취소는 어떻게 작동하는가?

잡이 취소되면 Cancelling 상태로 바뀌고, 상태가 바뀐 뒤 첫 번째 중단점에서 CancellaionException 예외를 던진다. 예외는 try-catch 구문을 사용하여 잡을 수도 있지만, 다시 던지는 것이 좋다.

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
		    try {
		        repeat(1_000) { i ->
		            delay(200)
		            println("Printing $i")
		        }
        } catch (e: CancellationException) {
		        println(e)
		        throw e
        }
    }

    delay (1100)
    job.cancelAndJoin()
    println("Cancelled successfully")
    delay(1000)
}

// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// JobCancellationException
// Cancelled successfully 

취소된 코루틴이 단지 멈추는 것이 아니라 내부적으로 예외를 사용해 취소되는 걸 명심해야 한다.

따라서 finally 블록 안에서 모든 것을 정리할 수 있다. 예를 들면 finally 블록에서 파일이나 데이터베이스 연결을 닫을 수 있다.

대부분의 자원 정리 과정은 finally 블록에서 실행되므로(예를 들면 useLines 를 이용해 파일을 읽는 경우) 코루틴에서도 finally 블록을 마음껏 사용해도 된다.

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
		    try {
            delay(Random.nextLong(2000))
            println("Done")
        } finally {
		        print("Will always be printed")
        }
    }

    delay (1000)
    job.cancelAndJoin()
}

// Will always be printed
// (또는)
// Done
// Will always be printed

취소 중 코루틴을 한 번 더 호출하기

위에서 보았듯이 코루틴이 실제로 종료되기 전에 finally 블록에서 추가 연산을 수행할 수 있다. 코루틴은 모든 자원을 정리할 필요가 있는 한 계속해서 실행될 수 있다.

하지만 정리 과정 중에 중단을 허용하지는 않는다. Job은 이미 Cancelling 상태가 되었기 때문에 중단되거나 다른 코루틴을 시작하는 건 절대 불가능하다.

다른 코루틴을 시작하려고 하면 그냥 무시해 버리고, 중단하려고 하면 CancellationExcpetion을 던진다.

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
		    try {
            delay(2000)
            println("Job is Done")
        } finally {
		        print("Finally")
		        launch { // 무시됩니다.
				        println("Will not be printed")
		        }
		        delay(1000) // 여기서 예외가 발생합니다.
		        println("Will not be printed")
        }
    }

    delay (1000)
    job.cancelAndJoin()
    println("Cancel done")
}

// (1초 후)
// Finally
// Cancel Done

가끔씩 코루틴이 이미 취소되었을 때 중단 함수를 반드시 호출해야 하는 경우가 있다. 예를 들면 데이터베이스의 변경 사항을 롤백하는 경우,,

이런 경우! withContext(NonCancellable)블록 안에서 함수를 호출하는 방법이 많이 사용되고 있다. (withContext가 어떻게 작동하는지는 추후 살펴볼 예정!)

여기서 중요한 것은 코드 블록의 컨텍스트를 바꾼다는 것이다. withContext 내부에서는 취소될 수 없는 Job인 NonCancellable 객체를 사용한다. 따라서 블록 내부에서 Job은 Active 상태를 유지하며, 중단 함수를 호출할 수 있게 된다.

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
		    try {
            delay(2000)
            println("Coroutine finished")
        } finally {
		        print("Finally")
		        withContext(NonCancellable) { 
				        delay(1000L)
				        println("Cleanup done")
		        }
        }
    }

    delay (100)
    job.cancelAndJoin()
    println("Done")
}

// Finally
// Cleanup done
// Done

invokeOnCompletion

자원을 해제하는 데 자주 사용되는 또 다른 방법은 Job의 invokeOnCompletion 메서드를 호출하는 것이다.

invokeOnCompletion 메서드는 잡이 ‘Completed’나 ‘Cancelled’와 같은 마지막 상태에 도달했을 때 호출될 핸들러를 지정하는 역할을 한다.

suspend fun main(): Unit = coroutineScope {
		val job = launch {
				delay(1000)
		}
		job.invokeOnCompletion { exception: Throwable? ->
				println("Finished")
		}
		delay(400)
		job.cancelAndJoin()
}
// Finished

핸들러의 파라미터 중 하나인 예외의 종류는 다음과 같다.

  • 잡이 예외 없이 끝나면 null이 된다.
  • 코루틴이 취소되었으면 CancellationException이 된다.
  • 코루틴을 종료시킨 예외일 수 있다.(다음 장에서 자세히 알아볼 예정!)

잡이 invokeOnCompletion 이 호출되기 전에 완료되었으면 핸들러는 즉시 호출된다.

onCancelling 과 invokeImmediately 파라미터를 사용하면 핸들러의 동작 방식을 변경할 수도 있다.

suspend fun main(): Unit = coroutineScope {
		val job = launch {
				delay(Random.nextLong(2400))
				println("Finished")
		}
		delay(800)
		job.invokeOnCompletion { exception: Throwable? ->
				println("Will always be printed")
				println("The exception was: $exception")
		}
		delay(800)
		job.cancelAndJoin()
}
// Will always be printed
// The exception was:
// kotlinx.coroutines.JobCancellationExcepion
// (또는)
// Finished
// Will always be printed
// The exception was null

invokeOnCompletion은 취소하는 중에 동기적으로 호출되며, 어떤 스레드에서 실행할지 결정할 수는 없다.

중단될 수 없는 걸 중단하기

취소는 중단점에서 일어나기 때문에 중단점이 없으면 취소를 할 수 없다.

이런 상황을 위해, delay 대신 Thread.sleep 을 이용한 예제를 살펴보자. (이는 정말 나쁜 방식이다. 여기서는 코루틴을 확장해서 사용하고 중단시키지 않는 상황을 만들기 위한 예제로 사용했다. )

파일들을 읽을 때와 같이 블록되는 호출이 필요할 때와 같은 복잡한 상황에서 이런 상황이 일어날 수 있다.

아래 예제는 코루틴 내부에 중단점이 없기 때문에 취소할 수 없는 상황을 확이할 수 있다.

1초 뒤에 실행이 취소되어야 함에도 실제론 3분 넘게 걸린다.

suspend fun main(): Unit = coroutineScope {
		val job = Job()
		launch(job) {
				repeat(1_000) { i ->
						Thread.sleep(200) // 여기서 복잡한 연산이나
						// 파일을 읽는 등의 작업이 있다고 가정하자.
						println("Printing $i")
				}
		}
		delay(1000)
		job.cancelAndJoin()
		println("Cancelled successfully")
		delay(1000)
}
// Printing 0
// Printing 1
// Printing 2
// ... (1000까지)

이런 상황에 대처하는 몇 가지 방법이 있다.

  • yield()를 주기적으로 호출한다.
  • 잡의 상태를 추적한다.
  • ensureActive() 함수를 사용한다.

yield는 코루틴을 중단하고 즉시 재실행한다. 중단점이 생겼기 때문에 취소(또는 디스패처를 사용해 스레드를 바꾸는 일)를 포함해 중단(또는 재실행) 중에 필요한 모든 작업을 할 수 있는 기회가 주어진다.

suspend fun main(): Unit = coroutineScope {
		val job = Job()
		launch(job) {
				repeat(1_000) { i ->
						Thread.sleep(200)
						yield()
						println("Printing $i")
				}
		}
		delay(1100)
		job.cancelAndJoin()
		println("Cancelled successfully")
		delay(1000)
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfully

중단 가능하지 않으면서 CPU 집약적이거나 시간 집약적인 연산들이 중단 함수에 있다면, 각 연산들 사이에 yield를 사용하는 것이 좋다.

suspend fun cpuIntensiveOperations() = 
		withContext(Dispatchers.Default) {
				cpuIntensiveOperations1()
				yield()
				cpuIntensiveOperations2()
				yield()
				cpuIntensiveOperations3()
		}

또 다른 방법은 잡의 상태를 추적하는 것이다.

코루틴 빌더 내부에서 this(리시버)는 빌더의 스코프를 참조하고 있고, 코루틴 스코프는 coroutineContext 프로퍼티를 사용해 참조할 수 있는 컨텍스트를 가지고 있다.

따라서 코루틴 잡(coroutineContext[Job] 또는 coroutineContext.job)에 접근해 현재 상태가 무엇인지 확인할 수 있다.

잡은 코루틴이 액티브한지 확인하는데 사용되기 때문에, 코틀린 코루틴 라이브러리는 액티브 상태를 확인하는 간단한 함수를 제공한다.

public val CoroutineScope.isActive: Boolean
		get() = coroutineContext[Job]?.isActive ?: true

isActive 프로퍼티를 사용해 잡이 액티브하지 않을 때는 연산을 중단할 수 있다.

suspend fun main(): Unit = coroutineScope {
		val job = Job()
		launch(job) {
				do {
						Thread.sleep(200)
						println("Printing")
				} while (isActive)
		}
		delay(1100)
		job.cancelAndJoin()
		println("Cancelled successfully")
}
// Printing
// Printing
// Printing
// Printing
// Printing
// Printing
// Cancelled successfully

또는 Job이 액티브 상태가 아니면 CancellationException을 던지는 ensureActive() 함수를 사용할 수 있다.

suspend fun main(): Unit = coroutineScope {
		val job = Job()
		launch(job) {
				repeat(1_000) { i ->
						Thread.sleep(200)
						ensureActive()
						println("Printing $i")
				}
		}
		delay(1100)
		job.cancelAndJoin()
		println("Cancelled successfully")
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfully

yield()와 ensureActive() 함수는 다른 코루틴이 실행할 수 있는 기회를 준다는 점에서 결과는 비슷하지만, 둘은 매우 다르다.

ensureActive() 함수는 CoroutineScope(또는 CoroutineContext 나 Job)에서 호출되어야 한다. 이 함수가 하는 일은 잡이 더 이상 액티브 상태가 아니면 예외를 던지는 것이다. 일반적으로 좀더 가벼워 더 선호되는 함수이다.

yield 함수는 전형적인 최상위 중단 함수이다. 스코프가 필요하지 않기 때문에 일반적인 중단 함수에서도 사용될 수 있다. 중단하고 재개하는 일을 하기 때문에 스레드 풀을 가진 디스패처를 사용하면 스레드가 바뀌는 문제가 생길 수 있다. CPU 사용량이 크거나 스레드를 불로킹하는 중단 함수에서 자주 사용된다.

suspendCancellableCoroutine

3장에서 소개한 이 함수는 suspendCoroutine과 비슷하지만, 컨티뉴에이션 객체를 몇 가지 메서드가 추가된 CancellableContinuation로 래핑한다.

가장 중요한 메서드는 코루틴이 취소되었을 때 행동을 정의하는 데 사용하는 invokeOnCancellation 메서드이다.

이 메서드는 라이브러리의 실행을 취소하거나 자원을 해제할 때 주로 사용된다.

suspend fun someTask() = suspendCancellableCoroutine{ cont ->
		cont.invokeOnCancellation {
				// 정리 작업을 수행한다.
		}
		// 나머지 구현 부분이다. 
}

다음 예제에서 Retrofit Call을 중단 함수로 래핑한 것을 확인할 수 있다.

suspend fun getOrganizationRepos(
    organization: String,
): List<Repo> = suspendCancellableCoroutine { continuation ->
    val orgReposCall = apiService.getOrganizationRepos(organization)

    orgReposCall.enque(object : Callback<List<Repo>> {
        override fun onResponse(
            call: Call<List<Repo>>,
            response: Response<List<Repo>>
        ) {
            if (response.isSuccessful) {
                val body = response.body()
                if (body != null) {
                    continuation.resume(body)
                } else {
                    continuation.resumeWithException(
                        ResponseWithEmptyBody
                    )
                }
            } else {
                continuation.resumeWithException(
                    ApiException(
                        response.code(),
                        response.message()
                    )
                )
            }

        override fun onFailure(
            call: Call<List<Repo>>,
            t: Throwable
        ) {
            continuation.resumeWithException(t)
        }
    })

    continuation.invokeOnCancellation {
        orgReposCall.cancel()
    }
}

Retrofit 도 이제 중단 함수를 지원한다!

class GithubApi {
		@GET("orgs/{organization}/repos?per_page=100")
		suspend fun getOrganizationRepos(
				@Path("organization") organization: String
		): List<Repo>
}

CancellableContinuation에서도 (isActive, isCompleted, isCancelled 프로퍼티를 사용해) 잡의 상태를 확인할 수 있으며, 컨티뉴에이션을 취소할 때 취소가 되는 원인을 추가적으로 제공할 수 있다.

요약

  • 취소는 코루틴의 아주 강력한 기능이다.
  • 취소를 적절하게 사용하면 자원 낭비와 메모리 누수를 줄일 수 있다.
profile
공부 내용 기록

0개의 댓글