개발/안드로이드

코루틴 동기와 비동기, 코루틴 용어

스몰스테핑 2024. 5. 21. 14:48

1. 코루틴 동기와 비동기

뛰어난 사용자 경험을 제공하기 위해서는 동시 실행이라는 중요한 기술이 필요하다. 동시 실행이란, 앱에서 여러 태스크를 동시에 실행하는 것이다. 예를 들어, 앱은 웹 서버에서 데이터를 가져오거나 기기에 사용자 데이터를 저장하는 동시에 사용자 입력 이벤트에 응답하고 적절하게 UI를 업데이트할 수 있다.

 

앱에서 동시에 작업하기 위해선 Kotlin의 코루틴이라는 것을 사용한다. 코루틴을 사용하면 코드 블록의 실행을 정지했다가 나중에 다시 시작할 수 있으며 그동안 다른 작업도 수행할 수 있다. 코루틴을 사용하면 비동기 코드를 더 쉽게 작성할 수도 있다. 즉, 한 태스크를 완전히 완료하지 않아도 다음 태스크를 시작할 수 있으므로 여러 태스크를 동시에 실행할 수 있다.

 

 

동기 코드

동기 코드에서는 한 번에 하나의 개념 태스크만 진행된다. 순차 선형 경로라고 할 수 있는데, 이는 다음 태스크가 시작되기 전에 한 태스크가 완전히 완료되어야 된다.

 

예를 들어, println() 함수의 경우, 동기 호출이라 할 수 있다. 텍스트를 출력하는 태스크가 완료된 후, 다음 코드 줄로 넘어가기 때문이다.

fun main() {
    println("A")
    println("B")
}

 

동기 함수는 태스크가 완전히 완료된 경우에만 반환되며, 위와 같은 코드에서 main()함수는 각 함수 호출이 동기식이므로 전체 main() 함수도 동기식이며, 마지막 print 문이 실행된 후에나 main() 함수가 반환된다.

 

 

지연 추가

날씨 앱을 만든다고 치자, 날씨 예보 정보를 받아오기 위해 웹 서버에 네트워크 요청을 해야한다. 네트워크 연결 후 데이터를 받아오는 지연시간이 생겼다고 가정하고 코드를 만들면 다음과 같다.

import kotlinx.coroutines.*

fun main() {
    println("Weather forecast")
    delay(1000)
    println("Sunny")
}

 

코루틴 라이브러리에서 제공되는 정지 함수인 delay()는 main() 함수가 실행 되었을 때, 이 delay() 코드 줄에서 일시 정지된 후, 다시 시작한다.

 

각 태스크를 중단된 지점에서 계속 진행하여 여러 태스크르 한 번에 처리하는 runBlocking() 함수로 래핑해보자

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        delay(1000)
        println("Sunny")
    }
}

 

runBlocking()은 동기식이다. 람다 블록 내에 모든 작업이 완료될 때까지는 반환되지 않는다. 즉, delay() 함수의 호출이 끝날때까지 기다린 후, Sunny 출력문을 실행한다. 마지막 출력문을 실행하고 나서야 runBlocking() 함수의 모든 작업이 완료되어 함수를 반환한다.

 

코드는 여전히 동기식이며, 선형으로 실행되어 한 번에 한 작업만 처리한다. 달라진 점은 delay로 인해 기존보다 더 오랜 시간에 걸쳐 실행된다.

 

 

정지 함수

날씨 데이터를 위한 네트워크 요청 및 데이터 가공 로직은 꽤 길기 때문에 따로 자체적인 함수로 추출하는게 좋다.

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

 

delay()는 정지 함수이고, printForecast()도 정지 함수이다.

정지 함수는 일반 함수와 비슷하지만, 정지되었다가 나중에 다시 시작할 수 있다. 이를 위해, 이러한 기능을 지원하는 다른 정지 함수에만 정지 함수를 호출할 수 있다.

 

정지 함수에는 정지 지점을 0개 이상 포함할 수 있다. 정지 지점은 함수 내에서 함수 실행을 정지할 수 있는 위치이다. 실행이 다시 시작되면 코드에서 마지막에 중단한 지점부터 다시 시작되어 함수의 나머지 부분이 진행된다.

 

 

비동기 코드

