코루틴의 취소

홍성덕·2024년 9월 23일
0

Coroutines

목록 보기
9/14

코루틴의 취소

코루틴 실행 도중 코루틴을 더이상 실행할 필요가 없어지면 즉시 취소해야 한다. 코루틴이 실행될 필요가 없어졌는데도 취소하지 않드나면 코루틴은 계속해서 스레드를 사용하며 이는 애플리케이션의 성능 저하로 이어진다.

예를 들어, 시간이 오래 걸리는 파일 변환 작업을 요청해 코루틴이 실행되었다가 사용자에 의해 작업이 취소된 경우, 특정 페이지에서 해당 페이지의 데이터를 로드하기 위한 코루틴이 실행됐는데 이후 사용자가 해당 페이지를 종료한 경우 등이 있다.

fun main() = runBlocking<Unit> {
    val startTime = System.currentTimeMillis()
    val longJob: Job = launch(Dispatchers.Default) {
        repeat(10) { repeatTime ->
            delay(1000L) // 1000밀리초 대기
            println("[${getElapsedTime(startTime)}] 반복횟수 ${repeatTime + 1}")
        }
    }
    delay(3500L) // 3500밀리초(3.5초)간 대기
    longJob.cancel() // 코루틴 취소
}

fun getElapsedTime(startTime: Long): String = "지난 시간: ${System.currentTimeMillis() - startTime}ms"

// 출력
// [지난 시간: 1021ms] 반복횟수 1
// [지난 시간: 2027ms] 반복횟수 2
// [지난 시간: 3030ms] 반복횟수 3

만약 longJob.cancel() 코드가 없었다면 반복횟수는 10까지 출력되었겠지만 cancel() 함수로 코루틴을 취소함으로써 반복횟수가 3에서 종료되었다.

코루틴은 바로 취소되지 않는다

fun main() = runBlocking<Unit> {
    val longJob: Job = launch(Dispatchers.Default) {
        // 작업 실행
    }
    longJob.cancel() // longJob 취소 요청
    executeAfterJobCancelled() // 코루틴 취소 후 실행돼야 하는 동작
}

위 코드의 의도는 코루틴을 취소한 다음, 코루틴 취소 후 실행되어야 하는 executeAfterJobCancelled()가 호출되는 것이다. 하지만 실제로는 의도대로 작동하지 않는다. 코루틴은 곧바로 취소되는 것이 아니기 때문이다. cancel() 함수를 호출했다고 해서 해당 코루틴이 즉시 취소되는 것이 아니라 미래의 어느 시점에 취소된다. 그래서 취소가 완료된 후 executeAfterJobCancelled()가 호출되는 것이 필요하다.

fun main() = runBlocking<Unit> {
  val longJob: Job = launch(Dispatchers.Default) {
	// 작업 실행
  }
  longJob.cancelAndJoin() // longJob이 취소 완료될 때까지 runBlocking 코루틴 일시 중단
  executeAfterJobCancelled()
}

그래서 코루틴이 취소된 이후에 executeAfterJobCancelled()가 호출되는 것을 보장하기 위해서는 위와 같이 cancelAndJoin()을 호출되야 한다. 그러면 해당 코루틴이 취소 완료될 때까지 runBlocking 코루틴이 일시중단되었다가 취소 완료 이후 재개한다.

코루틴의 취소 확인

하지만 cancelAndJoin() 함수도 코루틴이 취소 완료 될 때까지 호출부(여기서는 runBlocking)의 코루틴을 일시중단하는 역할인 것이지, 코루틴을 취소요청 -> 취소완료 상태로 바꿔주는 함수는 아니다.

코루틴 취소를 요청하면 Job 객체 내부에 있는 취소 확인용 플래그를 바꾸고, 코루틴이 이 플래그를 확인하는 시점에 취소된다. (안타깝게도 내부 코드에서의 플래그가 어떤 건지 파악하지 못했다)

만약 코루틴이 취소를 확인할 수 있는 시점이 없다는 취소는 일어나지 않는다. 코루틴이 취소를 확인하는 시점은 일반적으로 일시 중단 지점이나 코루틴이 실행을 대기하는 시점이다. 만약 이러한 시점들이 없다면 코루틴은 취소되지 않는다.

fun main() = runBlocking<Unit> {
    val whileJob: Job = launch(Dispatchers.Default) {
        while (true) {
            println("작업 중")
        }
    }
    delay(100L) // 100밀리초 대기
    whileJob.cancel() // 코루틴 취소
}

// 출력
// ...
// 작업 중
// 작업 중
// 작업 중
// ...

위 코드에서 코루틴을 취소하는 코드가 존재한다. 하지만 실제로 실행을 해보면 취소되지 않고 while 문에 의해 무한 반복된다. launch 코루틴 블록 내부에 코루틴 취소를 확인할 수 있는 시점이 없기 때문이다.

그래서 취소 확인 시점을 만들어 취소 요청 시 취소가 완료되도록 만들어야 한다.

