Android

안드로이드 [Kotlin] - Go Inside Coroutine - 코루틴 내부 구현 살펴보기

🤖 Play with Android 🤖 2023. 3. 25. 22:39
728x90


메인 스레드(Main Thread, UI Thread)란?

  • 앱이 실행됐을 때 시스템에서는 메인(Main)이라고 불리는 스레드를 생성한다.
  • 메인스레드는 안드로이드 이벤트 생성 및 처리를 담당할 뿐 아니라 안드로이드에서 발생되는 여러 이벤트를 그와 관련된 위젯으로 연동시키는 중요한 역할을 수행한다.
  • 또한 안드로이드에서 제공하는 다양한 뷰와 위젯을 표현하는 역할과 사용자로 하여금 그것을 사용할 수 있게 해 주어 UI 스레드라고도 불린다.

 

앱에 메인 스레드만 존재한다면?

  • 앱이 단일 스레드 즉 메인 스레드만 존재하는 모델에서는 형편없는 성능을 낳게 된다.
  • 메인 스레드에서 모든 작업을 처리한다면 네트워크 처리 및 데이터베이스 쿼리와 같이 오래 걸리는 작업을 하는 동안 UI와 관련된 작업을 처리하지 못하게 된다.
  • 이것이 문제가 되는 이유는 오랜 시간 UI 관련된 작업을 처리하지 못하면 ANR(Application Not Responding)에러가 발생하고 그 즉시 앱이 정지되기 때문이다.

 

동시성(Concurrency)

  • Main-Safe하게 앱의 여러 작업을 수행하는 방법의 하나로, 동시성을 사용하는 방법이 있다.
  • 하지만 동시성 코드를 제대로 작성하는 것은 좀처럼 간단한 일이 아닙니다. 경쟁 상태, 원자성 위반, 교착 상태, 라이브락과 같은 동시성 이슈들이 개발자들을 괴롭히기 때문이다.

Understand Kotlin Coroutines on Android (Google I/O'19)

 

우리는 이 중에서 Coroutine, 그리고 그 Coroutine는 내부적으로 어떻게 동시성(Concurrency)을 제공하는지 살펴보자.

 



코루틴이 JVM에서 동작하는 방법

There is no magic
  • 코루틴은 디컴파일되면 일반 코드일 뿐이다. 
  • Continuation Passing Style(CPS, 연속 전달 방식) 이라는 형태의 코드로 전환한다.

 

Basic Problem

fun postItem(item: Item) {
    val token = requestToken()
    val post = createPost(token, item)
    processPost(post)
}
  • 위 코드는 서버에서 토큰을 가져와서 게시물을 포스트 한 다음, 포스트 완료처리를 하는 세 가지 연산을 하는 코드이다.
  • 이것이 JVM에서 Continuation Passing Style(CPS)로 내부적으로 컴파일할 때 아래와 같이 바뀐다.

 

Continuation Passing Style(CPS)

fun postItem(item: Item) {
    requestToken { token ->
        val post = createPost(token, item)
        processPost(post)
    }
}
  • Continuation Passing Style은 결과를 호출자에게 직접 반환하는 대신 Callback 방식으로(Continuation)으로 결과를 전달한다.

 

Suspend 함수

suspend fun createPost(token: Token, item: Item): Post { … }
  • createPost(...) suspend 함수를 하나를 만들었을 때, 이 함수는 일시 중단 및 재개될 수 있는 함수가 된다.

중단 가능 지점 표현 아이콘

  • 어떻게 이것이 가능한지 살펴보자.
// suspend fun createPost(token: Token, item: Item): Post { … }
     ↓
// Java/JVM 
Object createPost(Token token, Item item, Continuation<Post> cont) { … }
  • 내부적으로는 JVM에 들어갈 때 바이트코드로 컴파일되면서 같은 createPost(…)인데 Continuation이 생성되어 Continuation Passing Style로 변환된다.
  • 호출했던 함수의 끝에 매개변수가 하나 추가되서 Continuation이라는 객체를 넘겨주는 것으로 변환되는 것이다.

 

suspen fun postItem(item: Item) {
    // LABEL 0
    val token = requestToken()
    
    // LABEL 1
    val post = createPost(token, item)
    
    // LABEL 2
    processPost(post)
}
  • 먼저 Labael이라는 작업을 하게 되는데 코루틴에서 순차적으로 작성했던 코드들이 suspend 함수가 되면 컴파일할 때 Label이 찍히게 된다.
  • 이 함수가 재개되어야 하는데, 재개될 때 필요한 Suspension Point(중단 및 재개 지점)가 요구된다.
  • 그래서 이 지점들을 Label로 찍어놓는 것이다. 이런 작업을 코틀린 컴파일러가 내부적으로 하게 된다.
suspend fun postItem(item: Item) {
    switch (label) {
        case 0:
            val token = requestToken()
        case 1:
            val post = createPost(token, item)
        case 2:
            processPost(post)
    }
}

대략적으로 위와 같은 형태가 되는데, 작성했던 함수가 내부적으론 'switch-case'문처럼 바뀌어 case문이 3개가 생성되고 세 번을 실행하는 것을 알 수 있다. 함수를 실행할 때 0번이든, 1번이든, 2번이든 함수를 재개할 수 있는 지점이 생긴 것이다. 그리고 이 함수를 호출한 지점은 중단점이 될 수도 있다.

 

Label들이 다 완성되고 나면 Continuation Passing Style로 변환을 하게 된다.

fun postItem(item: Item, cont: Continuation) {
    val sm = object : CoroutineImpl { … }
    switch (sm.label) {
        case 0:
            val token = requestToken(sm)
        case 1:
            val post = createPost(token, item, sm)
        case 2:
            processPost(post)
    }
}
  • Continuation이라는 객체가 있고, 매 번 함수를 호출할 때마다 continuation을 넘겨준다. 
  • Continuation은 Callback 인터페이스 같은 것으로, 재개를 해주는 인터페이스를 가진 객체인 것이다.

위의 코드에서 sm이라고 하는 것은 'state machine'을 의미하는데, 각각의 함수가 호출될 때 상태(지금까지 했던 연산의 결과)를 같이 넘겨줘야 한다. 이 'state machine'의 정체는 결국 Continuation이고, Continuation이 어떠한 정보값을 가진 형태로 Passing이 되면서 코루틴이 내부적으로 동작하게 되는 것이다.

 

 

정리

  • Coroutine은 Continuation을 주고 받는 CPS 패러다임을 사용한다.
  • 컴파일러는 suspend fun의 시그니처를 변경하는데, 이때 매개변수에 자동으로 Continuation을 추가한다.
  • 컴파일러는 suspend fun의 내부 코드들을 분석하여 중단 가능 지점을 찾아 구분한다.
  • 컴파일러는 다음 실행 지점을 나타내는 label과 내부 변수들을 관리하는 상태머신 클래스를 생성한다.

즉 컴파일러가 suspend 키워드를 만나면 CPS 패러다임을 구현하여, Continuation이라는 일종의 콜백을 주고 받도록 코드를 변환해준다는 것이다.