Kotlin

코루틴(Coroutine)

kakaroo 2022. 4. 3. 15:01
반응형

article logo

코루틴은 비동기적으로 실행되는 코드를 간소화하기 위해 Android에서 사용할 수 있는 동시 실행 설계 패턴입니다. 코루틴은 Kotlin 버전 1.3에 추가되었으며 다른 언어에서 확립된 개념을 기반으로 합니다.

 

https://kotlinlang.org/docs/reference/coroutines/coroutines-guide.html

 

kotlinlang.org

 

기능

코루틴은 Android의 비동기 프로그래밍에 권장되는 솔루션입니다. 주목할 만한 기능은 다음과 같습니다.

  • 경량: 코루틴을 실행 중인 스레드를 차단하지 않는 정지를 지원하므로 단일 스레드에서 많은 코루틴을 실행할 수 있습니다. 정지는 많은 동시 작업을 지원하면서도 차단보다 메모리를 절약합니다.
  • 메모리 누수 감소: 구조화된 동시 실행을 사용하여 범위 내에서 작업을 실행합니다.
  • 기본으로 제공되는 취소 지원: 실행 중인 코루틴 계층 구조를 통해 자동으로 취소가 전달됩니다.
  • Jetpack 통합: 많은 Jetpack 라이브러리에 코루틴을 완전히 지원하는 확장 프로그램이 포함되어 있습니다. 일부 라이브러리는 구조화된 동시 실행에 사용할 수 있는 자체 코루틴 범위도 제공합니다.

 

비동기 프로그래밍을 이해하기 위한 가장 쉬운 예입니다.

print("Start Main Thread")
GlobalScope.launch {
    delay(3000)
    print("in Coroutine ...")
}
print("End Main Thread")
}

//output
Start Main Thread
nd Main Thread
in Coroutine ...

 

GlobalScope : 프로그램 어디서나 제어, 동작이 가능함

CoroutineScope : 특정 목적의 dispatcher지정하여 제어 및 동작이 가능함

 

 

dispatcher

  • Default : 기본적인 백그라운드 동작
  • IO : 파일, 네트워크에 최적화  됨
  • Main : UI thread

launch()

launch() 함수로 시작된 코루틴 블록은 Job 객체를 반합니다.

val job : Job = launch {
    ...
}

반환받은 Job 객체로 코루틴 블록을 취소하거나, 다음 작업의 수행전 코루틴 블록이 완료 되기를 기다릴수 있습니다.

val job = launch {
	var i = 0
	while (i < 10) {
		delay(500)
		i++
	}
}
job.join() // 완료 대기
job.cancel() // 취소

 

val job1 : Job = launch {
	var i = 0
	while (i < 10) {
		delay(500)
		i++
	}
}

val job2 = launch {
	var i = 0
	while (i < 10) {
		delay(1000)
		i++
	}
}

job1.join()
job2.join()

여러개의 launch 코루틴 블록을 실행할 경우 각각의 Job 객체에 대해서 join() 함수로 코루틴 블록이 완료 될때까지 다음 코드 수행을 대기할수 있습니다.

 

모든 Job 객체에 대해서 일일히 join() 함수를 호출하지 않고 joinAll() 함수를 이용하여 모든 launch 코루틴 블록이 완료 되기를 기다릴수도 있습니다.

joinAll(job1, job2)

 

다음의 예시와 같이 첫번째 launch 코루틴 블록에서 반환받은 Job 객체를 두번째 launch() 함수의 인자로 사용하면, 동일한 Job 객체로 두개의 코루틴 블록을 모두 제어 할수 있습니다.

val job1 = launch {
	var i = 0
	while (i < 10) {
		delay(500)
		i++
	}
}

// 위 블록 과 같은 job1 객체를 사용
launch(job1) {
	var i = 0
	while (i < 10) {
		delay(1000)
		i++
	}
}

// 같은 job 객체를 사용하게 되면
// joinAll(job1, job2) 와 같다
job1.join()

 

