본문 바로가기
✔️ Kotlin

Coroutine의 개념 이해한대로 정리하기

by iirin 2024. 5. 18.

들어가기 전에

코루틴은 코틀린을 접하면서 가장 헷갈렸던 개념 중 하나입니다.
그래서 틈틈이 인프런 강의도 보고 이것저것 글도 찾아보며 학습하고 있는데요. 조금씩 학습했던 것들을 정리하며, 더 깊이 남기기 위해 이 글을 작성해보았습니다.
혹시나 부족한 부분이 있다면 댓글로 알려주세요 😀

  • 아래 개념을 전제하여 작성하였습니다만 이 글에서 다루고 있지는 않습니다.
    • 프로세스, 스레드
    • 컨텍스트 스위치 context switch
    • 동시성, 병렬성
    • 메모리 스택영역, 힙영역
    • 동기 프로그래밍, 비동기 프로그래밍

 

Coroutine

  • co는 '협력하는'이라는 의미가 있는 접두사입니다. routine은 컴퓨터 공학에서 이야기하는 루틴입니다. 즉, 협력하는 함수라는 의미입니다.
  • Coroutine은 Kotlin 언어를 개발한 Jetbrain 이 개발자들이 겪는 스레딩 문제를 직관적인 방식으로 해결할 수있도록 추가한 기능입니다.

 

Routine 과 Coroutine

  • 일반적인 Routine은 다음과 같은 시퀀스 다이어그램을 가집니다.
    +--------------+          +-------------+
    | Main Routine |          | sub Routine |
    +--------------+          +-------------+
         |                           |
         |        routine 시작        |
         |-------------------------->|
         |                           |
         |                           |
         |                           |
         |                           |
         |                           |
         |                           | 
         |<--------------------------|
         |        routine 종료       초기화
         |

루틴은 이런 특징이 있습니다.

  • 루틴은 진입하는 곳이 한 곳입니다.
  • 한 번 시작하면 종료될 때까지 멈추지 않습니다.
  • 루틴이 종료되면 그 루틴에서 사용했던 정보가 메모리에서 초기화 되어 사용할 수 없습니다.

 

반면 아래는 coroutine 에 대한 시퀀스 다이어그램입니다.

    +----------------+          +---------------+
    | Main coroutine |          | sub coroutine |
    +----------------+          +---------------+
         |                              |
         |         routine 시작          |
         |----------------------------->|
         |             중단              |
         |<-----------------------------|
         |                              |
         |                              |
         |                              |
         |             재개              |
         |----------------------------->|
         |<-----------------------------|
         |         routine 종료
         |
  • 코루틴은 중단하고 재개할 수 있습니다.
    • 중단-재개의 단위는 코드상으로 정의된 블록입니다.
  • 코루틴이 완전히 종료되기 전까지 중단된 코루틴 함수 안에 있는 정보는 메모리에서 제거되지 않습니다.

여기서 코루틴이 완전히 종료되기 전까지 메모리에서 해당 코루틴에 대한 정보가 계속 남아있다는 점이 매우 중요합니다. 왜 중요한지는 스레드와 코루틴의 관계와 차이점을 보면 명확히 알 수 있습니다.

 

Thread 와 Coroutine

위에서 언급하였듯이 코루틴은 스레드보다 더 작은 작업단위이기도 합니다.