코루틴 라이브러리의 launch() 함수를 사용하여 새 코루틴을 실행한다. 태스크를 동시에 실행하려면 동시에 여러 코루틴이 진행될 수 있도록 코드에 여러 launch() 함수를 추가한다.

 

Kotlin의 코루틴은 구조화된 동시 실행이라는 핵심 개념을 따른다. 즉, 코드가 기본적으로 순차적이며 동시 실행을 명시적으로 요청하지 않는 한 기본 이벤트 루프와 협력한다. 함수를 호출하면 구현 세부정보에 사용된 코루틴 수와 상관없이 함수는 반환되기 전까지 작업을 완전히 완료해야 한다고 가정한다. 예외와 함께 실패하더라도 예외가 발생하면 함수에서 더 이상 대기 중인 태스크가 없게 된다. 따라서 예외 발생 or 작업 성공과 상관없이 제어 흐름이 함수에서 반환되면 모든 작업이 종료된다.

 

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        launch {
            printForecast()
        }
        launch {
            printTemperature()
        }
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
}

 

해당 코드를 사용하면 출력은 동일하나, 실행을 더 빠른 것을 알 수 있다. 기존에 동기 코드를 사용했을 때는 두 정지 함수를 한꺼번에 처리하는 것이 아니라 순차대로 하나씩 처리 했지만, 이제 이 두 정지 함수가 각각 별개의 코루틴에 존재하므로 동시에 실행하여 처리하는 것이다.

 

그렇기 때문에 눈에 보이는 것만으로도 기존 동기 코드를 사용했을땐 단순히 딜레이 1초 씩 2개니 2초 이상 걸렸지만, 동시 실행하게 된 비동기 코드에선 1초 + @로 작업시간이 단축되는 것이다.

 

 

async()

실제 환경에서 예보 및 온도에 관한 네트워크 요청을 처리하는 데 얼마나 걸릴지 모른다. 네트워크 환경에 따라서 그 속도는 더 차이가 날 수 있다. 두 태스크가 모두 완료되었을 때 통합 날씨 보고를 표시하려는 경우, 위에서 사용한 launch() 만으론 충분하지 않을 것이다. 그래서 async()가 등장한다.

 

코루틴이 완료되는 시점에 관심이 있고, 코루틴의 반환 값이 필요하다면 코루틴 라이브러리의 async() 함수를 사용한다.

 

async() 함수는 Deferred 유형의 객체를 반환한다. 준비가 되면 결과가 표시될 것이라는 프로미스와 같다. await() 를 사용한 Deferred 객체의 결과에 액세스할 수 있다.

 

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        val forecast: Deferred<String> = async {
            getForecast()
        }
        val temperature: Deferred<String> = async {
            getTemperature()
        }
        println("${forecast.await()} ${temperature.await()}")
        println("Have a good day!")
    }
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}

 

동시에 실행되어 예보 데이터와 온도 데이터를 가져오는 두 코루틴을 만든다. 각 코루틴이 완료되면 값이 반환된다. 그런 뒤, 두 반환 값을 단일 print 문에 결합하여 출력한다.

 

참고: async(), 의 실제 예를 보려면 Now in Android 앱에서 이 부분을 확인할 수 있다. SyncWorker 클래스에서 특정 백엔드로 동기화에 성공하면 sync() 호출에서 Boolean이 반환된다. 동기화 작업 중 하나라도 실패하면 앱이 다시 시도해야 한다.

 

 

병렬 분해

이 날씨 예시를 한 단계 더 발전시켜 코루틴이 작업 병렬 분해에 어떻게 유용한지 확인할 수 있다. 병렬 분해는 문제를 병렬로 해결할 수 있는 더 작은 하위 태스크로 세분화하는 것이다. 하위 태스크의 결과가 준비되면 최종 결과로 결합할 수 있다.

 

coroutineScope {}는 태스크의 로컬 범위를 만든다. 이 범위 내에서 실행된 코루틴은 이 범위 내에 그룹화된다. 또한, 실행된 코루틴을 포함한 모든 작업이 완료되어야 반환한다.

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}

 

위 코드를 보면, 코루틴 getForeast()와 getTemperature()가 완료되고나서 각 결과를 반환한다. 그러면 "Sunny"와 "30ºC"가 결합되어 하나의 print 문으로 출력될 것이고 그제서야 반환하여 다음 출력문이 나올 것이다.

 