launch() 함수로 정의된 코루틴 블록은 즉시 수행되며, 반환 받은 Job 객체는 해당 블록을 제어는 할수 있지만 코루틴 블록의 결과를 반환하지는 않습니다.

코루틴 블록의 결과 값을 반환받고 싶다면 async() 코루틴 블록을 생성합니다.

 


async()

async() 함수로 시작된 코루틴 블록은 Deferred 객체를 반환합니다.

val deferred : Deferred<T> = async {
    ...
    T // 결과값
}

이렇게 시작된 코루틴 블록은 Deferred 객체를 이용해 제어가 가능하며 동시에 코루틴 블록에서 계산된 결과값을 반환 받을수 있습니다.

val deferred : Deferred<String> = async {
    var i = 0
    while (i < 10) {
        delay(500)
        i++
    }

    "result"
}

val msg = deferred.await()
println(msg) // result 출력

 

여러개의 async 코루틴 블록을 실행할 경우 각각의 Deferred 객체에 대해서 await() 함수로 코루틴 블록이 완료 될때까지 다음 코드 수행을 대기할수 있습니다. await() 함수는 코루틴 블록이 완료되면 결과를 반환합니다.

 val deferred1 = async {
    var i = 0
    while (i < 10) {
        delay(500)
        i++
    }

    "result1"
}

val deferred2 = async {
    var i = 0
    while (i < 10) {
        delay(1000)
        i++
    }

    "result2"
}

val result1 = deferred1.await()
val result2 = deferred2.await()

println("$result1 , $result2") // result1 , result 2 출력

 

각각의 Deferred 객체에 대해서 await() 함수를 호출하지 않고 awaitAll() 함수를 이용하여 모든 async 코루틴 블록이 완료 되기를 기다릴수도 있습니다.

awaitAll(deferred1, deferred2)

 

또는, 다음의 예시와 같이 첫번째 async 코루틴 블록에서 반환받은 Deferred 객체를 두번째 async() 함수의 인자로 사용하면, 동일한 Deferred 객체로 두개의 코루틴 블록을 모두 제어 할수 있습니다.

val deferred = async {
    var i = 0
    while (i < 10) {
        delay(500)
        i++
    }

    "result1"
}

// 같은 Deferred 객체 사용
async(deferred) {
    var i = 0
    while (i < 10) {
        delay(1000)
        i++
    }

    "result2"
}

val msg = deferred.await()
println(msg) // 첫번째 블록 결과인 result1 출력

단, 여러개의 async 코루틴 블록에 같은 Deferred 객체를 사용할경우 await() 함수 호출시 전달되는 최종적인 결과값은 첫번째 async 코루틴 블록의 결과값 만을 전달한다는것에 주의해야 합니다.

 

 

지연 실행

launch 코루틴 블록 과 async 코루틴 블록은 모두 처리 시점을 뒤로 미룰수 있습니다.

각 코루틴 블록 함수의 start 인자에 CoroutineStart.LAZY 를 사용하면 해당 코루틴 블록은 지연 되어 실행됩니다.

 

val job = launch (start = CoroutineStart.LAZY) {
    ...
}
또는
val deferred = async (start = CoroutineStart.LAZY) {
    ...
}

 

launch 코루틴 블록을 지연 실행 시킬 경우, Job 클래스 의 start() 함수 를 호출하거나 join() 함수를 호출하는 시점에 launch 코드 블록이 수행됩니다.

job.start()	//deferred.start()
또는
job.join()	//deferred.await()

 

println("start")
val deferred = async(start = CoroutineStart.LAZY) {
	var i = 0
	while (i < 5) {
		delay(500)
		println("lazy async $i")
		i++
	}
}
deferred.await()
println("end")

//output
start
lazy async 0
lazy async 1
lazy async 2
lazy async 3
lazy async 4
end

 

하지만 deferred.await()를 deferred.start()로 바꾸면 출력 결과는 다음과 같습니다.

