본문 바로가기
도서/코틀린 코루틴

[DroidKnights 2023] 김준비 - Coroutine Deep Dive - Android 실전편

by 안스 인민군 2023. 11. 14.

이번 글은 DroidKnights 2023에 나온 김준비님의 Coroutine Deep Dive의 내용을 정리한 글입니다.


What is Coroutine?

Routine : 컴퓨터 프로그램의 일부로써 특정한 일을 실행하기 위한 일련의 명령

SubRoutine?

프로그램 가운데 하나 이상의 장소에서 필요할 때마다 되풀이 해서 사용할 수 있는 부분적 프로그램이며 실행 후에는 메인 루틴이 호출한 장소로 되돌아간다.

fun a() {
	b()
    	c()
}

Coroutine 이란?

일시 중단(suspend)과 재개(resume)가 가능한 루틴이며 특정 Thread에 결합되지 않으며, 하나의 Thread에서 중단하고 다른 Thread에서 재개 가능한 부분적 프로그래밍이다.

How does suspension work?

suspension point는 비디어 게임의 check-point와 유사하다.게임에서 check-point했을 시 정지했다가 다시 시작할 수 있듯이 suspended 상태가 되면, Continuation을 통해 정지된 코루틴을 재개 가능하다.

suspended 상태에서는 어떤 자원도 사용하지 않기에  suspend function으로 선언했다고 해서 모두가 중단 가능한 함수는 아니다.

 

아래 Continuation Interface 함수를 보자.

kotlin에서는 Continuation 객체를 통해 다음에 수행할 중단/재개 시점을 Context에 저장하여 관리한다.
즉, Continuation 객체는 Callback Interface를 일반화한 객체라고도 볼 수 있다.

@SinceKotlin("1.3")
public interface Continuation<in T> {
    // 이 Continuation 에 해당하는 CoroutineContext
    public val context: CoroutineContext

    // 마지막 중단점의 반환값으로 성공 또는 실패한 결과를 전달하는 해당 코루틴을 재시작한다.
    public fun resumeWith(result: Result<T>)
}
@SinceKotlin("1.3")
@InlineOnly
public inline fun <T> Continuation(
    context: CoroutineContext,
    crossinline resumeWith: (Result<T>) -> Unit
): Continuation<T> =
    object : Continuation<T> {
        override val context: CoroutineContext
            get() = context

        override fun resumeWith(result: Result<T>) =
            resumeWith(result)
    }

이렇게 Continuation은 프로세스의 실행에서 다음에 수행할 위치를 구조화할 수 있기 때문에, Exception, Generator, Coroutine 등의 프로그래밍 언어에서 제어 메커니즘을 인코딩하는데 효율적이다.

 

suspend function은 코루틴이 아니다. 코루틴을 일시 정지 시킬 수 일시 정지 시킬 수 있는 함수일 뿐이다.

suspend fun main() {
	println("Droid")
    	suspendCoroutine<Unit> { }
    	println("Knights")
}

///////////////////////////////////
//Droid
종료

suspend fun main() {
	println("2023")
    	suspendCoroutine<Unit> { 
        	println("Droid")
        }
    	println("Knights")
}

///////////////////////////////////
//2023
//Droid
종료


suspend fun main() {
	println("2023")
    	suspendCoroutine<Unit> { continuation ->
        	println("Droid")
            continuation.resume(Unit)
        }
    	println("Knights")
}

///////////////////////////////////
//2023
//Droid
//Knights
종료

 

위의 예시를 보면 suspend에 continuation.resume을 해주지 않으면 "Knights"가 실행되지 않는다.

그렇다면 suspend 와 resume은 꼭 동일한 Thread에서만 이루어져야 할까?

코루틴의 suspend <-> resume은 서로 다른 Thread 에서도 가능하다.

아래 예시를 보면 continueOnAnotherThread에서 다른 쓰레드를 만들고 resume을 했음에도 println("Knights")가 출력된다.

fun continueOnAnotherThread(continuation: Continuation<Unit>) {
    Thread {
        Thread.sleep(1000L)
        println("(resume on another thread)")
        continuation.resume(Unit)
    }.start()
}