(이미지 출처 : https://medium.com/nerd-for-tech/a-journey-from-callback-hell-to-kotlin-coroutines-episode-1-98b52821b323)

 

코루틴은 코드 종류 중 하나이기 때문에 이를 실행하려면 스레드가 있어야 합니다.

루틴을 실행하려면 스레드가 배정되어 있어야 하는 것처럼요.

 

그래서 얼핏 보면 프로세스-스레드와 스레드-코루틴은 비슷한 관계 것처럼 보입니다.
하지만 스레드와 달리 코루틴은 중단과 재개를 할 수 있고 하나의 코루틴이 스레드 여러개에 거쳐 실행될 수 있다는 차이점이 있습니다.

 

시퀀스 다이어그램에서 코루틴은 2개가 실행되고 있지만, 3개의 스레드에서 각각 나뉘어 실행될 수 있는 것입니다.

(물론, 더 적은 스레드에 나뉠 수도 있습니다.)

 

      A thread
    +----------------+          +---------------+
    | Main coRoutine |          | sub coRoutine |
    +----------------+          +---------------+
         |                              |
         |    routine 시작(B thread)     |
         |----------------------------->|
         |             중단              |
         |<-----------------------------|
         |                              |
         |                              |
         |                              |
         |         재개(C thread)        |
         |----------------------------->|
         |<-----------------------------|
         |         routine 종료
         |
  • 다시 시퀀스 다이어그램을 그려보자면 이런식입니다.
         A thread                    B thread                    C thread
      +----------------+          +-----------------+          +-----------------+
      | Main coroutine |          | Sub coroutine-1 | 동일 코루틴 | Sub coroutine-2 |
      +----------------+          +-----------------+          +-----------------+
           |                              |                            
           |     routine 시작              |                            
           |----------------------------->|                            
           |                              |                            
           |             중단              |                            
           |<-----------------------------|                            
           |                                                           
           |                                                           
           |                                                           
           |                                       재개                 
           |---------------------------------------------------------->|
           |                                                           |
           |<----------------------------------------------------------|
           |         routine 종료                                       
           |                                                           
    
     

스레드의 컨텍스트 스위칭과 코루틴의 컨텍스트 스위칭의 비용에도 차이점이 있습니다.

 

  • 스레드
    • 프로세스 내에서 힙 영역을 공유하고 있기 때문에 스레드간 컨텍스트 스위칭할 때는 스택 영역이 교체됩니다.
    • OS에 의해서 컨텍스트 스위칭이 일어납니다.
  • 코루틴
    • 코루틴이 다른 스레드에서 실행될 경우, 스택 영역이 교체됩니다.
    • 코루틴이 같은 스레드에서 실행될 경우, 메모리를 교체할 필요가 없으므로 스레드보다 컨텍스트 스위칭 비용이 적습니다.
    • 코드를 통해 개발자가 코루틴이 다른 코루틴에게 실행을 양보하게 할 수 있습니다. (`yield()` 함수 등 subroutine 간의 상호작용)

비동기 프로그래밍을 더 쉽고, 더 가볍게

우리는 효율적인 애플리케이션 성능을 위해 스레드간 컨텍스트 스위칭을 이용하여 비동기 프로그래밍을 구현할 수 있습니다.
하지만 자칫 무분별한 스레드 생성과 컨텍스트 스위칭으로 많은 리소스를 소비하게 될 수 있다는 점. 그리고 코드가 복잡해진다는 단점이 있습니다.

콜백 지옥은 불시에 찾아올 수 있습니다 👀 (이미지 출처 : https://medium.com/nerd-for-tech/a-journey-from-callback-hell-to-kotlin-coroutines-episode-1-98b52821b323)

 

예를 들어 아래는 GPT와 함께 짜본 스레드 카페 예시입니다. 동기로 짜는 것보다 `Callback`, `Future` 등 주문을 기다리거나 커피 만드는 것을 기다리는 간단한 비동기 함수인데 코드의 복잡도가 쉽게 올라감을 알 수 있습니다.

class Cafe {
    fun takeOrder(order: Order) {
        synchronized(orderQueue) {
            orderQueue.add(order)
        }
    }

    fun makeCoffeeAsync(): CompletableFuture<Order> {
        return CompletableFuture.supplyAsync {
            var order: Order? = null
            synchronized(orderQueue) {
                if (orderQueue.isNotEmpty()) {
                    order = orderQueue.removeAt(0)
                }
            }
            if (order != null) {
                Thread.sleep(2000L) // 커피 만드는 시간
                order
            } else {
                null
            }
        }
    }

    fun serveCoffeeAsync(order: Order) {
        CompletableFuture.runAsync {
            Thread.sleep(500L) // 커피 서빙 시간
            synchronized(completedOrders) {
                completedOrders.add(order)
            }
        }
    }
}

fun main() {
    val cafe = Cafe()
    val executor = Executors.newFixedThreadPool(3)

    val orderTask = CompletableFuture.runAsync {
        cafe.takeOrder(Order("Alice", "Latte"))
        Thread.sleep(1000L)
        cafe.takeOrder(Order("Bob", "Espresso"))
        Thread.sleep(1000L)
        cafe.takeOrder(Order("Charlie", "Cappuccino"))
    }

    val coffeeMakerTask = CompletableFuture.supplyAsync {
        while (true) {
            cafe.makeCoffeeAsync().thenAcceptAsync { order ->
                if (order != null) {
                    cafe.serveCoffeeAsync(order)
                }
            }.join()
        }
    }

    orderTask.thenRunAsync(coffeeMakerTask, executor)

    try {
        Thread.sleep(10000L)
    } catch (e: InterruptedException) {
        e.printStackTrace()
    }

    executor.shutdown()
}

 

이런 점을 해결하기 위해서 각 언어마다 고안한 방법들이 등장하였는데요.

Kotlin 에서는 Coroutine 이 해결책으로 등장한 것입니다.

 

코루틴은 Callback이나 블록에서의 예외처리 등을 코드 복잡도를 덜 높이면서 할 수 있도록 돕습니다.

그리고 OS에게 작업 스케줄링을 넘기지 않고, 코드를 통해 개발자가 직접 작업을 스케줄링할 수 있도록 합니다.

 


Refs.