티스토리 뷰

Backend

코루틴(Coroutine)에 대하여

지마켓 안세희 2023. 6. 14. 11:36

안녕하세요. Checkout Engineering 팀 안세희입니다.

코루틴에 대해 많이 들어 봤지만 원론적인 의미에서 코루틴을 이해하기는 어렵다고 생각합니다.

언어 수준에서 다양한 라이브러리가 코루틴을 사용할 수 있게 제공하기에 항상 깊은 고려가 필요하진 않지만, 코루틴에 대한 개념을 정확히 알고 있어야 활용도 가능하기에 해당 주제로 글을 작성하고자 합니다.

 

코루틴(Coroutine) 이란?

코틀린을 통해 코루틴을 처음 접하시는 분들이 많기에 코루틴이 코틀린 언어에 종속적인 기술이라고 생각하는 분들이 많습니다. 그러나 코루틴은 1958년 코루틴 용어가 만들어지고 난 후 어셈블리 프로그램에서부터 적용되었던 개념입니다.

C#, Javascript, Python, Go와 같은 고급 프로그래밍 언어에서도 지원하고 있으며 Javascript의 async/await 기술을 사용하고 있다면 더욱 친숙하게 코틀린을 느끼실 수도 있습니다.

코루틴은 Co(함께, 서로) + routine(규칙적 일의 순서, 작업의 집합) 2개가 합쳐진 단어로 함께 동작하며 규칙이 있는 일의 순서를 뜻합니다.

위키피디아에서는 코루틴을 아래와 같이 정의하고 있습니다.

실행의 지연과 재개를 허용함으로써,
비선점적 멀티태스킹을 위한 서브 루틴을 일반화한 컴퓨터 프로그램 구성요소

정의 내 비선점적 멀티태스킹과 서브 루틴은 하기와 같은 의미를 가지고 있습니다.

 

비선점적 멀티태스킹이란?

  • 비선점형 : 하나의 프로세스가 CPU를 할당받으면 종료되기 전까지 다른 프로세스가 CPU를 강제로 차지할 수 없습니다. (코루틴)
  • 선점형 : 하나의 프로세스가 다른 프로세스 대신에 프로세서(CPU)를 강제로 차지할 수 있습니다. (쓰레드)

따라서 코루틴은 병행성은 제공하지만, 병렬성은 제공하지 않습니다.

- 병행성(=동시성, Concurrency) : 논리적으로 병렬로 작업이 실행되는 것처럼 보이는 것
- 병렬성(Parallelism) : 물리적으로 병렬로 작업이 실행되는 것

선점형으로 비동기 처리 시에는 하기와 같은 몇 가지 문제점이 있었습니다.

 

  1. 코드의 복잡성 : 독립적인 쓰레드 안에서 각 루틴이 동작하기에 동시성을 제어하는 복잡한 코드가 필요하며 이는 코드 흐름 파악을 어렵게 합니다.
  2. 비용 : 무분별하게 쓰레드를 사용하면 Context switching 리소스 비용으로 프로그램 성능이 저하됩니다.

이러한 문제를 해결하기 위해 다양한 방법이 나오는데, 코루틴은 이런 단점을 해결해 주는 언어적 지원 방법이며 비선점적 멀티태스킹을 사용하여 해결하고 있습니다.

코루틴은 쓰레드가 아닌 쓰레드 내 동작하는 하나의 작업 단위이며 정의된 다양한 구성요소의 집합인 Context를 오버라이드 하며 실행됩니다. 따라서 쓰레드 내 Context switching 없이 여러 코루틴을 실행, 중단, 재개하는 상호작용을 통해 병행성을 갖기에 쓰레드와 메모리 사용이 줄어들고 개발자가 직접 작업을 스케줄링 할 수 있도록 합니다.

 

서브 루틴이란?

프로그램은 여러 루틴의 조합으로 진행되는데, 메인 루틴과 서브 루틴으로 나뉩니다.

 

  • 메인 루틴 : 프로그램 전체의 개괄적인 동작으로 main 함수에 의해 수행되는 흐름
  • 서브 루틴 : 반복적인 기능을 모은 동작으로 main 함수 내에서 실행되는 개별 함수의 흐름

서브 루틴은 메모리에 기능을 모아 놓고, 호출 시 저장된 메모리로 이동한 뒤 실행 후 반환문을 통해 원래 호출 위치로 돌아옵니다.

코루틴은 서브 루틴과 비슷하지만 큰 차이점이 있습니다.

서브 루틴은 단일 진입 지점에서 시작 후 단일 반환 지점에서 종료되는 것에 반해, 코루틴은 진입 후 반환문이 없더라도 임의 지점에서 실행 중 동작을 중단하고 이후 해당 지점에서부터 실행을 재개합니다. (진입과 반환이 여러 개입니다.)