suspend fun main() {
    println("Droid")
    
    suspendCoroutine<Unit> { continuation ->  
        continueOnAnotherThread(continuation)
    }
    
    println("Knights")
}


//////////////////////////////////////////
//Droid
(1초뒤)
//(resume on another thread)
//Knights
종료

 

이 원리를 활용하면 아래 예제코드와 같다.

Suspend function 중 대표적으로 delay() 함수가 있다.

delay()가 어떻게 구현되어 있는지 확인해보면 물론 실제로 아래 코드보다 복잡하게 되어 있지만 간략하게 컨셉만 가져와 봤다.

timeMillis뒤에 스케쥴에 의해서 새로운 쓰레드에서 continuation에 resume을 달아줬다.

이렇게 했을때 기존에 알고 있는 Thread.sleap() 함수와 차이가 있다.

앞서 말했듯 Thread.sleap()는 해당 쓰레드를 모두 점유를 해서 다른 자원이 쓰레드에서 동작할 수 없는데

Suspend function 의 delay()는 suspend한 쓰레드는 마저 다른 코루틴을 동작하고 timeMillis뒤에 스캐쥴에 의해서 새로 생성된 쓰레드에 쓰레드 내부적으로 resume을 해주기 때문에 Thread.sleap() 함수보다 훨씬 더 효율적으로 자원을 사용하면서 delay를 시킬 수 있다.

private val executor = Executors.newSingleThreadExecutor{
    Thread(it, "scheduler").apply { isDaemon = true }
}

suspend fun delay(timeMillis: Long) {
    suspendCoroutine {continuation ->
        executor.schedule({
            Thread.sleep(timeMillis)
            continuation.resume(Unit)
        }, timeMillis, java.util.concurrent.TimeUnit.MILLISECONDS)
    }
}

fun main() {
    println("Droid")
    
    //Thread.sleep(1000L)
    delay(1000L)
    
    println("Knights")
}

 

다음으로 알아 볼 키워드는

suspend Function으로 선언된 함수를 Decompile 해보면, 함수의 마지막 파라미터로 Continuation이 추가되어 있다.

이 사실은 기억해 놓을 필요가 있다.

아래 예시를 보자.

위의 함수는 continuation을 잡았고 아래 함수는 잡지 않았다고 가정하자.

suspend fun getUserName(): String {
    return suspendCoroutine {continuation ->
        continuation.resume("DroidKnights")
    }
}

suspend fun printUserName(name: String) {
    println(name)
}

 

이 함수를 decompile해보면 아래와 같다.

아래 코드를 보면 두 함수 다 Continuation이 파라미터로 추가되어 있다.

여기서 재미있는 점은 getUserName()은 Continuation을 사용하고 있고, printUserName()은 Continuation을 사용하지 않는다.

이렇기에 우리는 무분별하게 suspend function을 사용하지 않고 중단 가능한 함수를 만들 수 있을 지 예상하면서 코드를 작성할 수 있다.

이제 continuation을 살펴 봤으니 더 많이 접하는 CoroutionContext와 CoroutineScope에 대해서도 알아보자.

CoroutineContext & CoroutineScope

CoroutineContext

  • interface이다.
  • 코루틴이 실행 될 때 코루틴의 실행 환경을 정의하고 구성한다.
  • 여러 개의 Element 객체를 포함하는데, Map or Set 자료구조와 유사한 컨셉이다.
  • Job, CoroutineName, CoroutineDispatcher, CoroutineExceptionHandler 등이 있다.
  • CoroutineContext 가 포함하는 모든 Element는 마치 Map처럼 중복을 허용하지 않는 선에서 unique key를 가진다.
  • 모든 CoroutineScope와 suspend Function은 CoroutineContext을 가지고 있기에 접근이 가능하다.
  • CoroutineContext는 Element와 key를 적절하게 구현한다면 커스텀도 가능하다.

