Study/kotlin

[코틀린 프로그래밍] Chapter.16 비동기 프로그래밍

에디개발자 2021. 7. 1. 07:00
반응형

나를 닮았다고 한다...

코루틴은 논블로킹 호출을 구현하기 위한 훌륭한 방법입니다. 코루틴을 이용하면 작업을 동시 실행으로 할 수 있고 다른 코루틴 컨텍스트를 사용해서 병렬로 실행할 수 있습니다. 이번 글에서는 코루틴의 개념을 바탕으로 비동기 프로그래밍을 만들어보겠습니다.

 


비동기 프로그래밍

코루틴은 비차단방식을 사용합니다. 사용 용도로는 리모트 서비스 호출, DB 업데이터, 검색 등 즉시 수행되지 않는 행위들의 효율성을 높이기 위해 사용됩니다.

순차적으로 시작하기

날씨 정보를 가지고 오는 예제로 살펴보겠습니다. 먼저 외부 통신으로 가져온 데이터를 JSON으로 파싱하기 위해서 Klaxon 라이브러리를 추가합니다.

implementation("com.beust:klaxon:5.5")

 

데이터를 가지고 있을 Airport 클래스와 온도를 가지고 있을 Weather 클래스를 만들어보겠습니다.

class Weather(@Json(name = "Temp") val temperature: Array<String>)

class Airport(
    @Json(name = "IATA") val code: String,
    @Json(name = "Name") val name: String,
    @Json(name = "Delay") val delay: Boolean,
    @Json(name = "Weather") val weather: Weather
) {
    companion object {
        fun getAirportData(code: String): Airport? {
            val url = "https://soa.smext.faa.gov/asws/api/airport/status/$code"
            return Klaxon().parse<Airport>(URL(url).readText())
        }
    }
}

 

Airport 클래스에서 @Json 을 이용하여 JSON 응답이 클래스내의 프로퍼티에 맵핑되도록 합니다. 그리고 getAirportData() 메소드에서 우리는 데이터를 가지고 오고, text 응답을 추출한 다음 JSON 컨텐츠를 Airport의 인스턴스로 parse합니다.

 

위 코드를 사용하는 main 함수를 작성합니다.

fun main() {
    val format = "%-10s%-20s%-10s"
    println(String.format(format, "Code", "Temperature", "Delay"))
    val time = measureTimeMillis {
        val airportCodes = listOf("LAX", "SFO", "PDX", "SEA")
        val airportData: List<Airport> =
            airportCodes.mapNotNull { anAirportCode ->
                Airport.getAirportData(anAirportCode)
            }

        airportData.forEach { anAirport ->
            println(String.format(format, anAirport.code, anAirport.weather.temperature[0], anAirport.delay))
        }
    }

    println("Time taken $time ms")
}

 

결과는 아래와 같습니다.

Code      Temperature         Delay
LAX       54.0 F (12.2 C)     false
SFO       55.0 F (12.8 C)     false
PDX       46.0 F (7.8 C)      false
SEA       46.0 F (7.8 C)      false
Time taken 2548 ms

 

비동기로 만들기

위 코드에서 getAirportData() 메소드는 차단방식이었습니다. 차단방식일 경우 위처럼 4번의 호출을 하는 동안 4번을 순차적으로 외부통신을 하기 때문에 시간이 오래걸릴 것 입니다. 이 때 비동기처리로 시간을 단축시킬 수 있습니다. 

 

Airport의 getAirportData()를 비 차단방식으로 호출하기 위해서는 runBlocking()을 사용할 수 없습니다. 그리고 launch()는 로직을 수행하기 위한 함수이지 결과를 리턴하는 함수가 아닙니다. 여기서는 async()가 적합합니다.

 

async()가 반복 안에서 직접 사용되면 코루틴은 현재 코루틴 컨텍스트에서 동작을 하게 됩니다. (main 쓰레드) 하지만 이번 예제에서는 다른 풀의 스레드를 이용해서 코루틴을 실행시켜 보겠습니다. async()에 Dispatchers.IO를 전달하는 코드를 작성해보겠습니다.

