Kt.Academy Kotlin Coroutines Deep Dive Summary 1부 —코루틴 원리 및 빌더

GodDB
10 min readMar 22, 2022

--

본 게시글은 Kt.Academy의 Kotlin Coroutines DEEP DIVE의 요약본입니다.

목차

  1. 코루틴 원리 및 빌더 — 현재 게시글
  2. 코루틴 컨텍스트 및 취소
  3. 코루틴 예외 처리 및 스코프 함수
  4. 코루틴 디스패처 및 스코프 함수
  5. 코루틴 테스트
  6. Flow 빌더 및 기본 Operator
  7. Flow Operator

suspend는 코루틴에서 어떻게 작동되는가?

코루틴을 suspending 한다는 것은, 코루틴을 중간에 일시 정지하는 것을 의미합니다. 이것은 비디오 게임을 중간에 멈췄다가 다시 실행하는 것과 유사합니다.

코루틴을 일시 정지 하려면 Continuation 객체에게 지금까지 실행한 정보(로컬 변수, 현재 까지 실행된 라인 등)들을 담고, 함수에서 빠져나옵니다. 그리고 다른 함수를 수행하다가 resumeWith() 을 호출 받고 다시 이전 함수를 처리하는 식으로, 일시정지를 구현합니다.

Resume

코루틴 내부에서 일시 중지를 하고 싶다면, suspend 함수를 호출해야 일시 중지가 됩니다.

하나의 예를 통해 알아보도록 하겠습니다.

suspend fun main() {
println("before")

println("after")
}
//before
//after

일반적인 main()에서 println()을 호출하면 당연하게도 결과에 맞게 나옵니다.

하지만 이렇게 한다면 어떨까요?

suspend fun main() {
println(“Before”)
suspendCoroutine<Unit> { continuation ->}println(“After”)
}
// Before

suspendCoroutine()이라는 함수는 콜백 등을 동기화하기 위해 사용합니다.
그렇기에 내부적으로 Continuation 객체의 resumeWith() 호출 하지 않으면, 그 다음 줄이 실행되지 않습니다.

그렇기에 예제 에서는 suspendCoroutine() 이후의 로직은 실행되지 않습니다.

그래서 다음과 같이 suspendCoroutine()에서 continiation.resumeWith() 를호출 시킴으로써, 해당 코루틴은 일시정지에 놓이게 되고, 다음 로직인 println(“After”)를 실행하게 되고, 다시 코루틴으로 돌아와서 그 다음인 println(“dddd”)를 실행되게 됩니다.

suspend fun main() {
println("Before")

suspendCoroutine<Int> { continuation ->
Thread {
Thread.sleep(1000L)
continuation.resumeWith(Result.success(3333))
println("dddd")
}.start()
}

println("After")
}
// Before
1초 대기 ...
// After
// dddd

자 이게 어떻게 이렇게 될 수 있는지를 알기 위해서는 먼저 CPS에 대해서 알아야 합니다.

CPS(Continuation Passing Style)

코루틴이 일시정지 되고, 다시 돌아와서 그 이후의 로직 부터 실행할 수 있는 이유는 CPS라는 개념이 적용 되어 있기 때문입니다.

그래서 우리가 함수에 suspend 라는 키워드를 붙이면 다음과 같이 컴파일러는 함수를 변환합니다.

suspend라는 키워드를 붙임으로써, 컴파일러는 continuation 객체를 전달 받을 수 있게 함수 인자를 추가 시킵니다. 그리고 리턴을 Any로 변경합니다.

또한 내부에 suspend 함수가 있다면 그 suspend 함수를 기점으로 분기 시킵니다.

그리고 인자로 받은 continuation 객체에 상태값을 저장합니다.
label 은 어디까지 실행 되었는지에 대한 상태값이며, 지역변수가 있다면, 지역변수를 저장하기 위한 상태값도 추가 되어, 그에 맞게 상태값을 저장합니다.

그래서 다른 함수에서 continuation.resumeWith() 를 호출하면, 다시 MyFunction() 로 콜백되며, continuation 객체에 담겨있는 label 및 상태값을 확인하고 그 이후 로직을 실행합니다.

그리고 함수가 일시 정지될 경우엔 COROUTINE_SUSPENDED라는 것을 리턴 하는 것을 확인할 수 있는데, 이는 이 함수는 종료되지 않았고, 일시정지 중이다 라는 정보를 알리기 위함 입니다.

그래서 리턴을 Any로 변경하고, 이 함수가 진짜 종료될 때는 이 함수의 리턴값이 리턴 되고, 일시 정지일 때는 COROUTINE_SUSPENDED 라는 상수값을 리턴합니다.

코루틴 빌더

모든 일시 정지 함수는, 다른 일시 정지 함수로 부터 실행되어야 합니다. 그리고 그 일시 정지 함수의 루트는 코루틴 이여야 합니다.

코루틴을 생성하기 위한 코루틴 빌더 함수는 대표적으로 3개가 있습니다.

  • launch()
  • runBlocking()
  • async()

launch

launch()의 작동 방식은 thread와 유사합니다. launch()는 마치 공중으로 발사되는 미사일처럼 독립적으로 실행됩니다.

launch()는 값을 리턴 할 필요가 없는 경우에 사용합니다.

