코루틴의 비동기 사용 하는 방법

sundays·2023년 2월 15일
0

kotlin

목록 보기
15/19


멀티 스레드 환경에서 자원을 공유하게 될때 데이터의 무결성을 유지하기 위한 방법으로 동기화 기능을 사용하고 있습니다. 안드로이드 환경에서는 Coroutine의 Non-blocking 방식을 이용하여 스레드를 관리하는것이 권장되는데요. Async를 사용하는 이유는 Retrofit 과 같은 네트워크 API는 비동기 방식이 권장되고 있기 때문입니다.

안드로이드가 가지는 데이터들은 동기화 과정을 거쳐야 하기 때문에 이 작업은 정말 중요합니다. 게다가 Retrofit 의 Callback 은 다음과 같이 메인 스레드를 사용하는데 Request, Response가 같은 스레드를 사용하게 됩니다.

On Android, callbacks will be executed on the main thread.
On the JVM, callbacks will happen on the same thread that executed the HTTP request.

이 스레드가 응답이 늦어지거나 오지 않는다면 메인 스레드 Context가 오류가 생겨 앱이 작동하지 않을 가능성이 있습니다. 이런문제 때문에 코루틴에서는 해당 Routine을 쉽게 일시 중단하거나 다시 시작하게 할 수 있게 하는데요. 특히 코루틴은 문맥 교환없이 일시 중단(suspend) 를 통해 제어 할 수 있는 점이 가장 큰 장점이라 하겠습니다.

비동기 순차 실행

코루틴에 들어가게 되는 두가지 이상의 함수가 실행될때 순차 실행되는 방법은 실행 전 delay() 를 주어서 함수들끼리 겹치지 않게 실행될 수 있도록 하는 것입니다. 그리고 이 함수는 코루틴에서 사용할 수 있는 suspend 를 명시적으로 선언해주어야 합니다. 이것은 코루틴에서만 사용할 수 있으며 필수적입니다

suspend fun dowork1(): String {
    delay(1000)
    return "work1"
}

suspend fun dowork2(): String {
    delay(3000)
    return "work2"
}

fun main() = runBlocking {
    val time = measureTimeMillis {
        val one = dowork1()
        val two = dowork2()
    }
    println("time $time")
}

// time 4024

이 작업은 dowork1()에서 1초 dowork2()에서 3초가 걸리며 작업이 종료되기까지 4초가 걸리는 코루틴이 되겠습니다. 해당 코드 블록에서는 리턴값을 받지않고 작동하기 때문에 순차실행시 코드블록의 문맥에 따라 제한을 두게 하기 위해서 부모 스레드를 블록 하지 않는 withContext() 를 사용하게 할 수도 있습니다

fun main() = runBlocking {
    val time = measureTimeMillis {
        val one = withContext(Dispatchers.IO) {
            dowork1()
        }
        if (one != "work2") {
            dowork2()
        }
    }
    println("time $time")
}

one 에서 반환값을 리턴하기 까지 일시 중단하게 하기 때문에 순차적으로 문맥에 따라 순서를 변경하거나 실행되지 않게 할 수도 있습니다.

비동기 병렬 실행

병렬로 실행하게 되면 두가지 이상의 함수가 실행될때 각각 동시에 실행되어 태스크들이 실행시간이 단축될 수 있습니다.

1. launch

launch를 사용하면 Job 객체를 반환하게 되는데, 이 Job은 상위 코드를 블록시키지 않는 Non-Block 함수로 기본적으로 실행결과를 반환하지는 않습니다, 그러나 job.join() 으로 명시적 선언을 하게되면 이 객체는 상위코드의 완료를 기다리게 될 수 있게 됩니다.

fun main() = runBlocking {
    val time = measureTimeMillis {
        val one = launch { dowork1() }
        val two = launch { dowork2() }
        one.join()
        two.join()
    }
    println("time $time")
}

// time 3029

이 작업은 dowork1()에서 1초 dowork2()에서 3초가 걸리며 작업이 종료되기까지 3초가 걸리는 코루틴이 되겠습니다. 또 join은 취소하고 싶을 경우 join.cancel()을 사용하여 코루틴 작업을 즉시 취소 할 수 있습니다.

2. sync

sync는 비동기 호출을 위해 만든 코루틴으로 코루틴으로 결과와 예외를 반환하는데 Deffered<T> 를 반환합니다. await() 으로 작업이 완료될때까지 기다리고 결과를 반환하고 있습니다.