fun main() = runBlocking {
    val format = "%-10s%-20s%-10s"
    println(String.format(format, "Code", "Temperature", "Delay"))
    val time = measureTimeMillis {
        val airportCodes = listOf("LAX", "SFO", "PDX", "SEA")
        // 1)
        val airportData: List<Deferred<Airport?>> =  
            airportCodes.map { anAirportCode ->
                async(Dispatchers.IO) {    // async 적용 ( Dispatcher.IO )
                    Airport.getAirportData(anAirportCode)
                }
            }

        // 2)
        airportData
            .mapNotNull { anAirportData -> anAirportData.await() }
            .forEach { anAirport ->
                println(String.format(format, anAirport.code, anAirport.weather.temperature[0], anAirport.delay))
            }
    }

    println("Time taken $time ms")
}

 

비동기 코드가 동기버전의 코드만큼 추론하기 쉽고 이해하기 쉽습니다. 

1) : 비동기 처리로 4번의 호출을 병렬로 처리한다.

2) : await() 메소드를 이용하여 4건의 병렬처리가 모두 완료될때까지 기다린 후 로직을 실행한다.

결과는 아래와 같습니다.

Code      Temperature         Delay
LAX       53.0 F (11.7 C)     false
SFO       54.0 F (12.2 C)     false
PDX       47.0 F (8.3 C)      false
SEA       44.0 F (6.7 C)      false
Time taken 1820 ms

예외 처리

코드는 여러 상황( DB 업데이트, 파일 엑세스, 외부 API 통신 )에서 많은 것들이 계획대로 되지 않습니다. 작업을 비동기적으로 실행하도록 델리게이팅 할 때 우리는 방어적이어야 하고 실패를 우아하게 다뤄야합니다. 예외를 다루는 방법은 코루틴을 어떻게 시작하냐에 달려있습니다. launch()와 async() 중 무엇을 사용할 것 인가??

구조화된 동시성을 주의하자!

스코프가 명시적으로 지정되어 있지 않다면, 코루틴은 컨텍스트와 부모 코루틴의 스코프에서 실행.
이를 구조화된 동시실행이라고 한다. 구조화된 동시 실행이란 코루틴의 계층구조가 코드의 구조와 일치할 때 일어난다.
구조화된 동시실행은 우리가 시작한 코루틴의 실행을 관리하고 모니터하는 것을 쉽게 만들고 코루틴은 자식 코루틴이 모두 완료될 때까지 완료되지 않는다.
이는 코루틴이 어떻게 서로 협력하고 실패했을 어떻게 다뤄지는지에 따라 바뀐다.

코루틴이 예외와 함께 실패했을 때 부모 코루틴도 함께 실패하는 게 기본 동작이다!
SupervisorJob 컨텍스트를 이용해서 자식 코루틴이 부모 코루틴을 취소하는 것을 방지하는 방법을 알아본다.
우리는 SupervisorJob을 이용해서 자식 코루틴에서 예외가 발생했을 때 부모 코루틴을 취소하지 못하도록 막는 방법을 알아보자!

 

launch와 Exception

launch()를 사용했다면 호출자는 예외를 받을 수 없습니다. launch()는 실행 후에 코루틴이 완료될 때까지 기다리는 방법이 있지만 대게 fire and forget 모델( 작업을 위임한 후 결과를 기다리지 않는 모델 )을 사용합니다. 예외를 발생시키기 위해 이전 코드에서 잘못된 공항 코드를 넣어보겠습니다.

fun main() = runBlocking {
    try {
        val airportCodes = listOf("LAX", "SF-", "PD-", "SEA")  // 잘못된 공황 코드
        val jobs: List<Job> = airportCodes.map { anAirportCode ->
            launch(Dispatchers.IO + SupervisorJob()) {
                val airport = Airport.getAirportData(anAirportCode)
                println("${airport?.code} delay: ${airport?.delay}")
            }
        }
        jobs.forEach { it.join() }
        jobs.forEach { println("Cancelled: ${it.isCancelled}") }
    } catch (e: Exception) {
        println("ERROR: ${e.message}")
    }
}

 

위 코드는 2개는 정상, 2개는 잘못된 코드입니다. 