end 는 start가 출력 되자마자 출력되고, 코루틴 블록이 수행됩니다.

start
end
lazy async 0
lazy async 1
lazy async 2
lazy async 3
lazy async 4

withContext() 함수 사용

 

withContext 블록은 IO Thread의 작업이 끝나기 전까지 Main Thread에서 수행되는 코루틴을 일시중단하도록 만듭니다.

IO Thread의 작업이 끝나면 Async result가 반환됩니다. 

작업 중간 결과에 따른 UI 처리가 필요할 때 사용하면 좋습니다.

하지만, 너무 많이 사용하는 것은 좋지 않습니다.

비동기 작업을 하는 이유 자체가 순차적인 처리를 했을 때 연산 시간을 줄이기 위함인데, 순차적으로 처리하도록 만들어보리면 비동기 작업의 장점이 사라지기 때문입니다.

 


일정시간 이후 자동 취소 되는 코루틴 블록 만들기

간단히 말해서 Timeout 으로 동작하는 코루틴 블록을 작성하는 방법입니다.

다양한 이유로 코루틴 의 작업을 취소 할 필요가 있습니다. 그중 가장 빈번히 발생하는 이유는 유효한 처리 시간을 초과 하여 더이상의 작업이 무의미 하기 때문입니다.

withTimeout() 함수 사용

withTimeout() 함수는 첫번째 인자로 작업을 수행할 시간, 두번째 인자로 수행할 블록 함수를 받습니다.

withTimeout(1300L) {
    repeat(1000) { i ->
        println("I'm sleeping $i ...")
        delay(500L)
    }
}

 

withTimeoutOrNull() 함수 사용

withTimeoutOrNull() 함수도 kotlinx.coroutines 패키지에 포함되어 제공됩니다.

val result = withTimeoutOrNull(1300L) {
    repeat(1000) { i ->
        println("I'm sleeping $i ...")
        delay(500L)
    }
    "Done"
}

println("Result is $result")

작업 취소 시 TimeoutCancellationException 이 발생하기 때문에 작업 취소 보다는 애플리케이션이 강제 중지 된다고 표현하는게 맞습니다.

try-catch 를 이용해 TimeoutCancellationException 을 처리할수도 있지만, 코틀린에서 제공하는 withTimeoutOrNull() 함수를 이용할수도 있습니다.

 

시간내 정상 종료 시 값을 반환할수도 있고, 만약 시간내 처리되지 못한다면 withTimeout() 함수 와는 다르게 TimeoutCancellationException 이 발생하지 않고, 값으로 null 이 반환 됩니다.


아래 Async로 구현한 것을 코루틴으로 변경해 보겠습니다.

https://kakaroo.tistory.com/70

 

BlogViewer (feat. JSoup Crawling)

Tistroy 에 게시된 글을 crawling 을 통해 cardview 형태로 만들어보겠습니다. 전체적인 UI는 아래 게시글을 재사용하겠습니다. https://kakaroo.tistory.com/57 NewsFeed - JSoup / Nested RecyclerView 관심있..

kakaroo.tistory.com

 

CoroutineScope(Dispatchers.IO).launch 로 scope를 생성한 뒤,

timeout 시 다시 원상태로 돌아오기 위해 withTimeout 을 try문으로 설정해 줍니다.

그 다음, async로 executeCrawling 함수를 실행하여 게시글들을 가져옵니다.

UI를 처리하는 부분은 withContext 함수에 Dispathcer.Main 을 파라미터로 처리합니다.

var bComplete = if(searchType == Common.EditorInputType.NONE) false else true
var pageIndex = 0
var asyncTryCnt = 0