아래 예시를 보면 ctxName2는 ctxName에 CoroutineName("2023 DroidKnights")을 더했을때 기존 ctxName이 가지고 있던 CoroutineName인 "DroidKnights"에 덮어져 "2023 DroidKnights"만 출력하게 된다.

이로써, 같은 keydml CoroutineContext는 중복 허용을 하지 않는다.

또, 제거할때는 minus operator가 아닌, minusKey()를 활용해야 한다.

import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.Job
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext

fun main() {
    val emptyContext: CoroutineContext = EmptyCoroutineContext
    println(emptyContext[CoroutineName]) //null
    println(emptyContext[Job]) //null
    
    val ctxName = emptyContext + CoroutineName("DroidKnights")
    println(ctxName[CoroutineName]) //DroidKnights
    
    val ctxName2 = ctxName + CoroutineName("2023 DroidKnights")
    println(ctxName2[CoroutineName]) //2023 DroidKnights
    
    val ctx = ctxName.minusKey(CoroutineName)
    println(ctx[CoroutineName]) //EmptyCoroutineName
}

이런 특징을 활용하여 아래와 같은 코드를 작성해 볼 수 있다.

 

아래 코드는 droidKnightsCEH이름을 가진 프로젝트 전반적으로 Exception을 핸들링할 수 있는 공용 핸들러 객체를 만들고 모든 곳에 적용하려고 한다.

이후 CoroutineScope의 확장함수 safeLaunch를 만들고 CoroutineContext의 파라미터값이 어떤 것이 들어오더라도 droidKnightCEH값을 적용할 수 있다.

import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlin.coroutines.EmptyCoroutineContext


val droidKnightsCEH = CoroutineExceptionHandler { coroutineContext, throwable ->
    // handle throwable
}

fun CoroutineScope.safeLaunch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    return launch(droidKnightsCEH + context, start, block)
}

여기서 중요한 것은 context의 순서가 중요하다.

이유는 '+' operator 뒤에 오는 객체가 앞 객체의 값을 덮어쓰기 때문이다.

 

다음으로 알아볼 것은

모든 suspend function에서는 CoroutineContext 에 접근이 가능하다.

이유는 아래 삼단논법에 의해서이다.

  1. suspend fuction은 컴파일 시 Continuation 을 파라미터로 추가해준다.
  2. Continuation은 CoroutineContext를 가진다.
  3. 모든 suspend function은 current coroutine의 Context에 접근 가능하다.

이런 사실은 아래와 같은 함수를 만들 수 있다.

아래 함수는 CoroutineExceptionHandler가 정상적으로 적용이 되었으면 통과하고 아닐 시 Exception을 반환하는 함수이다.

 

CoroutineScope의 설명은 다음과 같다.

  1. child는 parent 를 상속한다.
  2. parent는 모든 children 이 끝날 때까지 기다린다.
  3. parent가 취소되면, children도 취소된다.
  4. child 에서 에러가 발생하면, parent 에게도 전파된다.
  5. launch(), async()는 CoroutineScope의 확장 함수이다.
  6. runBlocking()은 CoroutineScope의 확장 함수가 아니며 오직 rootCoroutine으로만 수행하게 된다.
  7. runBlocking()은 오직 root coroutine 으로 사용이 된다.

CoroutineScope의 코드는 다음과 같이 interface로 정의 되어 있으며 coroutineContext 프로퍼티를 가진다.

또 이와 유사하게 runBlocking이 정의되어 있고 CoroutineScope.launch, CoroutineScope.async는 다음과 같다.

여기서 CoroutineScope.launch의 내부를 보면 context를 파라미터로 받고 있기에 CoroutinScope도 당연히 CoroutineContext를 가진다.

CoroutineScope.launch이 실행이 되면 newCoroutineContext()함수를 통해서 context를 복사를 해준다.

그러면 receiver의 CoroutineContext가 부모의 CoroutineContext가 될 것이고 파라미터로 받은 CoroutineContext를 복사를 해서 부모로 받은 값 + 파라미터로 받은 값을 덮어쓰는 구조로 된다.

 