coroutineScope()를 사용하면 함수가 내부적으로 동시에 작업하더라도 모든 작업 완료 전까진 반환되지 않으므로 호출자에는 함수가 동기 작업을 하는 것 처럼 보일 것이다.

 

구조화된 동시 실행에 관한 주요 정보는 여러 동시 작업을 단일 동기 작업에 적용할 수 있다는 것이다. 여기서 동시 실행은 구현 세부 정보이다. 호출 코드의 유일한 요구사항은 호출 코드가 정지 함수 또는 코루틴에 있어야 한다는 점이다. 이 점 외에 호출 코드 구조에 동시 실행 세부 정보를 고려할 필요는 없다.

 

 

비동기식으로 또는 동시에 작업을 실행할 때 작업 실행 방법, 코루틴의 지속 시간, 취소되거나 오류로 인해 실패하는 경우 발생하는 현상 등에 대한 질문에 답해야 한다. 코루틴은 구조화된 동시 실행의 원칙을 따르므로, 메커니즘의 조합을 사용하여 코두에서 코루틴을 사용할 때 이러한 질문에 답해야 한다.

 

 

2. 코루틴 개념

작업

launch() 함수로 코루틴을 실행하면 Job 인스턴스가 반환된다. Job은 코루틴에 대한 핸들 또는 참조를 보유하므로 그 수명 주기를 관리할 수 있다.

val job = launch { ... }

 

참고: async() 함수로 시작된 코루틴에서 반환되는 Deferred 객체도 Job이며 코루틴의 향후 결과를 보유한다.

 

이 작업은 수명 주기 또는 코루틴의 지속 시간을 제어하는 데 사용할 수 있다. (더 이상 작업이 필요하지 않은 경우, 코루틴 취소)

job.cancel()

 

작업을 사용하면 활성, 취소, 완료 등의 상태를 확인할 수 있다. 코루틴 및 이 코루틴이 실행한 코루틴이 모든 작업을 완료하면 작업이 완료된다. 취소되거나 예외와 함께 실패하는 등 다른 이유로 코루틴이 완료되었을 수도 있지만, 그렇다고 하더라도 작업은 해당 지점에서 완료된 것으로 간주한다.

 

또한 코루틴 간의 상위-하위 관계를 추적한다.

 

 

작업 계층 구조

코루틴이 다른 코루틴을 실행할 때 새 코루틴에서 반환되는 작업을 원래 상위 작업의 하위 요소라고 한다.

val job = launch {
    ...

    val childJob = launch { ... }

    ...
}

 

 

이러한 상위-하위 관계는 각 작업을 실행할 수 있는 작업 계층 구조를 형성한다.

이 상위-하위 관계는 하위 요소와 상위 요소 및 동일한 상위 요소에 속한 다른 하위 요소의 특정 동작을 지정하므로 중요하다.

  • 상위 작업이 취소되면 그 하위 작업도 취소된다.
  • 하위 작업이 job.cancel()을 사용하여 취소되면 종료되지만, 이로 인해 상위 작업이 취소되지는 않는다.
  • 작업이 예외와 함께 실패하면 이 예외로 상위 항목이 취소된다. 이를 오류 상향 전파라고 한다.

 

CoroutineScope

코루틴은 일반적으로 CoroutineScope로 실행된다. 이렇게 하면 코루틴은 관리되지 않아 손실되는 일이 없으므로 리소스 낭비를 방지한다.

 

launch() 및 async()는 CoroutineScope의 확장 함수이다. 범위에서 launch() 또는 async()를 호출하여 이 범위 내에서 새 코루틴을 만든다.

 

CoroutineScope는 수명 주기와 연결되어 범위 내의 코루틴이 유지되는 기간에 경계를 설정한다. 범위가 취소되면 작업이 취소되고 취소가 하위 작업에 전파된다. 범위의 하위 작업이 예외와 함께 실패하면 다른 하위 작업이 취소되고, 상위 작업이 취소되며, 호출자에 예외가 다시 발생한다.

 

 

Android 앱의 CoroutineScope

Android는 Activity(lifecycleScope) 및 ViewModel(viewModelScope) 같이 수명 주기가 잘 정의된 항목에서 코루틴 범위를 지원한다. 이러한 범위 내에서 시작된 코루틴은 상응하는 항목의 수명 주기(예: Activity, ViewModel)를 따른다.

 