fun main() = runBlocking {
    val time = measureTimeMillis {
        val one = async { dowork1() }
        val two = async { dowork2() }
        one.await()
        two.await()
    }
    println("time $time")
}

// time 3405

이 작업은 dowork1()에서 1초 dowork2()에서 3초가 걸리며 작업이 종료되기까지 3초가 걸리는 코루틴이 되겠습니다.

coroutineScope

모든 비동기를 사용하는 방법들을 알게되었습니다만, 예제 코드가 아닌 실제 코드들을 보면 대부분 runBlocking을 사용하지 않고 coroutineScope 안에서 개발되고 있습니다. runBlocking은 단순히 현재 스레드를 블록킹하는 Global Scope 에서 실행되는 함수이기 때문에 권장되지 않고 사용자가 관리하기 편한 스코프를 생성하여 Non-Blocking 으로 사용할 수 있는 suspend 형태의 함수를 사용할 수 있기 때문입니다.

public suspend fun <R> coroutineScope(
block: suspend kotlinx.coroutines.CoroutineScope.() -> R)
: R { contract { /* compiled contract */ }; /* compiled code */ }

coroutineScope vs CoroutineScope

같은 코루틴 스코프 이지만 사용법에 대해서는 차이가 있습니다. 그리고 완료 시점에 따라서도 다르게 사용합니다.

public fun CoroutineScope(
context: kotlin.coroutines.CoroutineContext)
: kotlinx.coroutines.CoroutineScope { /* compiled code */ }

CoroutineScope 는 부모 - 자식의 스코프 관계에서 자식이 늦게 끝나더라도 끝날때까지 기다려 주지 않는 코루틴 스코프입니다. 예를 들면 로드를 하는데 필요한 자식 코루틴을 따로 만든 순간 부모에선 이 루틴을 신경쓰고 싶지 않을때 사용될것입니다.
반대로 coroutineScope 는 Global scope에서 처럼 부모가 자식 코루틴이 종료 될때까지 기다려주게 됩니다.

fun main() = runBlocking {
    val time = measureTimeMillis {
        CoroutineScope(Dispatchers.Default).launch {
            dowork1()
        }
    }
    println("time $time")
}

CoroutineScope(Dispatchers.Default).launch 에서는 부모는 기다려 주지 않고 time을 1초가 걸리지 않는 시간대로 끝내고 종료 시켜 버립니다.

fun main() = runBlocking {
    val time = measureTimeMillis {
         coroutineScope {
            dowork1()
        }
    }
    println("time $time")
}

그와 반대로 coroutineScope 는 하위 코루틴이 종료 될때까지 기다려 주며 time을 1초가 넘는 시간대로 끝내고 종료 시키게 됩니다.

예외 처리

결론은 Non-block 인CoroutineScope를 사용하여야 합니다 하지만 이것을 사용하는데에는 주의 사항이 있습니다. 그것은 결국 에러 예외 처리를 어떻게 하느냐는 것 입니다. 예외 처리를 하지 않으면 호출한 쪽에서 이미 중단되었다고 해도 백그라운드 상에 아직 남아있게 되는 경우에는 성능에 큰 문제를 야기할 수 있습니다.

fun main() = runBlocking {
    val time = measureTimeMillis {
        val one = CoroutineScope(Dispatchers.IO).async {
            try {
                dowork1() // 실행 불가
            } catch (e: Exception) {
				// 예와 처리
            } finally {
            	// 실행 완료 후 처리
            }
        }
        val two = async { dowork2() } // 실행 완료
        one.await()
        two.await()
    }
    println("time $time")
}

/*결과*/
//work2
//time 3062

저는 위의 코드와 같이 Exception 처리를 전체로 해주어서 상위에 까지 에러의 영향을 끼치지 않는 코드로 변경하였습니다. work1()은 실행되지않고 work2()는 3초 실행후 종료하게 됩니다.

Use

저는 Compose에서 UI를 구성하게 되는 부분에서 CoroutineScope를 사용해 주었습니다. 이렇게 하지 않으면 메인 스레드에서도 UI를 변경해주는데 비용을 많이 사용하게 되어 앱이 종료되기 때문에 필수적으로 사용해주어야 하기 때문입니다

Reference

profile
develop life

0개의 댓글