launch()는 코루틴이 시작되었다는 의미를 가지는 Job 객체를 리턴합니다. Job 객체의 isCancelled 프로퍼티를 이용해서 작업이 정상 종료되었는지 실패하여 취소되었는지 확인할 수 있습니다. 결과로 살펴보겠습니다.

SEA delay: false
LAX delay: false
Cancelled: false
Cancelled: true
Cancelled: true
Cancelled: false

그리고 Exception.....

 

launch()를 호출할 때 try-catch문을 사용했지만 join() 메소드에서 에러가 날 때 동작하지 않았습니다. 대신 콘솔에 찍힌 예외 메시지와 함께 프로그램이 부적절하게 종료된 것을 볼 수 있습니다. 이유는 코루틴이 launch()를 이용하여 실행되었기 때문에 예외를 호출자에게까지 전파하지 못했습니다. launch()를 사용한다면 예외 핸들러를 반드시 설정해야합니다. CoroutineExceptionHandler 예외 핸들러가 등록되어 있다면 컨텍스트 세부사항과 예외 정보와 함께 핸들러가 트리거됩니다. launch()를 호출할 때 핸들러를 만들고, 등록해봅니다.

fun main() = runBlocking {
    // 예외 핸들러
    val handler = CoroutineExceptionHandler { context, ex ->
        println("Caught: ${context[CoroutineName]} ${ex.message?.substring(0..28)}")
    }

    try {
        val airportCodes = listOf("LAX", "SF-", "PD-", "SEA")
        val jobs: List<Job> = airportCodes.map { anAirportCode ->
            // launch 실행에 예외 핸들러 등록
            launch(Dispatchers.IO + CoroutineName(anAirportCode) + handler + SupervisorJob()) {
                val airport = Airport.getAirportData(anAirportCode)
                println("${airport?.code} delay: ${airport?.delay}")
            }
        }
        jobs.forEach { it.join() }
        jobs.forEach { println("Cancelled: ${it.isCancelled}") }
    } catch (e: Exception) {
        println("ERROR: ${e.message}")
    }
}

 

먼저 CoroutineExceptionHandler를 이용하여 예외 핸들러 객체를 생성한 후 launch 실행에 핸들러를 등록하여 실행합니다. 결과는 아래와 같이 ExceptionHandler가 정상적으로 Exception 처리를 하여 println 한 것을 확인할 수 있습니다.

Caught: CoroutineName(SF-) Unable to instantiate Airport
Caught: CoroutineName(PD-) Unable to instantiate Airport
SEA delay: false
LAX delay: false
Cancelled: false
Cancelled: true
Cancelled: true
Cancelled: false

 

async와 Exception

launch()는 예외를 호출자에게까지 전파하지 않습니다. 그래서 launch()를 사용할 때는 반드시 예외 핸들러를 등록하는 것을 확인했습니다. 하지만 async()는 Deferred<T> 인스턴스를 리턴합니다. Deffered<T> 인스턴스는 await()가 호출되면 호출자에게 예외를 전달합니다. async() 도 Exception이 발생되는 예제를 살펴보겠습니다.

fun main() = runBlocking {
    val airportCodes = listOf("LAX", "SF-", "PD-", "SEA")
    val airportData = airportCodes.map { anAirportCode ->
        async(Dispatchers.IO + SupervisorJob()) {  // async
            Airport.getAirportData(anAirportCode)
        }
    }

    for (anAirportData in airportData) {
        try {
            val airport = anAirportData.await()
            println("${airport?.code} ${airport?.delay}")
        } catch (e: Exception) {
            println("Error: ${e.message?.substring(0..28)}")
        }
    }
}

 

위 코드의 결과값은 우리가 예상한대로 예외처리도 잘 되었습니다. 

LAX false
Error: Unable to instantiate Airport
Error: Unable to instantiate Airport
SEA false

코루틴은 내부적으로 더 이상 처리할 작업이 없거나, 실패해서 취소된 경우나, 부모 코루틴이 강제로 취소시키는 경우에 취소가 됩니다. 그리고 작업이 얼마나 지속될 것인지 타임아웃을 설정할 수도 있습니다. 


취소와 타임아웃

