안드로이드 코루틴 개념, CPS, ScopeBuilder 정리

SSY·2022년 12월 15일
0

Coroutine

목록 보기
1/8
post-thumbnail

1. 코루틴 기본 개념

Coroutine?
ㄴ> Co + Routine의 합성어

함께하는(=Co) 작업(=Routine)이라는 뜻으로 여러가지의 일을 동시에 처리할수 있게 해주는 라이브러리다. 즉, 비동기 작업을 효율적으로 해줄 수 있는 라이브러리를 의미하며 이는 기존의 AsyncTask나 Handler를 사용하지 않고도 더 적은코드로 직관적인 프로그래밍이 가능해진다.

물론.. 지금은 AsycTask가 Deprecated되어서 못쓰긴 하지만~ 아무튼, Coroutine이란 무엇인지 알아가기 전 아래 그림을 보면 이해가 좀 쉬울거라 생각한다.

위 그림을 보면 Entry라는 일의 시작지점이 있다. 그리고 Return이라는 끝지점도 있다. 이때 이 일을 모두 끝마치기 위해선 A, B, C라는 일(=Job)이 필요하다. 그리고 이러한 일들을 원하는 타이밍에 실행시켜줘야 하는데 이를 도와주는 동시성 라이브러리가 바로 Coroutine이다.

2. Coroutine Builder

대표적으로 많이쓰는걸 뽑아보자면 다음의 정도가 있다.

  1. launch
  2. async
  3. withContext
  4. runblocking

1. launch

중단함수를 통한 코루틴을 시작할 수 있는 첫 빌더이다. 이로써 호출하는 쪽의 스레드를 중단 없이 시작시킬 수 있으며, 결과값으로 JOB객체를 반환한다. 쓰임새는 아래와 같다.

launcer쓰임새
일반 함수 내부 → launch 빌더 내부 → suspend 함수 호출

private val myCoroutineScope = CoroutineScope(Dispatchers.Main)

fun startTask(view: View) {
	// 일반 함수 내부
    
	myCoroutineScope.launch(Dispatchers.Main) {
    	// launch 빌더 내부
        
    	performSlowTask() // suspend 함수 호출
    }
}