그러면 CoroutineScope가 안드로이드에서 어떻게 쓰이는지 알아보자.

CoroutineScope in Android

LifecycleCoroutineScope

  • LifecycleOwner 의 lifecycleScope 라는 extension property 로 접근이 가능하다.
  • Lifecycle 이 destroy 될 때 cancelled 된다.
  • Dispatchers.Main.immediate 를 사용한다

ViewModelScope

  • ClosableCoroutineScope로 생성한다.
  • ViewModel의 tag를 통해 객체 관리한다.
  • Closable 인터페이스를 구현하고 있기 때문에, ViewModel.onCleared() 호출시 cancelled 된다.
  • Dispatchers.Main.immediate 를 사용한다

아래 코드는 LifecycleCoroutineScope가 실제로 어떻게 쓰이는지 구현체의 모습이다.

LifecycleCoroutineScope 구현체는 LifecycleEventObserver라는 인터페이스를 구현하고 있다.

LifecycleEventObserver는 onStateChanged라는 함수로 라이프 사이클의 변화가 있을때마다 obseving하여 콜백받게 된다.

여기서 Lifecycle.State.DESTROYED가 되면 옵저빙을 해제하고 coroutionContext를 cancel하게 되는 구조로 구현되어 있다.

다음은 lifecycle에서 stop 상태에서 다시 foreground로 다시 왔와 다시 재기했을때의 Lifecycle.repeatOnLifecycle의 내부구현을 살펴보자.

어떤 라이프 사이클의 state가 될 것인지에 대한 state 와 block이라는 CoroutineScope의 확장함수를 파라미터로 받고 있다.

내부적으로 suspendCancellableCoroutine함수를 통해서 취소 가능한 중단점을 잡아준다.

lifecycleOberse를 사용해서 Observe를 등록한 이후에 Observe에서 lifecycle의 변화가 있을때마다 범위 안에 있으면 block()을 수행하고, 밖에 있다면 Job을 cancle한다.

취소는 Destroyed 상태가 되어야만, Continuation의 resume()을 통해 코루틴을 재개될 수 있게 하고, 그제서야 finally block이 수행되어 LifecycleEventObserver 가 해제된다.

그래서 보통 Lifecycle.repeatOnLifecycle 같은 경우에는 onCreate() 같은 init 단계에서 등록을 해야 무사히 프로세스 내에서 파괴되고 다시 살아날때 다시 등록되게 되어 원하는 동작을 구현할 수 있다.

 

이제 Retrofit에서의 사용법을 살펴보도록 하자.

How to use Coroutine in Retrofit

아래 코드는 Retrofit의 간단한 예제이다.

아래 코드 처럼 suspend fun 으로 선언을 하면 도대체 Retrofit은 우리가 interface를 정의할때 그냥 function으로 정의한 것과 어떻게 구분을 하는지 궁금할 것이다.

interface UserApi {
    @GET("user/{userId}")
    suspend fun getUser(@Path("userId") userId: Long): User
}

 

아래 코드는 Retrofit의 RequestFactory.java 내부 코드이다.

확인을 해보면 리플렉션을 통해서 인터페이스에 정의된 함수에 파라미터를 검사를 해서 파라미터 타입중에 Continuation이라는 타입이 있으면 suspendFunction이라고 판별한다.

RequestFactory.java

이후 인터페이스만 넘기고 구현체는 retrofit에서 알아서 담아주는 HttpServiceMethod.java 클래스가 존재하는데

이 코드를 보면 위에서 isKotlinSuspendFunction이 true일 경우 SuspendForBody라는 객체를 만들어서 SuspendFunction에 맞게끔 수행하게 한다.

HttpServiceMethod.java

그 내부 코드를 살펴보면 kotlinExtensions.kr 내에서 Call.await()이라는 함수를 만들어서 enqueue를 통해 비동기로 받은 값을 중단점을 잡아서 코루틴으로 감싸준 형태로 구현되어 있다.

아래 코드에서 보면 suspendCancellableCoroutine이라는 함수로 감싸주고 취소가 발생할 경우 call을 cancel해준다.