코루틴은 취소될 수 있습니다. 코루틴을 취소하면 코루틴 내의 코드가 더 이상 실행되지 않습니다. 코루틴의 취소는 Java의 쓰레드 종료와는 연관성이 없습니다. 코루틴은 현재 서스펜션 포인트에 있는 경우에만 취소가 가능합니다. 코루틴이 동작 중이라면 취소 알림을 받지 못하고 빠져나오지 못합니다.

  • launch() 가 리턴하는 Job 객체의 cancel() 메소드
  • async()가 리턴하는 Defferred<T> 객체의 cancelAndJoin() 메소드

코틀린은 컨텍스트를 공유하는 다수의 코루틴이 계층관계를 구성할때 구조적 동시성을 제공합니다.

  - 코루틴에서 컨텍스트를 공유하는 새로운 코루틴을 생성하면 새 코루틴은 기존 코루틴의 자식으로 간주!
  - 부모 코루틴은 자식 코루틴이 완료되어야만 완료될 수 있다.
  - 부모 코루틴을 취소하면 모든 자식 코루틴이 취소
  - 서스펜션 포인트에 진입한 코루틴은 서스펜션 포인트에서 던져진 CancellationException을 받을 수 있다.
  - 실행되고 있는 코루틴이 서스펜션 포인트에 진입하지 않는 경우 isActive 프로퍼티를 체크해 동작 중에 취소되었는지 여부 확인 가능
  - 정리해야 할 자원을 가진 코루틴은 finally 블록에서 정리해야함
  - 처리되지 않은 예외는 코루틴을 취소시킴
  - 자식 코루틴이 정지하면 부모 코루틴이 정지하므로 형제 코루틴도 취소되고 만다. 이런 동작은 부모에서 자식으로만 단반향으로 취소가 가능하게 만드는 슈퍼바이저 잡을 통해서 변경할 수 있다.

 

코루틴 취소

코루틴을 취소시키려면 cancel() 메소드나 cancelAndJoin() 메소드를 이용해서 코루틴을 취소시킬 수 있습니다. 하지만 이 명령으로 코루틴이 바로 취소되지 않습니다. 코루틴이 동작 중이라면 위 메소드는 해당 동작을 방해하지 못합니다. 하지만 yield(), delay(), await() 같은 서스펜션 포인트에 진입해있다면 CencellationException을 발생시킵니다.

 

코루틴을 설계할 때 이전 제약사항을 명심해야합니다. 긴 연산을 수행해야 하다면 빈번하게 중단점을 두면서 코루틴의 isActive 프로퍼티가 true인지 확이낳도록 구조를 만들어야합니다. isActive가 false라면 연산을 중단하고 취소요청을 받아드립니다.

 

때떄로 isActive 프로퍼티를 체크하는 기능을 만들 필요가 없을 때도 있습니다. 코루틴 내부에서 호출한 함수가 차단된 상태이거나 서스펜션 포인트가 없을 수 있기 때문입니다. 이런 상황에서는 차단된 호출을 다른 코루틴으로 델리게이트해서 우회시키고 기다려야합니다. 이렇게하면 차단된 호출의 중단점이 만들어집니다.

 

예제를 통해서 취소를 잘하는 코드와 잘 못하는 코드의 행동을 검증해보자!

// 코틀린 취소
suspend fun compute(checkActive: Boolean) = coroutineScope {  // coroutineScope로 감싼다.
    var count = 0L
    val max = 10000000000

    while (if (checkActive) {
            isActive     // isActive 체크
        } else (count < max)
    ) {
        count++
    }
    if (count == max) {
        println("compute, checkActive $checkActive ignored cancellation")
    } else {
        println("compute, checkActive $checkActive bailed out early")
    }
}

 

파라미터가 true로 전달될 경우 긴 연산 중에서 isActive 프로퍼티를 확인하게 됩니다. 파라미터가 false일 경우 긴 시간 동안 그냥 실행됩니다. isActive 프로퍼티에 접근을 해야 할 필요가 있기 떄문에 코드는 코루틴의 컨텍스트에서 동작해야합니다. 이를 위해서 compute() 함수 내부의 코드를 호출자의 스코프를 운반하는 coroutineScope()의 호출로 감싸도록 합니다.

 