private suspend fun performSlowTask() {
	Log.i("TAG", "performSlowTask before)
    delay(7_777)
    Log.i("TAG", "performSlowTask after")
}

2. async

launch와 마찬가지로 호출부의 스레드를 중단시키지 않고 실행시킨다는 공통점이 있다. 하지만 연산 결과를 Deffered<T> 타입으로 호출부에 반환한다는 점이 차이이다. (await()의 호출로 결과값 수신 가능)

또한 async{}빌더는 오로지 suspend함수 내부에서만 사용이 가능하다. 즉, async{}사용을 위해선 launch{} 또는 async{}등의 빌더로 또다시 감싸줘야만 한다는걸 의미한다.

async쓰임새
일반 함수 내부 → launch빌더 내부 → suspend함수 호출 → async빌더 호출

private val myCoroutineScope = CoroutineScope(Dispatchers.Main)

fun startTask(view: View) {
	// 일반 함수 내부
    
	myCoroutineScope.launch(Dispatchers.Main) {
    	// launch 빌더 내부
    	binding.myTextView.text = performSlowTaskAsync().await() // suspend 함수 호출
    }
}

private suspend fun performSlowTaskAsync(): Defferred<String> {

	// async 빌더 호출
	myCoroutineScope.async(Dispatchers.Default) {
    	Log.i("TAG", "performSlowTask before")
        delay(7_777)
        Log.i("TAG", "performSlowTask before")
        return@async "Finished"
    }
}

3. withContext

async빌더와 상당히 유사하다. 단, withContextDeferred<T>형태의 객체를 반환하지 않고, T형태의 객체를 그대로 반환한다. 그리고 withContextasync처럼 suspend함수 안에서 선언되어야만 하는데, 이때 부모 빌더와는 다른 coutine context를 사용하여 실행시킨다는 점이 또 다른 차이이다.

private val myCoroutineScope = CoroutineScope(Dispatchers.Main)

fun startTask(view: View) {
	myCoroutineScope.launch(Dispatchers.Main) {
    	binding.myTextView.text = performSlowTaskAsync().await()
    }
}
  
  private suspend fun performSlowTaskAsync(): String {
	withContext(Dispatchers.Default) {
    	Log.i("TAG", "performSlowTask before")
        delay(7_777)
        Log.i("TAG", "performSlowTask before")
        return@withContext "Finished"
    }
}

4.runblocking

실무에선 거의 사용되지 않는 스코프이다. 이는 함수의 이름에 맞게, runblocking을 호출한쪽의 스레드를 점유 즉 블락킹한다. 만약 UI 스레드에서 사용할 경우, ANR이 발생한다.

3. Coroutine Scope Provider

안드로이드에서 대표적으로 쓰이는 LauncherBuilder는 크게 3가지가 있다.

안드로이드 대표 LauncherBuilder
1. coroutineScope
2. GlobalScope
3. ViewModelScope

1. coroutineScope

부모의 CoroutineContext를 그대로 물려받는 빌더로, withContext(EmptyCoroutineContext)와 동일하다. 즉, 부모의 콘텍스트를 그대로 물려받아 동시성을 구조적으로 처리하고자할 때 유용하다. 아래 코드의 경우, supervisorJob이 아닌 일반 Job을 사용하고 있고 이를 자식에게 물려주고 있다. 따라서 1개의 작업의 실패로 모든 코루틴이 종료되는 코드이다.

참고 : 코루틴 스코프 함수 'coroutineScope'를 왜 사용할까?

fun main {
	test()  
}
  
fun test() {
  runBlocking {
  	try {
  		failedConcurrentSum()
  	} catch (e: ArithmeticException) {
  		Log.i("코루틴테스트", "ArithmeticException!!")
  	}
  }
}
  
suspend fun failedConcurrentSum(): Int = coroutineScope {
	val one = async<Int> {
  		try {
  			delay(Long.MAX_VALUE)
  			42
  		} finally {
  			Log.i("코루틴테스트", "First child was cancelled")
  		}
  	}
  	val two = async<Int> {
  		Log.i("코루틴테스트", "예외 던져버리기 직전 ㅇㅇ")
  		throw ArithmeticException()
  	}
  	one.await() + two.await()
}

로그값을 보면 알 수 있다시피 두 번째 코루틴에서 에러를 던졌다. 그러더니 첫 번째 코루틴도 끝나버린걸 알 수 있다!

2. viewModelScope

viewModelScope의 경우는 ViewModel의 라이프사이클에 맞춰 Scope을 적절히 cancel시켜줄 수 있는 스코프이다. 예를들어, 사용자가 api를 호출하자마자 액티비티를 onDestroy했다고 가정해보자. 그럼 이때 ViewModel도 onCrear되는데, 이때 ViewModelScope도 이에 맞추어 비동기코드를 종료시키게 된다.

[ViewModelScope의 TIP]
ViewModelScope가 보유한 Dispatcher는 Main.immediate로써, 해당 스코프를 통해 실행된 코루틴들은 코루틴 스케줄러에 들어가는 게 아닌, 즉시 실행을 보장한다.

3. GlobalScope

이름에서도 알 수 있다시피, 프로그램 전체에서 싱글톤으로 진행시킬 수 있는 스코프이며, 사용법은 위와 동일하다.

3. CPS

CPS의 내부 동작원리에 대해 세세히 알고싶다면 아래 포스팅을 참고한다.

CPS는 Continuation Passing Style의 약자로, 연속 전달 스타일이란 뜻이다. 다음과 같은 형태 변화를 통해 이해가 가능하다.

우선 코드 예시로 아래와 같이 api를 호출하는 일반 함수가 있다고 가정하자. 그리고 그 함수 내부엔 suspend함수가 2개 있다.

fun postItem(item: Item) {
    val token = requestToken() // suspend fun
    val post = createPost(token, item) // suspend fun
    processPost(post)
}

이러한 형태의 코드가 CPS과정을 거쳐 어떤 형태로 어떻게 변환되는지 코드로 탐구해볼까 한다. 우선 각각의 suspend펑션에 중단지점을 만들기 위해 각각의 함수 마지막 파라미터 부분에 람다 함수가 뚫린다.

fun postItem(item: Item) {
    equestToken() { token ->
      createPost(token, item) { post ->
          processPost(post)
      }
    }
}

그리고 위와 같은 형태로 컴파일 되기 위해선, 아래와 같은 방식으로 컴파일이 진행됩니다.

fun postItem(item: Item, cont: Continuation) {
    
    // 2. CPA스타일로 변환을 시작한다.
    val sm = cont as? ThisSM ?: object : ThisSM {
    
        // 5. 해당 메소드를 호출함으로써 재귀호출이 진행된다
        fun resume() {
            postItem(null, this)
        }
    } 

    // 1. 라벨링 작업을 한다(비동기 코드의 '중단지점'과 '재개지점'을 알기 위하여)
    switch (sm.label) { 
        case 0:
            sm.item = item
            sm.label = 1 // 3. 라벨을 1씩 증가시켜 case1 호출 준비를 한다
            requestToken(sm) // 4. 재귀호출을 시작한다
        case 1:
            sm.item = item
            createPost(token, item, sm)// 6. 0번부터 끝번호까지 호출을 반복한다.
    }
}

즉, CPS를 이해함으로써 서두에 밝혀두었던 사진이 더 잘 이해가 갈거라 생각한다. 이는 Coroutine의 비동기 작업 처리가 어떻게 직렬적으로 잘 이루어질 수 있는지 알 수 있다.

4. 잡

잡은 launch{}async{}등의 빌더를 호출함으로써 return받은 인스턴스를 의미한다. 이때 luanch{}Job을 리턴받으며 async는 Job을 상속한 Deferred<T>를 리턴받는다. (따라서 Job이든, Deffered<T>든, 모두 Job으로 상위 캐스팅이 가능하다.)

이러한 Job 객체는 코루틴의 상태(state machine) 를 내부적으로 관리하고 있다. 코루틴은 단순히 실행되느냐 마느냐의 문제가 아니라, 6단계의 상태에 기반한 통해 정교한 제어가 가능하다.

isActive, isCompleted, isCancelled의 true/false값은 코루틴 수명주기에 따라 이해하며 따라가면 금방 학습이 가능하다.

상태isActiveisCompletedisCancelled설명
Newfalsefalsefalse코루틴이 아직 시작되지 않음 (start() 를 호출하지 않은 상태)
Activetruefalsefalse코루틴이 실행 중이거나 일시 중단(suspended)된 상태
Completingtruefalsefalse코루틴의 실행이 완료되고 있고, 결과를 처리 중인 상태
Cancellingfalsefalsetruecancel() 호출로 인해 취소되고 있는 중간 상태
Cancelledfalsetruetrue취소가 완료된 상태 (에러나 명시적 cancel 등)
Completedfalsetruefalse정상적으로 완료된 상태

그리고 이러한 상태에 대한 플로우차트는 아래와 같다. 생명주기라 생각하고 단간하게 외워두자.

그리고 이러한 부분을 코드에서 다음과 같이 확인할 수 있다.

val job = CoroutineScope(Dispatchers.Default).launch {
    delay(1000)
    println("작업 완료")
}

// 코루틴이 실행 후, 1초간 딜레이를 걸었다. 현재 코루틴은 실행상태이다.

println("isActive = ${job.isActive}")      // 코루틴이 실행중이므로, true
println("isCompleted = ${job.isCompleted}") // 코루틴이 끝나지 않았으므로, false
println("isCancelled = ${job.isCancelled}") // 코루틴이 취소되지 않았으므로, false

// 딜레이가 걸린 코루틴을 취소
job.cancel()

println("After cancel()")
println("isActive = ${job.isActive}")      // 코루틴이 종료됐으므로, false
println("isCompleted = ${job.isCompleted}") // 코루틴의 종료 및 완료됐으므로, true
println("isCancelled = ${job.isCancelled}") // 코루틴이 취소됐으므로, true
profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

0개의 댓글