do {
    var uriTag = ""
    when(searchType) {
        Common.EditorInputType.PAGE -> uriTag += Common.PAGE_TAG + searchStr
        Common.EditorInputType.SEARCH -> uriTag += Common.SEARCH_TAG + searchStr
        else -> uriTag += Common.PAGE_TAG + ++pageIndex
    }
    
    asyncTryCnt++
    //Log.d(Common.MY_TAG, "pre: asyncTryCnt[$asyncTryCnt]")
    binding.btSearch.isEnabled = false

    CoroutineScope(Dispatchers.IO).launch {

        try {
            withTimeout(Common.HTTP_CRAWLING_TIMEOUT_MILLIS) {
                //Log.e(Common.MY_TAG, "Force to stop dueto Coroutine's Timeout")
                val coroutine = async {
                    executeCrawling(url, uriTag, articlesTag) }

                val result = coroutine.await()
                //Log.e(Common.MY_TAG, "asyncTryCnt[$asyncTryCnt], pageIndex[$pageIndex], result size is ${result.size}")

                asyncTryCnt--
                mArticleList.addAll(result)

                mArticleList.sortWith(compareByDescending<Article> {it.date})

                if(searchType == Common.EditorInputType.NONE)
                    mCategoryList.clear()

                val mapList = mArticleList.groupBy { it.categoryName }
                //Log.i(Common.MY_TAG, mapList.toString())

                mapList.forEach{ item -> mCategoryList.add(Category(item.key,
                    item.value as ArrayList<Article>
                ))}

                withContext(Dispatchers.Main) {
                    mAdapter.notifyDataSetChanged()

                    if(asyncTryCnt == 0) {
                        Log.d(Common.MY_TAG, "btSearch is enabled")
                        binding.btSearch.isEnabled = true
                    }

                    if (result.size == 0 && mCategoryList.isEmpty()) {
                        bComplete = true
                        if(searchType != Common.EditorInputType.NONE) {
                            Toast.makeText(
                                applicationContext,
                                "게시글이 없습니다.!!",
                                Toast.LENGTH_SHORT
                            ).show()
                        }
                    }
                }
            }
        } catch(te: TimeoutCancellationException) {
            Log.e(Common.MY_TAG, "Timetout!!! - asyncTryCnt[$asyncTryCnt], pageIndex[$pageIndex]")
            withContext(Dispatchers.Main) {
                binding.btSearch.isEnabled = true
                bComplete = true

                Toast.makeText(
                    applicationContext,
                    "시간 초과",
                    Toast.LENGTH_SHORT
                ).show()
            }
        }
    }

    /*val jsoupAsyncTask =
        JSoupParser(url, uriTag, articlesTag, object : onPostExecuteListener {
            override fun onPostExecute(
                result: ArrayList<Article>,
                *//*categorySet: MutableSet<String>,*//*
                bError: Boolean
            ) {
                asyncTryCnt--
                if(bError || result.size == 0) {
                    Log.e(Common.MY_TAG, "asyncTryCnt[$asyncTryCnt], pageIndex[$pageIndex], error[$bError}, result size is ${result.size}")
                    bComplete = true
                } else {
                    mArticleList.addAll(result)
                    Log.i(Common.MY_TAG, "asyncTryCnt[$asyncTryCnt], url[$url] - result[${result.size}]")

                    if(searchType == Common.EditorInputType.NONE)
                        mCategoryList.clear()

                    val mapList = mArticleList.groupBy { it.categoryName }
                    //Log.i(Common.MY_TAG, mapList.toString())

                    mapList.forEach{ item -> mCategoryList.add(Category(item.key,
                        item.value as ArrayList<Article>
                    ))}
                    mAdapter.notifyDataSetChanged()
                }
                if(asyncTryCnt == 0) {
                    Log.d(Common.MY_TAG, "btSearch is enabled")
                    binding.btSearch.isEnabled = true
                }

                runOnUiThread {
                    if (result.size == 0 && mCategoryList.isEmpty()) {
                        Toast.makeText(
                            applicationContext,
                            "게시글이 없습니다.!!",
                            Toast.LENGTH_SHORT
                        ).show()
                        bComplete = true
                    }
                }
            }
        })
    jsoupAsyncTask.execute()*/
} while(!bComplete && pageIndex < pageMaxNumInt)

....
....