연산이 길게 실행된다면 때때로 isActive를 체크해봐야합니다. 만약 코루틴 안에서 호출한 함수가 연산이 길고 도중에 방해할 수 없다면 코루틴 또한 방해할 수 없습니다. 

val url = "http://httpstat.us/200?sleep=2000"
fun getResponse() = URL(url).readText()
suspend fun fetchResponse(callAsync: Boolean) = coroutineScope {
    try {
        val response = if (callAsync) {
            async { getResponse() }.await() // 취소 허용 O
        } else {
            getResponse()  // 취소 허용 X
        }
        println(response)
    } catch (e: CancellationException) {
        println("fetchResponse called with callAsync $callAsync: ${e.message}")
    }
}

fetchResponse() 함수는 sleep 파라미터의 크기만큼의 밀리초가 지나면 특정 HTTP 코드를 리턴하는 URL에 요청을 보냅니다. callAsync 파라미터가 false라면 URL 호출을 동기화해서 실행합니다. 그렇기 떄문에 호출자를 차단하고 취소를 허용하지 않게 됩니ㅏㄷ. 하지만 파라미터가 true라면 URL 호출은 비동기로 진행이 됩니다. 그렇기 때문에 코루틴이 대기상태라면 바로 취소할 수 있습니다. 

 

이 두 함수를 코루틴 안에서 사용해보겠습니다. 1초 간 실행시키고 cancel 명령을 내려보도록 합니다. cancel() 메소드 사용 후 join() 메소드를 호출하는 방법과 두 호출을 조합해놓은 cancelAndJoin() 메소드 둘 다 사용가능합니다.

runBlocking {
    val job = launch(Dispatchers.Default) {
        launch { compute(checkActive = false) }       // 방해 실패
        launch { compute(checkActive = true) }        // 방해 성공
        launch { fetchResponse(callAsync = false) }   // 방해 실패
        launch { fetchResponse(callAsync = true) }    // 방해 성공
    }
    println("Let them run...")
    Thread.sleep(1000)
    println("OK, that's enough, cancel")
    job.cancelAndJoin()
}

 

// 실행
kotlinc-jvm -classpath /Users/limyongtae/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-coroutines-core/1.2.2/6ff48bdfc38a8c22e3fc37605b6a6afaed3b6dbd/kotlinx-coroutines-core-1.2.2.jar -script

 

compute는 isActive 체크에 의해서 빠르게 종료됩니다. fetchResponse도 비슷합니다. 결과는 아래와 같습니다.

Let them run...
OK, that's enough, cancel
compute, checkActive true bailed out early
fetchResponse called with callAsync true: Job was cancelled
200 OK
compute, checkActive false ignored cancellation

 

방해금지

심각한 연산을 실행 중일 때 연산을 중단하면 비참한 결과가 나올 때가 있습니다. 중요한 작업에 아무런 서스펜션 포인트가 없으면 걱정할 필요가 없지만 yield(), delay(), await() 같은 서스펜션 포인트가 존재한다면 작업 중간에 취소되거나 방해받길 원치 않을 것입니다. withContext(NonCancellable) 함수를 호출하는 건 방해금지 효과를 나타냅니다. 

suspend fun doWork(id: Int, sleep: Long) = coroutineScope {
    try {
        println("$id: entered $sleep")
        delay(sleep)

        println("$id: finished nap $sleep")
        withContext(NonCancellable) {
            println("$id: do not distrub, please")
            delay(5000)
            println("$id: OK, you can talk to me now")
        }
        println("$id: outside the restricted context")
        println("$id: isActive: $isActive")
    } catch (e: CancellationException) {
        println("$id: doWork($sleep) was cancelled")
    }
}

 

위 코드의 흐름에 대해서 알아보겠습니다.

1. doWork 실행
2. 첫번째 delay에 걸리면 취소 가능
3. withContext(NonCancellable) 부터 취소 불가능
4. 두번째 delay에 걸려도 취소 불가능 - 취소 명령이 들어오면 isActive 프로퍼티 변경
5. isActive 프로퍼티 프린트

 