예를 들어 lifecycleScope라는 제공된 코루틴 범위로 Activity에서 코루틴을 시작한다고 가정했을 때, 활동이 소멸되면 lifecycleScope가 취소되고 해당하는 하위 코루틴도 모두 자동으로 취소된다. Activity의 수명 주기 이후에 발생하는 코루틴이 원하는 동작인지만 결정하면 된다.

 

 

CoroutineContext

CoroutineContext는 코루틴이 실행될 컨텍스트에 관한 정보를 제공한다. CoroutineContext는 본질적으로 각 요소에 고유한 키가 있는 요소를 저장하는 맵이다. 필수 필드는 아니지만 컨텍스트에 포함될 수 있는 항목의 예는 다음과 같다.

  • 이름 - 코루틴을 고유하게 식별하는 이름
  • 작업 - 코루틴의 수명 주기를 제어함
  • 디스패처 - 작업을 적절한 스레드에 전달함
  • 예외 핸들러 - 코루틴에서 실행되는 코드에서 발생하는 예외를 처리함
참고: CoroutineContext의 기본값이며 개발자가 값을 제공하지 않는 경우 사용된다.
1 .코루틴 이름으로 'coroutine' 사용
2. 상위 작업 없음
3. 코루틴 디스패처로 Dispatchers.Default 사용
4. 예외 핸들러 없음

 

컨텍스트의 각 요소는 + 연산자와 함께 추가될 수 있다. 예를 들어 다음과 같이 CoroutineContext을 정의할 수 있다.

Job() + Dispatchers.Main + exceptionHandler

 

이름이 제공되지 않았으므로 기본 코루틴 이름이 사용된다.

 

코루틴 내에서 새 코루틴을 실행하는 경우 하위 코루틴이 상위 코루틴의 CoroutineContext를 상속하지만 구체적으로 방금 만든 코루틴의 작업을 대체한다. 상위 컨텍스트에서 상속된 요소를 재정의할 수 있다. 변경하려는 컨텍스트 부분에 관해 launch() 함수나 async() 함수에 인수를 전달하면 된다.

 

scope.launch(Dispatchers.Default) {
    ...
}

 

 

디스패처

디스패처는 스레드에 작업을 전달하거나 할당하는 것이다. 코루틴은 디스패처를 사용해 실행에 사용할 스레드를 결정한다. 스레드를 시작할 수 있고, 스레드가 작업을 실행한 후에 더 이상 할 작업이 없으면 종료된다.

 

사용자가 앱을 시작하면 Android 시스템에서 새 프로세스와 앱의 단일 실행 스레드(기본 스레드)가 만들어진다. 기본 스레드는 Android 시스템 이벤트, 화면에 UI 그리기, 사용자 입력 이벤트 처리 등 앱의 여러 중요한 작업을 처리한다. 따라서 작성하는 대부분의 앱 코드는 기본 스레드에서 실행될 수 있다.

 

코드의 스레딩 동작과 관련하여 알아야 할 것이 차단비차단이다. 일반 함수는 작업이 완료될 때까지 호출 스레드를 차단한다. 즉 작업이 완료될 때까지 호출 스레드를 생성하지 않으므로 그동안 다른 작업을 할 수 없다. 반대로 비차단은 특정 조건이 충족될 때까지 호출 스레드를 생성하므로 그동안 다른 작업을 할 수 있다. 비동기 함수를 사용하여 비차단 작업을 실행할 수 있다. 작업이 완료되기 전에 반환되기 때문이다.

 

Android 앱의 경우 매우 빠르게 실행되는 경우에만 기본 스레드에서 차단 코드를 호출해야 한다. 새 이벤트가 트리거되면 즉시 작업이 실행될 수 있도록 기본 스레드를 차단 해제 상태로 유지하는 것이 목표다. 이 기본 스레드는 활동의 UI 스레드이며 UI 그리기 및 UI 관련 이벤트를 담당한다. 화면에 변경사항이 있으면 UI를 다시 그려야 한다. 화면의 애니메이션 같은 항목의 경우 매끄러운 전환으로 보이도록 UI를 자주 다시 그려야 한다. 기본 스레드가 장기 실행 작업 블록을 실행해야 하는 경우에는 화면이 자주 업데이트되지 않고 사용자에게 갑작스러운 전환(버벅거림)이 표시되거나 앱이 중단되거나 느리게 반응할 수도 있다.

 