private suspend fun executeCrawling(url: String, uriTag: String, articlesTag: ArticlesTag): ArrayList<Article> {
        var articleList = ArrayList<Article>()

        try {
            val doc: Document = Jsoup.connect(url+uriTag)
                .ignoreContentType(true)
                .get()

            //Log.i(Common.MY_TAG, doc.toString())

            val contentElements: Elements = doc.select(articlesTag.articleTag)
            for ((i, elem) in contentElements.withIndex()) {
                val category = elem.select(articlesTag.categoryTag).text()
                val articleUrl = elem.select(articlesTag.articleUrlTag).first().attr(Common.HREF_TAG)
                val title = elem.select(articlesTag.titleTag).text()
                val date = elem.select(articlesTag.dateTag).text()
                val categoryUrl = elem.select(articlesTag.categoryTag).first().attr(Common.HREF_TAG)
                val imageUrl = elem.select(articlesTag.imageTag).attr(Common.SRC_TAG)
                val summary = elem.select(articlesTag.summaryTag).text()

                //mCategorySet.add(category)
                articleList.add(Article(i, category, title, date, url+categoryUrl, url+articleUrl, imageUrl, summary))
            }

        } catch (e: IOException) {
            // HttpUrlConnection will throw an IOException if any 4XX
            // response is sent. If we request the status again, this
            // time the internal status will be properly set, and we'll be
            // able to retrieve it.
            Log.e(Common.MY_TAG, "Jsoup connection has error: $e")
        }

        return articleList
    }

 

성능을 높이기 위해 스레드 풀의 개수를 늘려봅니다.

//Thread pool
val dispatcher = newFixedThreadPoolContext(Common.COROUTINE_THREAD_POOL_NUM, "IO")	//1보다 큰 숫자

 

CoroutineScope(Dispatchers.IO).launch() {
    try {
        withTimeout(Common.HTTP_CRAWLING_TIMEOUT_MILLIS) {
            //Log.i(Common.MY_TAG, "Force to stop due to Coroutine's Timeout")

            /*  single thread ; suspend fun 호출
           val coroutine = async {
                executeCrawling(url, uriTag, articlesTag) }
            val result = coroutine.await()
            mArticleList.addAll(result)
            Log.i(Common.MY_TAG, "asyncTryCnt[$asyncTryCnt], pageIndex[$pageIndex], result size is ${result.size}")
             */
            
            //dispatcher를 인수로 하는 비동기 함수 호출
            val result = asyncFetchElement(url, uriTag, articlesTag, dispatcher).await()
            mArticleList.addAll(result)

            //feeds는 url list로 되었을 때는 아래와 같이 처리
            /*
            var requests = mutableListOf<Deferred<List<Article>>>()
            feeds.mapTo(requests) {
                asyncFetchElement(url, it, articlesTag, dispatcher)
            }
            requests.forEach {
                it.join()   //deferred가 예외를 가질 경우 예외를 갖지 않게 await대신 join으로 처리
            }
            mArticleList = requests.filter{!it.isCancelled}
                .map{it.getCompleted()} as ArrayList<Article>
            */

            asyncTryCnt--

 

기존 suspend 함수 대신 dispatcher를 인수로 갖는 비동기 함수로 처리해 줍니다.

private fun asyncFetchElement(url: String, uriTag: String, articlesTag: ArticlesTag,
                              dispatcher: CoroutineDispatcher) = GlobalScope.async(dispatcher) {
    var articleList = ArrayList<Article>()
    ..
    ..
    
    articleList.map{ it }
}

 

 

동작중인 함수를 일시 중단하는 suspending functions에 대해 포스팅 해 보겠습니다.

반응형

'Kotlin' 카테고리의 다른 글

object, companion object  (0) 2022.04.15
확장함수 (Extension Function)  (0) 2022.04.14
BlogViewer (feat. JSoup Crawling)  (1) 2022.04.03
inline 함수  (0) 2022.04.01
enum class, sealed class  (0) 2022.03.31