이 함수를 분리된 코루틴에서 두 차례 호출해보겠습니다. 2초동안 sleep 시키고, 코루틴의 부모를 취소해야합니다. 그리고 부모 코루틴이 완료될 때까지 기다려보겠습니다.

runBlocking {
    val job = launch(Dispatchers.Default) {
        launch { doWork(1, 3000) }
        launch { doWork(2, 1000) }
    }
    Thread.sleep(2000)
    job.cancel()
    println("cancelling")
    job.join()
    println("done")
}

 

결과를 살펴보면 NonCancellable 컨텍스트 밖에 코드를 실행 중일 때 취소 명령을 하면 코루틴이 취소된다는 사실을 확인할 수 있습니다. 하지만 코루틴이 NonCancellable 컨텍스트 내부에 있는 경우엔 취소되지 않고 인터럽트 없이 실행됩니다.

1: entered 3000
2: entered 1000
2: finished nap 1000
2: do not distrub, please
cancelling
1: doWork(3000) was cancelled
2: OK, you can talk to me now
2: outside the restricted context
2: isActive: false
done

 

양방향 취소

코루틴이 코드에서 처리해놓은 cancellation 예외가 아닌 다른 예외를 만난다면 코루틴은 자동으로 취소됩니다. 코루틴이 취소될때 코루틴의 부모 코루틴도 취소됩니다. 부모 코루틴이 취소될 때 모든 자식 코루틴도 취소됩니다. 이런 모든 동작들을 코루틴의 협력 방법에 자동으로 구성되어있습니다.

suspend fun fetchResponse(code: Int, delay: Int) = coroutineScope {
    try {
        val response = async {
            URL("http://httpstat.us/$code?sleep=$delay").readText()
        }.await()
        println(response)
    } catch (e: CancellationException) {
        println("${e.message} for fetchResponse $code")
    }
}

runBlocking {
    val handler = CoroutineExceptionHandler { _, ex ->
        println("Exception handled: ${ex.message}")
    }

    val job = launch(Dispatchers.IO + SupervisorJob() + handler) {
        launch { fetchResponse(200, 5000) }
        launch { fetchResponse(202, 1000) }
        launch { fetchResponse(404, 2000) }
    }
    job.join()
}

 

주어진 코드가 404일 경우 서비스 요청이 예외와 함께 실패합니다. 함수가 예외를 처리하고 있지 않으므로 함수를 실행중인 코루틴은 취소가 됩니다. 이는 아직 완료되지 않는 다른 모든 형제 코루틴을 취소합니다.

202 Accepted
Parent job is Cancelling for fetchResponse 200
Exception handled: http://httpstat.us/404?sleep=2000


코루틴은 cancellationException이 아닌 다른 예외가 발생하면 코루틴을 중지시킨다.
로그 살펴보자
1. 202는 성공
2. 404를 처리하던 중 Exception이 발생
3. 200은 성공할 수 있었는데....ㅠ 코루틴이 종료되면서 자식 코루틴도 같이 종료된다.

 

코루틴이 취소될 때 다른 모든 코루틴도 취소시키는 것이 기본 동작입니다. 슈퍼바이저 잡을 이용하면 코루틴 사이에서 취소에 대한 커뮤니케이션을 조정할 수 있습니다. 

 

슈퍼바이저 잡

코루틴에 handler를 전달한 방법처럼 인스턴스를 전달할 수 있습니다. launch(coroutineContext + supervisor ) 형태로 SupervisorJob 에서 supervisor 인스턴스를 사용할 수 있습니다. 또한 supervisorScope 호출을 이용해 슈퍼바이저를 적용할 자식을 감쌀 수도 있습니다. 두 경우 모두 슈퍼바이저가 적용된 자식이 취소된다고 부모는 취소되지 않습니다. 하지만 부모가 취소되면 자식도 취소됩니다. 이전 코드를 supervisor를 적용해보겠습니다.

// 이전 코드에서 supervisor를 사용하도록 변경하자
suspend fun fetchResponse(code: Int, delay: Int) = coroutineScope {
    try {
        val response = async {
            URL("http://httpstat.us/$code?sleep=$delay").readText()
        }.await()
        println(response)
    } catch (e: CancellationException) {
        println("${e.message} for fetchResponse $code")
    }
}