이는 내부적으로 Continuation Passing Style(CPS, 연속 전달 방식)과 State machine을 이용하여 동작합니다.
코루틴에서 함수 호출 시 연산 결과 및 다음 수행 작업과 같은 제어 정보를 가진 일종의 콜백 함수인 Continuation을 전달하며 각 함수의 작업이 완료되면 Continuation을 호출합니다. 이를 통해 상태를 연속적으로 전달하며 컨텍스트를 유지하고 코루틴 실행 관리를 위한 State machine에 따라 코드 블록을 구분해 실행합니다.

 

코루틴(Coroutine)은 왜 쓰이는가?

코틀린 언어를 개발한 Jetbrain에서는 멀티 쓰레딩 문제를 간소화된 비동기 작업 방식으로 해결할 수 있도록 코루틴을 개발했습니다.

Google I/O에서는 코루틴을 비동기 처리에 쓰면 코드를 간단하게 작성할 수 있다고 합니다.

Coroutines simplify async code by replacing callbacks.

코루틴은 메인 쓰레드가 Blocking 되는 부분에 도움을 주며, 비동기 처리 코드를 순차적인 코드로 만들 수 있게 합니다.

예로 메인 쓰레드에서 화면 UI를 그리는데 네트워크 작업까지 처리하면 블로킹되어 응답성 높은 UI 작업이 어려워지집니다. 이러한 경우에 네트워크 작업을 비동기로 처리하기 위해 주로 사용됩니다.

물론 ReactiveX Java(RxJava)를 사용해서도 비동기 문제를 해결할 수 있으며 다음 그림과 같이 Coroutine에 비해 제공하고 있는 함수도 많습니다.

그럼에도 RxJava는 러닝 커브가 상당하기에 일반적으로 아래의 Fabio Collini가 제시한 다음의 알고리즘을 통해 어떤 방식을 사용할지 채택한다고 합니다.

 

  1. 이미 RxJava를 사용하고 있고 잘 동작한다면 RxJava를 사용한다.
  2. 아키텍처가 Reactive Stream을 기반으로 하는 경우 RxJava를 사용한다.
  3. 프로젝트가 Kotlin Native를 사용한 멀티플랫폼인 경우 코루틴을 사용한다.
  4. 코드 베이스가 Java / Kotlin인 경우 RxJava를 사용한다.
  5. 이 외에는 코루틴을 사용한다.

 

코루틴의 장점은?

1. 루틴 간 협력을 통한 비선점적 멀티태스킹

한 사람이 출퇴근 시간에 음악을 듣는다고 가정한다면 아래와 같은 선점형 코드와 비선점형 코드로 멀티태스킹이 가능합니다.

 

쓰레드/콜백 비동기 처리 예제 (선점형)

fun main() {
 // 수많은 쓰레드가 생성되는 것을 확인할 수 있으며,
 // 출퇴근 시간에 음악을 들으면서 다른 비동기 처리를 해야한다면 콜백 지옥이 발생하는 구조입니다.
 asyncGoToWork() { // thread-1
   stopMusic()
   startWork()
   asyncLeaveWork { // thread-2
     stopMusic()
   }
   asyncPlayMusic() // thread-3
 }
 asyncPlayMusic() // thread-4
 Thread.sleep(5000)
}

fun asyncGoToWork(time: () -> Unit) {
 Thread {
   println("Go to work")
   Thread.sleep(1000)
   time.invoke()
 }.start()
}

fun asyncLeaveWork(time: () -> Unit) {
 Thread {
   println("Leave work")
   Thread.sleep(1000)
   time.invoke()
 }.start()
}

var playMusic = false

fun asyncPlayMusic() {
 Thread {
   playMusic = true
   println("Play music")
   while (playMusic) {
     println("Listening")
     Thread.sleep(500)
   }
 }.start()
}

fun stopMusic() {
 playMusic = false
}

fun startWork() {
 println("Start work")
 println("Working")
 Thread.sleep(2000)
}

 

코루틴 예제 (비선점형)

fun main() {
  // 메인 루틴
  // 새 코루틴을 실행하고 완료 전까지 현재 쓰레드를 blocking 하는 코루틴 빌더 함수로 전체 작업 완료 전까지 다른 작업 할당이 불가합니다.
  runBlocking {
    // 서브 루틴
    // 새 코루틴을 실행하고 완료 전까지 현재 쓰레드를 blocking 하지 않는 코루틴 빌더 함수로 다른 작업 할당이 가능합니다.
    val goToWork = launch {
      goToWorkCoroutine()
    }
    val playMusicWhileGoingToWork = launch {
      playMusicCoroutine()
    }
    goToWork.join() // goToWork 코루틴 실행이 끝날 때까지 대기합니다.
    playMusicWhileGoingToWork.cancel() // playMusicWhileGoingToWork 코루틴을 종료합니다.

    startWorkCoroutine()

    val leaveWork = launch {
      leaveWorkCoroutine()
    }
    val playMusicWhileLeaveWork = launch {
      playMusicCoroutine()
    }
    leaveWork.join()
    playMusicWhileLeaveWork.cancel()
  }
}