launch()CoroutineScope 인터페이스의 확장 함수 입니다. 이것은 부모 코루틴, 자식 코루틴 사이의 관계를 구축하는 것을 목적으로 하는 구조적 동시성 이라는 중요한 메커니즘의 일부입니다.

코루틴은 스레드를 block하지 않기 때문에 맨 아랫줄에 Thread.sleep()을 지정하여, 코루틴이 완료할 때 까지, 스레드를 block 키기 위함입니다.

runBlocking

일반적인 규칙은 코루틴이 스레드를 block 해서는 안되고 코루틴을 일시 정지만 시켜야 합니다. 하지만 스레드를 block 해야하는 경우가 존재합니다.

앞선 예제처럼 main()이 코루틴이 완료하기 전까지 끝나면 안되는 경우, 대표적으로 unit test가 있습니다.

이런 경우에 runBlocking()을 사용해서 코루틴이 완료될 때 까지 스레드를 block 시킬 수 있습니다.

async

async()launch()와 유사하지만, 값을 리턴 할 수 있도록 설계 되었습니다. 그리고 그 값은 async() 람다에서 리턴 해야 합니다.

async()Deferred 객체를 리턴 합니다. 그리고 Deferred 객체에는 async()의 결과값이 나올 때 까지 일시 정지 시킬 수 있는 await() 가 있어, await()를 통해 람다 결과값을 얻을 수 있습니다.

async()launch()와 유사해서 launch() 대신에 사용할 수 있지만, 코드의 명확성을 위해 값을 리턴 하는 경우에만 async()를 사용해야 합니다.

또한, async()는 서로 다른 두 위치에서 데이터를 가져오는 것과 같이, 병렬적으로 데이터를 가져와서 결합 하는데에 자주 사용합니다.

구조적 동시성

앞서 runBlocking()을 제외한 launch(), async()CoroutineScope의 확장함수입니다.

다음은 구조적 동시성을 구현한 예, 아닌 예입니다.

구조적 동시성을 만드는 법은 부모 코루틴의 CoroutineScope를 활용하여 새로운 코루틴을 만들어서 부모 — 자식 관계를 만드는 것입니다.

첫번째 예는 runBlocking()으로 코루틴을 만든 뒤, runBlocking()의 Scope를 활용하지 않고, 다시 별도의 Scope로 코루틴을 생성 했으므로, 부모 — 자식 관계가 성립되지 않습니다.

두번째 예는 runBlocking()으로 코루틴을 만든 뒤, runBlocking()의 Scope를 활용하여 자식 코루틴을 생성 했으므로, 부모 — 자식 관계가 성립됩니다.

그렇다면 구조적 동시성의 이점은 무엇 일까요?

  • 자식은 부모로부터 CoroutineContext를 상속 받습니다 (덮어 쓰기도 가능합니다)
  • 부모는 모든 자식이 완료될 때 까지 살아있습니다.
  • 부모가 취소되면 자식 코루틴도 취소됩니다.
  • 자식이 Exception을 발생 시키면 부모 코루틴도 함께 파괴됩니다. (물론 아니게도 처리할 수 있습니다)

하지만 하나 궁금한 점이 생기실꺼 같은데요. 앞서 async() 예제에서 부모 — 자식 코루틴이 아님에도 끝까지 부모 코루틴이 살아있던 것을 볼 수 있었습니다.

이 예제는 부모 — 자식 코루틴이 아닌, 그냥 중첩된 코루틴 예제입니다.

하지만 이 예제는 async가 다 완료될 때까지 main()이 종료되지 않았었는데요

이게 가능한 이유는 await()에 있습니다. await()suspend 함수로, 이 suspend 함수가 runBlocking()에서 실행됬으므로, async()가 종료될 때 까지 대기하게 되었던 것입니다.

실제로 여기서 await()를 제거하고 실행 했을 때는, async() 의 실행이 끝나기 전에 main()이 종료되게 됩니다.

suspend 함수안에서 scope 가져오기

Coroutine을 사용하다보면 suspend 함수 안에서 자식 코루틴을 만들고 싶은 경우가 있습니다.

suspend fun main() {

// 자식 코루틴을 어떻게 만들지??

launch { /** ... */ } //error
}

이럴 경우 부모 코루틴의 Scope가 필요한데, 함수 인자로 Scope를 전달하는 것은 매우 안좋은 방법입니다.

//권장 하지 않는 방법
suspend fun main(parentScope : CoroutineScope) {
parentScope.launch { /** .. */ }
}

이럴땐 coroutineScope()를 활용해서 해당 suspend 함수를 호출한 scope를 가져올 수 있습니다.

suspend fun main() {
coroutineScope {
launch { /** ... */ } //good
}
}

또한 비슷한 역할로 withContext()가 존재합니다

suspend fun main() {
withContext(Dispatchers.Main) {
launch { /** ... */ } //good
}
}

coroutineScope()withContext()는 실행 흐름이 동일 합니다.

coroutineScope()withContext() 둘다, 부모 코루틴의 Scope를 통해 생성한 자식 코루틴이 종료되기 전까지 밑의 코드로 내려가지 않습니다.

하지만 둘의 가장 큰 차이점은 withContext()는 ContextSwitching이 가능하다는 점과, coroutineScope()는 불가능 하다는 점입니다.

그래서 이 두 사용간의 차이점은 Context Switching을 할꺼라면 withContext()를, 자식 코루틴을 생성하기 위함이라면 coroutineScope()를 사용하면 됩니다.

--

--