1. delay 사용

while (true) {
	println("작업 중")
    delay(1L)
}

delay 함수는 일시 중단 함수이기 때문에 호출부의 코루틴을 일시 중단하게 만든다. 그래서 위와 같이 delay를 사용하여 취소 확인 시점을 만들 수 있다. 하지만 이 작업은 불필요하게 작업을 지연시켜 성능 저하를 야기한다.

2. yield 사용

while (true) {
	println("작업 중")
    yield()
}

yield는 직역하면 양도, 양보라는 뜻으로 일시 중단 함수이다. yield를 사용하면 코루틴은 현재 디스패처의 스레드를 동일한 디스패처를 사용하는 다른 코루틴에게 양보한다. 예시에서 Dispatchers.Default를 사용하는 코루틴은 whileJob 밖에 없으므로 yield를 호출하면 일시 중단 후 곧바로 whileJob이 다시 재개될 것이다. 하지만 일시 중단된 시점에 취소 체크가 일어나므로 코루틴이 정상적으로 취소 완료된다.

그러나 이 방법은 while문을 한 번 돌 때마다 일시 중단 되는데, 매번 일시 중단되는 것은 작업을 비효율적으로 만든다.

3. CoroutineScope.isActive 사용

while (this.isActive) {
	println("작업 중")
}

CoroutineScope는 현재 코루틴이 활성화됐는지 확인할 수 있는 isActive 확장 프로퍼티를 제공한다. cancel()을 호출하면 isActive가 false가 되기 때문에 while문을 벗어날 수 있다. 이렇게 하면 코루틴을 일시 중단하지 않고 스레드 사용을 양보하지도 않기 때문에 효율적이다.


취소의 전파

위 그림은 여러 서버로부터 데이터를 다운로드하고 다운로드 완료 후 데이터를 변환하는 예시를 구조화하여 나타낸 것이다. 근데 여기서 작업 도중 부모 코루틴에 취소가 요청되었다고 가정해보자.

fun main() = runBlocking<Unit> {
    val parentJob = launch(Dispatchers.IO) { // 부모 코루틴 생성
        val dbResultsDeferred: List<Deferred<String>> = listOf("서버 1", "서버 2").map {
            async { // 자식 코루틴 생성
                delay(1000L) // 서버로부터 데이터를 가져오는데 걸리는 시간
                println("${it}로부터 데이터를 가져오는데 성공했습니다")
                return@async "[${it}]data"
            }
        }
        val dbResults: List<String> = dbResultsDeferred.awaitAll() // 모든 코루틴이 완료될 때까지 대기
        println(dbResults)

        launch {
            dbResults.forEach {
                delay(500L)
                println("${it}의 데이터 변환을 완료했습니다")
            }
        }
    }

    delay(1500L)
    parentJob.cancel()
}

// 출력
// 서버 1로부터 데이터를 가져오는데 성공했습니다
// 서버 2로부터 데이터를 가져오는데 성공했습니다
// [[서버 1]data, [서버 2]data]

코드로 작성하면 위와 같다. 출력한 결과를 보면 데이터 변환은 이루어지지 않은 것을 볼 수 있다. 부모 코루틴인 parentJob을 취소하니까 완료되지 않은 자식 코루틴들이 모두 취소된 것이다.

서버로부터 데이터를 가져오는 코루틴은 parentJob이 취소 요청을 받기 전에 완료되어 결과가 출력되었다. 하지만 데이터 변환 코루틴은 parentJob이 취소 요청을 받기 전에 완료되지 않았기 때문에 부모 코루틴으로부터 취소가 전파되어 취소되었다.

이처럼 부모-자식 관계의 코루틴에서 부모 코루틴으로 취소가 요청되면 자식 코루틴으로 취소가 전파된다는 특징이 있다. 만약 부모 코루틴이 취소됐는데도 자식 코루틴이 계속해서 실행된다면 자식 코루핀이 반환하는 결과를 사용할 곳이 없기 때문에 리소스가 낭비될 것이다. 하지만 취소의 전파를 통해 그러한 리소스 낭비는 발생하지 않는다.
그리고 자식 코루틴 방향으로만 취소가 전파되며 부모 코루틴으로는 취소가 전파되지 않는다.

안드로이드 개발에서의 코루틴 취소

안드로이드에서는 흔히 lifecycleScopeviewModelScope를 사용한다. lifecycleScope는 LifecycleOwner 객체(Activity, Fragment 등)의 Lifecycle이 종료되었을 때 스코프 안에 있는 코루틴이 모두 취소되고, viewModelScope는 ViewModel이 clear되었을 때 스코프 안에 있는 코루틴이 모두 취소된다. 그래서 안드로이드 개발을 진행하면서 직접적으로 코루틴을 취소해야 할 일은 흔치 않다.


참고자료

profile
안드로이드 주니어 개발자

0개의 댓글

관련 채용 정보