// 실행 중 쓰레드는 블로킹하지 않으면서 실행 중 코루틴은 일시 중단할 수 있는 함수입니다.
suspend fun goToWorkCoroutine() {
  println("Go to work")
  delay(1000) // 현재 루틴을 잠시 대기시키는 함수로 선언 위치에 따라 메인 쓰레드를 블로킹할 수도 있습니다.
}

suspend fun playMusicCoroutine() {
  println("Play music")
  while (true) {
    println("Listening")
    delay(500)
  }
}

suspend fun leaveWorkCoroutine() {
  println("Leave work")
  delay(1000)
}

suspend fun startWorkCoroutine() {
  println("Start work")
  println("Working")
  delay(2000)
}

 

실행 결과

비동기로 처리하기에 순서의 차이는 있지만 전체적인 콘솔 로그는 다음과 같습니다.

Go to work
Play music
Listening
Listening
Start work
Working
Leave work
Play music
Listening
Listening

이처럼 코루틴을 사용하면 비동기로 루틴을 실행하고 일반적인 서브 루틴과 다르게 실행 중간에 중단과 임의 시점에 재개가 가능하여 루틴 간 협력을 통해 비선점적 멀티태스킹이 가능해집니다.

 

2. 동시성 프로그래밍 지원

동시성 프로그래밍이란 2개 이상의 프로세스가 동시에 계산을 진행하는 상태를 말합니다.

두 개 이상의 실행 쓰레드가 필요하나 단일 코어에서는 병렬적으로 실행하는 것이 물리적으로 불가하기에 쓰레드 실행이 효율적이도록 교차 배치합니다.

동시성 프로그래밍을 쓰레드와 코루틴 간 비교해 보면 다음과 같습니다.

쓰레드는 OS가 CPU 상태에 따라 쓰레드 작업을 스케줄링 하기에 쓰레드 간 교체 시 Context switching 비용이 발생합니다.

코루틴은 하나의 쓰레드 내 코루틴 간 관계 정의를 통해 중단 및 재개하기에 Context switching 비용으로 인한 오버헤드 없이 언어 레벨에서 스케줄링이 가능하게 합니다.

따라서 코루틴에서 메인 쓰레드를 차단하지 않으면서 현재 코루틴 실행을 일시 중지할 수 있는 기능을 제공하기에 경량 쓰레드로도 부릅니다.

 

3. 쉽고 가독성 있게 작성할 수 있는 비동기 처리

비동기 코드 작성 시 정상적인 비동기 결과를 받아서 처리하는 코드 외에 비정상적으로 완료되지 못하는 케이스를 예외 처리할 수 있는 부수적인 액세서리 코드 작성이 필요합니다.

코루틴은 예측 가능한 프로그래밍을 할 수 있다는 점에서 동기 코드 진행 흐름과 동일하게 예외 처리가 가능하며 디버깅 측면에서도 큰 장점이 있습니다.

또한 멀티 쓰레드와 비교해서 서로에게 영향 주기 위해 쓰레드 간 통신과 콜백 구조로 코드가 흐르지 않기에 코드 흐름 파악이 쉽습니다.

OS가 CPU 상태에 따라 쓰레드 작업을 스케줄링 하지 않고 개발자가 직접 작업을 스케줄링 하기에 비동기 코드 작성이 간단합니다.

 

글을 마무리하며

처음 코루틴을 접했을 때 코틀린의 Ko를 따서 Koroutine 이어야 할 것 같은데 왜 Coroutine 인지 의문이 들었던 기억이 있습니다.

비동기 처리에 필요한 함수와 동작 방식을 알더라도 이를 가능하게 하는 개념에 대한 정리가 중요하다는 생각이 들었고, 이번 글을 통해 코루틴을 활용하시기 전에 코루틴이 무엇인지와 어떤 장점이 있는지에 대해 간략하게 설명하고자 하였습니다.

개인적으로 실무에 이미 RxJava를 활용하고 계신다면 기존과 같이 사용하시는 게 맞다고 생각합니다. 하지만 RxJava에서만 제공되는 기능이 필요하지 않으며 낮은 러닝 커브로 이해하기 쉽고 간결한 코드를 사용하고 싶다면 이번 기회에 코루틴을 도입을 고민하시는 것도 좋을 것 같습니다.

 


출처

 

댓글