runBlocking {
    val handler = CoroutineExceptionHandler { _, ex ->
        println("Exception handled: ${ex.message}")
    }

    val job = launch(Dispatchers.IO + handler) {
        supervisorScope {  // supervisorScope 적용
            launch { fetchResponse(200, 5000) }
            launch { fetchResponse(202, 1000) }
            launch { fetchResponse(404, 2000) }
        }

    }
    Thread.sleep(4000)
    println("200 should still be running at this time")
    println("let the parent cancel now")
    job.cancel()
    job.join()
}

 

결과를 살펴보겠습니다.

202 Accepted
Exception handled: http://httpstat.us/404?sleep=2000
200 should still be running at this time
let the parent cancel now
Job was cancelled for fetchResponse 200

// 결과 분석
launch() 호출을 supervisorScope로 감쌌다.
로그를 살펴보자!
1. 202코드를 처리하는 코루틴은 완료!
2. 404코드를 처리하는 코루틴은 Exception으로 취소!
3. 200코드를 처리하는 코루틴은 이 시간 동안 영향을 받지 않고 5초가 되면 완료를 칠것이다!!
4. 하지만 4초 후 부모 코루틴을 취소시키면서 자식 코루틴도 같이 취소하게 되면서 결국 완료하지 못함

 

명확하게 독립된 작업을 하는 자식 코루틴의 탑다운 계층구조를 구성하고 싶을때만 코루틴에 슈퍼바이저를 적용해야합니다. 위 케이스에서 하나의 자식 코루틴이 실패하면 다른 현재 코루틴들은 영향을 받지 않기를 원합니다. 하지만 부모 코루틴이 취소되면 다른 작업들도 함께 취소되길 원합니다. 반면에 작업들간에 완전히 협력이 필요하다면 코루틴의 기본 동작에 의존해야합니다.

 

타임아웃을 이용한 프로그래밍

코루틴이 완료될 때까지 주어진 시간 이상의 시간이 사용된다면 CancellationException의 하위클래스인 TimeoutCancellationException을 받게 됩니다. 그 결과 완료까지 주어진 시간 이상의 시간이 걸리는 작업은 타임아웃 때문에 취소됩니다. 이전 코드에서 laucn() 호출을 withTimeout()으로 감싸고 3000 밀리초를 허용해보겠습니다.

suspend fun fetchResponse(code: Int, delay: Int) = coroutineScope {
    try {
        val response = async {
            URL("http://httpstat.us/$code?sleep=$delay").readText()
        }.await()
        println(response)
    } catch (e: CancellationException) {
        println("${e.message} for fetchResponse $code")
    }
}

runBlocking {
    val handler = CoroutineExceptionHandler { _, ex ->
        println("Exception handled: ${ex.message}")
    }

    val job = launch(Dispatchers.IO + handler) {
        withTimeout(3000) {  // withTimeout 적용
            launch { fetchResponse(200, 5000) }
            launch { fetchResponse(201, 1000) }
            launch { fetchResponse(202, 2000) }
        }

    }
    job.join()
}

 

주어진 3초보다 적게 걸린 코루틴은 성공적으로 완료됩니다. 3초 이상 걸린 코루틴은 취소가 되는 것을 확인할 수 있습니다.

201 Created
Timed out waiting for 3000 ms for fetchResponse 200
Timed out waiting for 3000 ms for fetchResponse 202

 


정리

- 코루틴은 비동기적으로 실행할 때뿐만 아니라 현명하고 쉽게 예외를 처리하는데도 좋은 방법을 제공한다.
- 코루틴은 동시실행, 비동기를 동기, 순차적 코드와 유사한 구조로 유지시킴
- 유지보수, 디버그하기 쉽게 도와줌
- 작업의 복잡한 관계를 코루틴의 계층구조로 맵핑하는 것을 실행의 생명주기를 관리하기 쉽게 만들어준다.
- 타임아웃을 사용하면 실행시간 제어 가능
- 슈퍼바이저 잡을 설정하면 계층구조 내의 코루틴의 상호작용 제어 가능!

반응형