따라서 장기 실행 작업 항목을 기본 스레드 외부로 이동하여 다른 스레드에서 처리하도록 해야 한다. 앱은 단일 기본 스레드부터 시작하지만, 추가 작업을 실행할 여러 스레드를 만들 수 있다. 이러한 추가 스레드를 작업자 스레드라고 할 수 있다. 장기 실행 태스크로 인해 작업자 스레드가 오랫동안 차단되어도 괜찮다. 그동안 기본 스레드가 차단 해제되어 사용자에게 적극적으로 응답할 수 있기 때문이다.

 

Kotlin에서 제공하는 몇 가지 기본 디스패처가 존재한다.

Dispatchers.Main - 기본 Android 스레드에서 코루틴을 실행한다. 이 디스패처는 주로 UI 업데이트 및 상호작용을 처리하고 빠른 작업을 실행하는 데 사용된다.

Dispatchers.IO - 기본 스레드 외부에서 디스크 또는 네트워크 I/O를 실행하도록 최적화 되어 있다. 예를 들어 파일에서 읽거나 파일에 쓰고 네트워크 작업을 실행한다.

Dispatchers.Default - 컨텍스트에서 디스패처가 지정되지 않은 상태에서 launch() 및 async()를 호출할 때 사용되는 기본 디스패처이다. 이 디스패처를 사용해 계산이 많은 작업을 기본 스레드 외부에서 실행할 수 있다. 예를 들면 비트맵 이미지 파일 처리 등이 존재한다.

참고: 이미 사용 가능한 Handler 또는 Executor 에서 CoroutineDispatcher를 만들어야 한다면 Executor.asCoroutineDispatcher() 및 Hander.asCoroutineDispatcher() 확장 프로그램이 존재한다.

 

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("${Thread.currentThread().name} - runBlocking function")
                launch {
            println("${Thread.currentThread().name} - launch function")
            withContext(Dispatchers.Default) {
                println("${Thread.currentThread().name} - withContext function")
                delay(1000)
                println("10 results found.")
            }
            println("${Thread.currentThread().name} - end of launch function")
        }
        println("Loading...")
    }
}

 

출력 결과:

main @coroutine#1 - runBlocking function
Loading...
main @coroutine#2 - launch function
DefaultDispatcher-worker-1 @coroutine#2 - withContext function
10 results found.
main @coroutine#2 - end of launch function

 

이 출력에서 코드의 대부분이 기본 스레드의 코루틴에서 실행되는 것을 확인할 수 있다. 그러나 withContext(Dispatchers.Default) 블록의 코드 부분에서는 기본 스레드가 아닌 기본 디스패처 작업자 스레드의 코루틴에서 실행된다. withContext()가 반환되면 코루틴은 다시 기본 스레드에서 실행된다. 이 예에서 코루틴에 사용되는 컨텍스트를 수정하여 디스패처를 전환할 수 있음을 보여준다.

 

기본 스레드에서 시작된 코루틴이 있고 특정 작업을 기본 스레드 외부로 이동하려면 withContext를 사용하여 작업에 사용되는 디스패처를 전환하면 된다. 작업 유형에 따라 사용 가능한 디스패처인 Main, Default, IO 중에서 적절하게 선택한다. 그런 다음 이 작업을 목적에 지정된 스레드에 할당할 수 있다. 코루틴은 자체적으로 정지될 수 있으며, 디스패처 또한 코루틴이 다시 시작되는 방식에 영향을 준다.

 

Room 및 Retrofit과 같이 많이 사용되는 라이브러리로 작업할 때는 라이브러리 코드에서 이미 Dispatchers.IO 같은 대체 코루틴 디스패처를 사용하여 이 작업을 처리한다면 개발자가 명시적으로 디스패처를 전환하지 않아도 된다. 이러한 경우 라이브러리가 표시하는 suspend 함수는 이미 기본 안전 함수일 수도 있으며 기본 스레드에서 실행되는 코루틴에서 호출할 수 있다. 라이브러리 자체에서 디스패처를 작업자 스레드를 사용하는 디스패처로 전환하는 작업이 처리된다.