네트워크에서 객체를 받았을 경우 continuation.resumeWithException을 호출하여 해당 네트워크에서 받은 객체를 반환해준다. 

 

여기까지 라이브러리와 코루틴을 이해할 만한 내용을 다뤘다. 다음은 노하우에 대해 살펴보자.

Best Practices

async()를 사용하면서 await()를 즉시 호출하지 말자

여러가지 함수를 병렬적으로 수행을 하면서 비동기적인 작업을 동기화 하는 작업을 할때는 async()가 효율적이다.

그러나 async를 사용하면서 await()를 즉시 호출하는 것은 아무런 이점이 없다.

차라리 그냥 suspend function을 사용하자.

Don't

suspend fun getUser(): User  = coroutineScope {
    val user = async { api.getUser() }.await()
    user.toDomain()
}

Do

suspend fun getUser(): User {
    val user = api.getUser()
    return user.toDomain()
}

 

 

withContext()의 인자값을 통해 CoroutineContext 를 override 하는 것이 아니라면 coroutineScope{}를 사용하자

withContext()는 coroutineScope{ } 와 차이는 CoroutionContext를 override하는것이 딱하나의 차이점만 있다.

그러니  CoroutionContext를 재정의 할때만 withContext() 사용하고 이외에는 coroutineScope{ } 것이 좋다.

 

비동기가 아닌, 동기로 처리해야 하는 로직은 Dispatchers.Main 보다는 Dispatchers.Main.immediate 를 사용하자

Dispatcher.Main은 핸들러를 통해 큐에 쌓는 작업이기 때문에 즉시 수행될것이라는 보장이 없다. 그러나 Dispatcher.Main.immediate는 즉시 실행하는 것이고 최적화된 버젼으로 불필요한 coroutine redispatching을 피한다.

일반적으로 UI를 업데이트 할때에는 Dispacher.Main 보다는 Dispacher.Main.immediate가 더 좋다.

이유는 Main으로 설정하여 큐에 쌓았다가 다른 작업이 오래 걸려 UI 작업이 늦어져 AN이 발생할 수 있기 때문에 Dispacher.Main.immediate이 좋다.

 

동작이 무거운 non-suspend 함수의 경우 yield() 를 활용하자

아래의 코드의 예시는 하나의 덩어리인 함수가 중간에 취소가 되는 경우 yield()함수를 심어줌으로서 중단점을 만들어줬다.

이처럼 yield()는 suspension point를 설정해주는 용도로 활용가능하며, non-suspend function도 cancellation과 coroutine redispatching의 도움을 받을 수 있게 할 수 있다.

suspend fun getUser() = heavyFunction() = withContext(Dispatchers.Default) {
    nonSuspendHeavyFunction1()
    yield()
    nonSuspendHeavyFunction2()
    yield()
    nonSuspendHeavyFunction3()
}

 

코루틴 취소가 발생 했을 때에도, 해제 해줘야 하는 자원(resource)이 있는 경우 finally black을 활용해라

기본적으로 cancel의 원리가 Exception을 통한 cancel 관리로 이루어져 있다. cancel에 의해 close해줘야 하는 작업이나 해제해줘야 하는 작업이 있을 경우 try 블럭을 만들고 finally 문들 만들어 close()같은 작업을 해주게 된다면 아래 예시 코드처럼 반드시 에러가 발생하지 않아도 coroutine 취소가 발생했을 경우에도 finally 문이 실행시켜 자원의 낭비를 줄이자

suspend fun readFile() = coroutineScope { 
    val job = launch { 
        val file = openFile("example.txt")
        try {
            
        } finally {
            
        }
    }
    
    delay(1000)
    job.cancelAndJoin()
}

 

 


출처

https://www.youtube.com/watch?v=x0KY6qtuNHg&list=PLu8dnNjU2Fmv55B8y6Mw78pZFflIoxDo8&index=7

https://velog.io/@jkh9615/coroutine-%EB%8F%99%EC%9E%91-%EA%B3%BC%EC%A0%95-CPS