Swift Async-Await과 Kotlin Coroutine 비교
Swift 개발자에게 소개하는 Kotlin Coroutine
안녕하세요. NaverFinancial 앱 개발팀의 심명표 입니다.
이번 포스팅에서는 Swift와 Kotlin에서 비동기 작업을 수행하기 위해 사용하는 Async-Await (Swift)과 Coroutine (Kotlin)을 소개 및 비교해 보고자 합니다. Kotlin으로 개발하는 안드로이드 개발자로써 Swift에 대한 지식이 부족하기에 두 기술에 대한 소개와 비교를 위한 예제들은 Swift 공식 문서의 Concurrency 섹션을 기준으로 합니다.
이번 포스팅은 특히 Swift에서 비동기 처리를 위해 Async-Await을 사용한 경험이 있고, Kotlin에서 비동기 처리를 위해 제공하는 Coroutine에도 관심이 있어 두 기술을 비교하고 싶은 경우 도움이 될 것 같습니다.
Swift와 Kotlin의 기본 문법 상 차이는 이곳에 잘 정리해 주신 분이 있으니 참고하세요.
동기와 비동기 스타일 프로그램
애플리케이션은 입력 데이터와 이를 가공하여 원하는 출력 데이터를 만들어내는 함수의 집합이라고 생각해 볼 수 있습니다. 여기서 입력 데이터는 다양한 소스로부터 입력될 수 있는데, 대표적으로 여러 가지 입력 장치를 통한 사용자의 입력이나 로컬 또는 원격에 저장된 데이터로부터 읽어드린 데이터를 예로 들 수 있습니다. 그리고 요구사항에 따라 다양한 데이터 가공 함수를 정의하고, 정의된 함수를 입력 데이터를 대상으로 적절한 순서로 호출하여 원하는 출력 데이터로 만들어 냅니다.
= Input -> Fn -> … -> Fn -> Output
이때, 입력 데이터를 읽어들이는 과정이나 데이터를 가공하는 과정 그리고 가공된 데이터를 다양한 장치로 출력하는 과정은 항상 빠르게 수행된다고 보장할 수 없는데, 대표적으로 Network I/O나 Disk I/O와 같은 요인들에 의해서 결과를 전달받기까지 오랜 시간이 걸릴 수 있습니다.
데이터를 읽어들이는 것, 가공하는 것, 가공된 데이터를 출력하는 것은 모두 함수로 정의할 수 있고, 이러한 함수들은 앞서 언급한 것처럼 여러 가지 요인에 의해 오래 걸릴 수 있는데 이는 프로그램을 일시적으로 중단 시키는 함수라고 볼 수 있습니다. 이런 함수들을 지금부터 중단 함수(Suspending function)라고 부르겠습니다 (실제 Kotlin Coroutine에서 이렇게 부릅니다).
동기 스타일 프로그램에서는 이러한 중단 함수를 코드 실행 흐름에 따라서 별도의 처리 없이 호출하고 결과를 받을 때까지 대기한 뒤 다음 코드를 실행합니다. 이는 코드를 직관적으로 만들어 실행 흐름을 따라가기 용이하게 만드는 장점이 있지만, 호출하는 중단 함수의 개수만큼 실행 시간이 소요되는 단점이 있습니다.
반면 비동기 스타일의 프로그램은 중단 함수는 별도의 스레드를 이용하여 실행하고 이후 코드를 계속 실행해 나갑니다. 이후 별도 스레드에서 실행을 마친 중단 함수는 콜백 함수 등의 형식으로 호출 스레드에게 결과를 전달하고, 호출 스레드는 결과 데이터를 가지고 필요한 추가 작업을 수행합니다. 이 방식은 병렬로 실행 가능한 중단 함수들을 동시에 실행하여 실행 시간을 줄일 수 있지만, 비동기로 호출해야 하는 함수가 많아지거나 서로 의존성을 갖는 경우 코드가 복잡해질 수 있습니다. 또한, 필요에 따라 적절하게 실행을 취소할 수 있는 수단도 마련해야 합니다.
동기의 옷을 입은 비동기 프로그램
개발자들은 비동기 프로그래밍의 효율성을 가져가면서 동기 스타일 프로그래밍의 장점을 얻을 수 있는 방법을 고민했습니다. 즉, 병렬로 실행 가능한 작업은 동시에 실행하여 효율을 높이고, 코드는 실행 흐름을 유지해 직관적으로 만들고, 필요에 따라 전체 작업을 손쉽게 취소할 수도 있으며, 특정 작업에서 예외 발생 시 예외 처리 및 전파를 단순화할 수 있는 방식을 고민했습니다.
그 결과 CPS(Continuation Passing Style) 같은 연구를 통해 관련 기술들이 발전했고, 널리 사용되는 많은 언어들이 각자의 방식으로 이를 지원하기 시작하였습니다. Kotlin은 Coroutine이라는 이름으로, Swift는 Async-Await이라는 이름으로 CPS 구현 기술이 등장하였습니다. 이 두 기술은 앞서 언급한 것처럼 동일한 문제를 해결하기 위해 등장한 기술에 근간을 두고 있기 때문에 겉모습은 다를지라도 많은 개념을 공유하고 있습니다.
지금부터 다양한 Swift Async-Await 예제를 살펴보고, 그에 상응하는 Kotlin Coroutine 예제로 변환해 보면서 두 기술을 비교해 보겠습니다.
중단 함수
우리는 프로그래밍을 하면서 다양한 함수를 정의합니다. 이러한 함수들 중에는 외부 요인에 의해 실행 시간을 예측할 수 없는 함수들이 있는데, 상대적으로 응답 속도가 늦은 하드웨어로의 접근이나 원격 장치의 접근을 수행하는 함수를 예로 들 수 있습니다. 이런 함수들은 외부 장치로부터 응답을 받기 전까지 대기했다가 응답이 도작하면 반환하는데, 이렇게 대기(중단) 상태로 전환될 가능성이 있는 함수들을 Kotlin에서는 중단 함수(Suspending Function)라고 부르며, 함수 정의 앞 부분에 suspend
키워드를 붙여 중단 함수임을 표시합니다.
// Kotlin
suspend fun doWork() {
// do something
}
CPS 구현체를 제공하는 언어마다 구현 방식은 조금씩 다를 수 있지만, 보통 이러한 중단 함수는 아무 곳에서나 호출할 수 있는 것은 아니며, 특정한 컨텍스트 내에서만 호출 가능합니다. 예를 들어 Kotlin에서는 다른 중단 함수 내부나 코루틴(Coroutine) 내부에서 호출이 가능합니다.
코루틴은 Kotlin에서 비동기 작업을 관리하는 최소 단위로 대표적으로 launch { }
나 async { }
코루틴 빌더를 통해 생성할 수 있습니다. launch 빌더로 생성한 코루틴은 Fire & Forget 방식으로 호출 후 호출한 곳으로 결과 전달이 필요하지 않을 때 사용하고, async 빌더로 생성한 코루틴은 비동기로 실행한 결과를 호출한 곳으로 전달받아야 할 경우 사용합니다.
Swift Async-Await에서는 Kotlin Coroutine처럼 중단 함수 단위로 정의하지는 않고, 조금 더 큰 단위인 중단 가능한 코드를 포함하고 있는 비동기 함수 단위로 정의합니다. 이는 앞서 설명한 async 코루틴 빌더로 생성된 코루틴과 유사합니다.
Swift Async Function == Kotlin Async Coroutine
다음 Swift 예제는 파라미터로 전달된 사진첩 이름에 해당하는 사진 목록을 비동기로 가져오는 예제입니다.
// Swift
func listPhotos(inGallery name: String) async -> [String] {
let result = // ... some asynchronous networking code ...
return result
}
Swift의 비동기 함수는 선언부에서 반환 타입 명시 전에 async
키워드를 표시하여 비동기로 실행되는 함수임을 나타냅니다. 이후 예제에서 다루겠지만 이 함수의 실행 결과는 await
키워드를 이용한 함수 호출로 전달받을 수 있습니다.
위 코드를 Kotlin 코루틴 방식으로 구현하면 다음과 같습니다.
// Kotlin
suspend fun listPhotos(inGallery: String): List<String> {
val result = // ... some asynchronous networking code ...
return result
}
fun main() = runBlocking {
val deferred: Deferred<List<String>> = async {
listPhotos(inGallery= "test")
}
}
Kotlin에서는 중단 함수 선언부에 suspend
라는 키워드를 붙여 이 함수를 호출하면 프로그램 실행 흐름이 일시 중단될 수 있음을 나타냅니다. 그리고 이러한 중단 함수들은 “코루틴 내부”나 “다른 중단 함수 내부”에서만 호출될 수 있기 때문에 위 예제에서는 이를 사용하는 main 함수에서 async { }
코루틴 빌더를 이용하여 코루틴을 생성한 뒤 코루틴 내부에서 listPhotos()
중단 함수를 호출하고 있습니다.
runBlocking { } 은 코루틴 컨텍스트가 없는 곳에서 현재 스레드를 블록하고 코루틴을 호출하기 위해 사용할 수 있는 코루틴 빌더이고, 그렇기 때문에 async { } 코루틴 빌더 없이도 중단 함수 호출이 가능하지만 Swift와의 비교를 명확히 하기 위해 async { }를 사용하였습니다.
앞선 Swift 예제에서 async
로 표시된 비동기 함수를 호출한 뒤 반환되는 결과는 어떻게 받을 수 있을까요? 다음은 listPhotos() async
함수를 호출한 뒤 await
을 통해 함수의 실행 결과를 받는 예제입니다.
// Swift
let photoNames = await listPhotos(inGallery: "Summer Vacation")
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
let photo = await downloadPhoto(named: name)
show(photo)
위 코드는 하나의 비동기 함수 내부 코드로 볼 수 있으며, 이 함수에서 await
을 호출하는 두 부분은 모두 실행 흐름이 중단될 수 있는 “중단점”이 됩니다. 이처럼 Swift에서는 다른 함수를 호출하는 곳에 await
키워드가 사용되었다면 실행 흐름이 중단될 수 있는 부분 임을 명확하게 알 수 있습니다.
이를 Kotlin Coroutine을 이용하여 나타내면 다음과 같습니다.
// Kotlin
1-/->| val photoNames = listPhotos(inGallery= "Summer Vacation")
2 | val sortedNames = photoNames.sorted()
3 | val name = sortedNames[0]
4-/->| val photo = downloadPhoto(named= name)
5 | show(photo)
Kotlin 코드를 살펴보면 Swift 코드와 매우 유사함을 알 수 있습니다. 언어의 키워드 차이에 의해 let
을 val
로 변경 적용 한 부분과 중단 함수인 listPhotos()
와 downloadPhoto()
함수 호출 시 await 키워드를 사용하지 않은 부분이 주요 차이점입니다. 이처럼 코루틴 내부에서 중단 함수를 호출하는 것은 별도의 키워드 없이 일반 함수를 호출하는 방식과 동일합니다. Swift 에서와 같이 await
키워드를 통해 명시적으로 중단점인 것을 나타내지 않기 때문에 Kotlin IDE에서는 코드 줄 번호가 표시되는 곳에 중단 함수 호출임을 나타내는 아이콘이 표시됩니다 (위 예제에 한해서 줄번호와 중단점 표시를 넣어봤습니다 😄).
또한 위 예제를 Swift 예제와 좀 더 유사하게 만들기 위해 Kotlin async { }
코루틴 빌더를 이용하도록 수정할 수 있습니다. async { }
빌더로 만든 코루틴을 실행하면 Deferred<T> 타입 객체를 반환 받을 수 있는데, 이것은 비동기 실행 결과를 전달 받을 수 있는 객체이며 Deferred<T>.await()
중단 함수 호출을 통해 실행 결과 받을 수 있습니다.
다음은 이를 적용한 Kotlin 예제 입니다.
// Kotlin
val photoNames = listPhotos(inGallery= "Summer Vacation").await()
val sortedNames = photoNames.sorted()
val name = sortedNames[0]
val photo = downloadPhoto(named= name).await()
show(photo)
하지만 위 예제와 같이 listPhotos()나 downloadPhoto() 함수는 async 코루틴으로 만들어 await()을 호출하는 것 보다 각 중단 함수를 호출하던 이전 예제처럼 작성하는 것이 좀 더 효율적입니다. 두 중단 함수 간에는 의존 관계가 있기 때문입니다 (사진 이름을 받고 난 후 이름을 이용하여 다운로드).
지금까지 Swift와 Kotlin에서 비동기 작업을 정의하는 방법과 그 작업의 결과를 확인하는 방법을 살펴보았습니다. 비동기 작업의 결과를 확인하는 코드는 실행 흐름을 다른 곳으로 옮겨 실행 후 되돌아오는 동작이기 때문에 옮겨진 실행 흐름이 돌아올 때 까지 대기하는 부분은 “중단점”이 됩니다. 즉, 비동기 작업의 결과를 기다리는 중단점에 도착한 호출자는 중단 상태(Suspended)로 전환되었다가 비동기 작업이 완료되고 결과가 반환되면 다시 재개 상태(Resumed)로 전환되어 이후 작업을 진행합니다. 이러한 기능을 프로그래밍 언어 레벨에서 제공하기 위해서는 호출자의 호출 컨텍스트의 백업 및 복원(중단 위치 및 콜스택, 지역 변수 캡처 등), 구조화된 동시성 제어를 위한 비동기 작업 스코프 정의 및 계층 관리 등의 제반 기능들이 필요합니다. 그리고 이러한 기능들을 제공하기 위해서 중단 함수를 호출할 수 있는 위치에 제약이 생깁니다.
Swift에서는 async
함수의 실행 결과를 받기 위한 await
같은 중단 함수는 다음과 같은 곳에서만 호출 가능합니다.
- async 함수, 메서드 또는 속성 내부
- @main으로 표시된 structure, class, enumeration의 static main() 메서드
(@main은 swift 프로그램의 Entrypoint를 나타냄) - Unstructured child task 내부 (뒤에서 다룸)
Kotlin에서는 suspend
키워드가 붙은 중단 함수의 호출은 다음과 같은 곳에서 가능합니다.
- 다른 중단 함수의 내부
- 코루틴 내부
이러한 제약 조건 속에서 중단 함수를 호출 한 비동기 작업은 중단될 때의 컨텍스트를 저장했다가 다시 재개될 때 중단 함수가 반환한 결과를 호출 위치로 전달하면서 이전 컨텍스트를 복원합니다.
Swift와 Kotlin 모두 테스트 환경에서 실행이 오래 걸리는 중단 함수를 묘사하고자 할 경우 사용할 수 있는 유용한 함수들이 있는데, Swfit에서는
Task.sleep()
중단 함수, Kotlin에서는delay()
중단 함수를 사용할 수 있습니다.
비동기 데이터 스트림
앞서 살펴 본 비동기 작업 예제에서는 사진첩의 특정 폴더에 저장된 사진 데이터 목록을 한 번의 비동기 함수 호출로 모두 가져왔습니다. 만일 하나의 데이터가 아닌 스트림 형태의 데이터를 비동기로 처리해야 한다면 어떻게 해야 할까요? 이러한 데이터의 예로는 실시간 공기질 측정 데이터, 실시간 사용자 위치 정보 데이터 같은 센터 데이터나 대용량 파일 다운로드 시 버퍼로 수신 한 청크 데이터 등이 있습니다.
다음 예제는 Swift에서 사용자가 표준 입력으로 한 줄의 텍스트를 입력할 때마다 표준 출력(화면)으로 출력하는 코드입니다.
// Swift
import Foundation
let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
print(line)
}
일반적인 for-in 문과 달리 for-await-in 문은 비동기 스트림을 처리하기 위해서 사용되며 for의 각 이터레이션마다 비동기 스트림에서 전달되는 각 데이터의 수신을 대기(중단점) 합니다.
이를 Kotlin 코루틴을 이용한 예제로 유사하게 작성해 보면 다음과 같습니다.
// Kotlin
fun main(args: Array<String>) = runBlocking {
val userInputFlow = standardInputFlow().flowOn(Dispatchers.IO)
userInputFlow.collect { line ->
println(line)
}
}
/**
* Read lines from stdin and emit each line to the collector.
*/
fun standardInputFlow(): Flow<String> = flow {
while(true) {
readlnOrNull()?.let { line ->
emit(line)
} ?: break
}
}
Kotlin에서는 비동기 스트림 처리를 위해 Flow
를 사용합니다. 위 예제에서 정의한 standardInputFlow()
함수는 반환 타입으로 Flow<String>을 정의하고 있습니다. 이것은 이 Flow를 수집(collect) 하는 쪽에서 수집을 중단하거나 해당 스트림이 종료되기 전까지는 지속적으로 스트림으로 전달되는 String 타입의 데이터를 전달받게 되는 것을 의미합니다.
코루틴에서는 이러한 Flow를 생성하기 위한 다양한 빌더들이 제공되는데 기본적으로는 앞선 예제처럼 flow { }
빌더를 이용할 수 있습니다. 빌더로 전달된 함수 내부에서 전달하고자 하는 데이터가 준비되었을 때 emit()
함수를 이용하여 현재 Flow를 수집하고 있는 곳으로 데이터를 전달할 수 있습니다.
예제에서 Flow를 수집하는 부분에 flowOn(Dispatchers.IO)
라는 구문을 볼 수 있는데 이것은 Flow 수집기(Collector)가 수집을 시작할 때 전달된 Dispatcher를 통해 수집을 시작하도록 설정합니다. Dispatchers.IO는 Kotlin 코루틴에서 제공되는 Built-in Dispatcher로써 I/O 작업을 위한 스레드 풀을 이용하여 코루틴들의 스케줄링을 수행하는 Dispatcher입니다.
수집기가 수집을 시작할 때 비동기 작업을 시작하여 결과 스트림 데이터를 전달하는 Flow를
Cold Flow
라고 하며 위 예제에 사용된 Flow도 Cold Flow입니다.
수집기 수집 여부와 무관하게 스트림 데이터를 전달하며, 2개 이상의 수집기들이 발생하는 데이터를 함께 전달받을 수 있는(Broadcast) Flow를Hot Flow
라고 하며, 코루틴에서 대표적인 구현체로는SharedFlow
와StateFlow
가 있습니다. StateFlow는 SharedFlow와 달리 기본 상태 값(Default State)이 있어 스테이트 머신처럼 초기 상태로부터 상태 변화에 관심이 있을 경우 이용되며, SharedFlow는 초기 상태 없이 수집 후 발생하는 이벤트에만 관심이 있을 경우 사용됩니다.
비동기 작업 병렬 호출
두 개 이상의 비동기 작업들을 호출할 때 다음 예제와 같이 순서대로 await
을 호출하면, 호출 순서대로 이전 비동기 함수가 끝나야 다음 함수가 호출됩니다.
// Swift
let firstPhoto = await downloadPhoto(named: photoNames[0])
let secondPhoto = await downloadPhoto(named: photoNames[1])
let thirdPhoto = await downloadPhoto(named: photoNames[2])
let photos = [firstPhoto, secondPhoto, thirdPhoto]
show(photos)
만약 비동기 함수들 간에 의존성이 없다면 다음과 같이 병렬로 호출될 수 있도록 코드를 작성하면 전체 작업 시간을 단축시킬 수 있습니다.
// Swift
async let firstPhoto = downloadPhoto(named: photoNames[0])
async let secondPhoto = downloadPhoto(named: photoNames[1])
async let thirdPhoto = downloadPhoto(named: photoNames[2])
let photos = await [firstPhoto, secondPhoto, thirdPhoto]
show(photos)
Swift에서 비동기 함수를 호출할 때 async let
을 이용하면 추가로 비동기 서브 태스크를 만들어 실행하고, 이후에 실행 결과를 await
으로 받을 수 있습니다.
Kotlin에서도 다음과 같이 중단 함수를 순서대로 호출하면 예상한 것처럼 하나씩 순차적으로 수행됩니다.
// Kotlin
// downloadPhoto() is suspending function.
val fistPhoto = downloadPhoto(named: photoNames[0])
val secondPhoto = downloadPhoto(named: photoNames[1])
val thirdPhoto = downloadPhoto(named: photoNames[2])
val photos = arrayOf(firstPhoto, secodePhoto, thirdPhoto)
show(photos)
물론 각각의 중단 함수(downloadPhoto
)를 async { }
코루틴 빌더를 이용하여 코루틴으로 만들고, 다음과 같이 await()
함수를 호출하여 결괏값을 확인하도록 만들면 앞선 Swift 예제와 좀 더 유사한 예제를 만들 수 있습니다.
// Kotlin
val firstPhoto = async { downloadPhoto(named: photoNames[0]) }.await()
val secondPhoto = async { downloadPhoto(named: photoNames[1]) }.await()
val thirdPhoto = async { downloadPhoto(named: photoNames[2]) }.await()
val photos = arrayOf(firstPhoto, secodePhoto, thirdPhoto)
show(photos)
위 예제 코드에서 비동기 함수들을 병렬로 실행하려면 다음과 같이 await()
중단 함수의 호출 시점만 조정해 주면 됩니다.
// Kotlin
val firstPhoto = async { downloadPhoto(named: photoNames[0]) }
val secondPhoto = async { downloadPhoto(named: photoNames[1]) }
val thirdPhoto = async { downloadPhoto(named: photoNames[2]) }
val photos = arrayOf(firstPhoto.await(), secodePhoto.await(), thirdPhoto.await())
show(photos)
구조화 된 동시성 (Structured Concurrency)
구조화된 동시성이란 비동기로 실행되는 작업들 간에 부모-자식 계층 구조가 형성되도록 만들어 계층 구조 내의 비동기 작업들이 유기적으로 동작할 수 있도록 구성하는 것을 말합니다. 이렇게 비동기 작업들 간 관계를 정의하면 계층 내 특정 작업 그룹을 취소하거나 혹은 전체 작업들을 일괄적으로 취소하는 것이 용이하며, 특정 작업에서 발생한 예외를 관련된 하위 작업들로 전파하여 필요한 예외 처리 및 취소 절차가 진행되도록 하는 것도 편리합니다.
Swift에서는 비동기로 실행될 수 있는 작업의 최소 단위로 Task
를 사용하고 있으며, 앞선 예제에서 보았던 비동기 함수를 병렬로 호출하기 위해서 사용했던 async-let
도 호출 Task를 부모로 하는 자식 Task들을 생성하여 실행합니다. Task는 task group
으로 묶여 관리되며 동일한 task group 내의 task들은 동일한 parent task를 갖습니다.
다음은 비동기 작업들을 하나의 task group으로 실행하는 Swift 예제입니다.
//Swift
await withTaskGroup(of: Data.self) { taskGroup in
let photoNames = await listPhotos(inGallery: "Summer Vacation")
for name in photoNames {
taskGroup.addTask { await downloadPhoto(named: name) }
}
}
withTaskGroup()
(or withThrowingTaskGroup()
) 함수를 통해 비동기로 수행할 작업 그룹(task group)을 생성할 수 있고, 전달된 함수 블록은 작업 그룹의 기본 task로 실행됩니다. 이 작업 그룹의 기본 task에서 비동기 함수인 listPhotos()
함수를 호출 후 결괏값을 반환 받기 위해서 await을 이용하여 호출하면 해당 작업 그룹 task는 중단(suspended) 상태로 들어가고, listPhotos() 함수의 결과 데이터가 준비되면, 작업 그룹 기본 task는 전달 받은 결과 데이터를 photoNames
지역 변수에 할당하고 다시 재개(resumed) 되어 for 반복문으로 진입합니다.
for 반복문 안에서는 블록으로 전달 된 taskGroup
에 addTask()
함수를 호출하면서 전달 한 함수 블록 내부에서 await downloadPhoto()
중단 함수를 호출하는데, 이 함수 블록은 현재 작업 그룹 기본 task의 자식 task에서 실행될 중단 함수 블록입니다.
Task Group
- Task
— await listPhotos (completed)
— Task
— — await downloadPhoto
— Task
— — await downloadPhoto
— …
비동기 작업의 최소 단위로 Swift에서는 Task
를 사용한다면, Kotlin에서는 Coroutine
을 사용합니다. 코루틴도 내부적으로 필요한 만큼 중단 함수를 호출할 수 있고 중단 함수를 호출하는 곳은 중단점이 됩니다. 또한 서로 의존 관계가 있는 코루틴들 간에 부모-자식 관계를 맺어 계층 구조를 형성합니다. 이렇게 형성 된 계층 구조 내에서 특정 그룹이나 전체 계층을 대상으로 취소 혹은 예외를 전파할 수 있다는 점도 동일합니다. 앞선 Swift 예제를 Kotlin으로 나타내보면 다음과 같습니다.
// Kotlin
suspend fun withChildCoroutine() = coroutineScope {
val photoNames = listPhotos(inGallery= "Summer Vacation") // suspending function
val storedPaths = photoNames
.map { name ->
async {
downloadPhoto(named= name) // suspending function
}
}
.awaitAll()
}
위 함수는 coroutineScope { }
코루틴 빌더를 이용하여 내부에서 실행될 비동기 함수들을 그룹화하고 이습니다. 이 역시 코루틴 빌더이기 때문에 내부적으로 코루틴을 생성하여 전달된 함수 블록을 실행합니다. 앞서 Swift에서 살펴 본 task group의 개념과 유사합니다. 먼저 listPhotos()
중단 함수를 이용하여 “Summer Vacation” 사진첩의 사진 이름 목록을 가져옵니다. 이후 결과 컬렉션에 map { } 을 이용하여 각 사진 이름을 async { }
코루틴 빌더로 변환하는데, 이 코루틴은 사진을 다운로드하는 downloadPhoto()
중단 함수를 호출 해 사진 이미지를 다운로드하는 코루틴이며, async 코루틴이므로 반환 타입은 Deferred<String>입니다. Deferred는 비동기로 실행 중인 작업의 핸들이며 await() 호출을 통해 결괏값을 받거나, 작업 취소 등의 요청을 할 수 있습니다.
이후, map으로 변환 된 비동기 작업 핸들 리스트인 [Deferred<String>, …] 에 awaitAll()을 호출하여 모든 downloadPhoto() 중단함수의 결과를 기다립니다.
- 만약 awaitAll()을 호출하지 않으면 coroutineScope { } 코루틴 빌더로 생성한 코루틴은 바로 종료될까요? asyc { } 코루틴 빌더를 통해 생성된 코루틴은 부모 코루틴에 자식으로 등록된 후 바로 실행되기 때문에 부모 코루틴은 모든 자식 코루틴이 종료되기 전까지 종료를 대기합니다. 즉 awaitAll()은 호출부에서 모든 코루틴의 결과를 받아보기 위해 사용할 것일 뿐 호출하지 않아도 coroutineScope는 자식 코루틴들이 모두 완료된 후에 종료됩니다.
- coroutineScope { } 대신 supervisorScope { } 를 이용하여 예외가 부모 코루틴으로 전파되지 않도록 할 수 있습니다.
- 만약 async(start = CoroutineStart.LAZY) { … } 와 같이 async { } 코루틴 빌더 사용 시 start 파라미터로 LAZY를 주면 실질적으로 값을 사용하기 전까지 (ex> await()) 비동기 함수를 실행하지 않기 때문에 coroutineScope는 바로 종료됩니다.
구조화 되지 않은 동시성 (Unstructured Concurrency)
Swift에서는 Task 간의 계층 구조를 활용한 구조화된 동시성을 지원하지만, 부모가 없는 Task를 생성하여 특정 스코프에 묶이지 않는 비동기 작업을 실행하는 구조화되지 않은 동시성 모델도 지원합니다. 이를 활용하면 비동기 컨텍스트가 아닌 곳에서도 비동기 작업을 시작할 수 있으며 보다 자유롭게 Task를 다룰 수 있게 되지만, 여러 가지 예외 상황에서도 Task가 올바르게 동작할 수 있도록 관리하는 것도 개발자의 몫입니다(커다란 자유에는 더 커다란 책임이.. 😏).
이러한 Task는 다음과 같은 방식으로 생성할 수 있습니다.
Task.init(priority:operation:)
생성자를 통해 현재 Actor에 속한(Attached) Unstructured Task를 생성Task.detached(priority:operation:)
클래스 메서드(static method)를 통해서 현재 Actor에 속하지 않은(Detached) Unstructured Task를 생성
(현재 Actor가 갖는 스레드 타입이나 우선순위 등의 속성을 상속하지 않음)
Actor에 대한 자세한 내용은 잠시 후에 다룹니다.
// Swift
let newPhoto = // ... some photo data ...
let handle = Task {
return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value
Kotlin에서는 다음과 같이 GlobalScope
에 정의된 프로세스 전역 스코프를 이용하는 코루틴 빌더를 통해 코루틴을 실행하여 비슷한 효과를 볼 수 있습니다 (권장되지 않음).
// Kotlin
val newPhoto = // ... some photo data ...
val handle = GlobalScope.async {
add(newPhoto, toGalleryNamed= "Spring Adventures")
}
val result = handle.await()
비동기 작업의 취소
Swift 동시성 모델은 상호 협력적인 취소 모델(Cooperative Cancellation Model)을 지원합니다. 각 비동기 작업(Task)들은 필요한 시점마다 작업이 취소되었는지 확인하고, 취소된 경우 작업 목적에 따라 다음 중 적절한 처리를 수행하는 것을 권고합니다.
- CancellationError 예외 던지기
- nil 또는 빈 컬렉션 반환
- 부분적으로 완료된 결과물(partially completed work) 반환
비동기 작업 내부에서 가능한 시점마다 현재 작업의 취소 여부를 적극적으로 확인하여 상호 협력적인 취소 모델을 잘 준수하는 비동기 작업을 만들 수 있습니다. 이를 위한 작업의 취소 확인 함수로는 다음과 같은 것들이 있습니다.
Task.checkCancellation()
작업이 취소된 상태인 경우CancellationError
가 발생.Task.isCancelled
속성
예외 발생없이 단순히 상태 체크 후 원하는 취소 처리.Task.cancel()
작업을 취소하고, 관련된 작업들로 취소 상태를 전파.
Kotlin의 경우에도 Swift와 동일하게 상호 협력적인 취소 모델을 지원하기 때문에 비동기 작업의 취소 여부를 체크하고, 작업이 취소되었다면 적절한 예외를 발생하거나 기본 값을 반환하는 등의 처리를 할 수 있습니다. Kotlin에서는 상호 협력적인 취소를 지원하기 위해서 다음과 같은 함수들을 제공합니다.
yield()
를 비롯한 모든 중단 함수 호
현재 비동기 작업이 취소된 상태라면CancellationException
을 발생- Coroutine의
isActive
속성
예외 발생없이 단순히 상태 체크 후 원하는 취소 처리. - Job.cancel() / CoroutineScope.cancel()
코루틴 실행 시 반환 받은 Job이나 코루틴 스코프의 cancel() 함수를 호출하여 현재 Active 상태인 코루틴을 취소하고, 관련 작업들로 취소 상태 전파.
yield() 함수는 스케줄러에 대기 중인 다른 코루틴으로 실행 기회를 넘기는 중단 함수입니다.
Actor
동시성 프로그래밍을 할 때 각각의 비동기 작업은 서로 격리되어 수행됩니다. 하지만 때로는 비동기 작업 간에 데이터를 공유해야 할 필요가 있습니다. 이럴 때 안전한 데이터 교환을 위해 Actor가 사용됩니다. 동시성 프로그래밍 세상에서 Actor는 공유 가능한 상태들을 갖는 하나의 독립된 태스크로써 이 상태 값들은 Actor로 격리되며 private 합니다. 이 상태 값들을 확인하거나 변경하고 싶은 태스크는 Actor에서 제공하는 메시지 채널을 통해 이러한 동작들을 요청할 수 있습니다. 이러한 특징들로 인해서 Actor 모델을 사용하면 동시성 프로그래밍에서 Lock 기반의 동기화 코드를 제거할 수 있습니다.
Swift에서는 이러한 actor를 다음과 같이 정의할 수 있습니다.
Swift Async-Await은 안전한 동시성 제어를 위해 Actor를 적극 활용하고 있습니다.
// Swift
actor TemperatureLogger {
let label: String
var measurements: [Int]
private(set) var max: Int
init(label: String, measurement: Int) {
self.label = label
self.measurements = [measurement]
self.max = measurement
}
}
Swift에서 actor
는 참조 타입이며 actor가 소유한 상태 값 중 변경 가능한 속성들(Mutable Properties)은 한 번에 하나의 태스크만 접근할 수 있습니다. 위 예제는 actor 내부에 다양한 타입의 상태 정의를 보여주기 위해 만들어졌으며, label
은 불변(Immutable) 속성이므로 여러 태스크들이 동시 접근할 수 있고, measurements
속성은 변경 가능(Mutable) 한 속성이므로 한 번에 하나의 태스크만 접근 가능합니다. max
도 변경 가능한 속성이므로 한 번에 하나의 태스크만 접근 가능하며, 추가로 private 이므로 actor 인스턴스 내부에서만 변경 가능합니다.
이렇게 생성한 actor는 일반적인 구조체나 클래스 인스턴스를 생성하듯이 다음과 같이 생성하고 이용할 수 있습니다.
// Swift
let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"
생성된 TemperatureLogger actor의 변경 가능한 상태인 max
속성 값에 접근 시에는 await
을 이용합니다. 생각해 보면 당연한 것이 앞서 언급한 것처럼 하나의 actor 인스턴스 내에 정의된 변경 가능한 속성 값들은 한 번에 하나의 태스크만 접근할 수 있기 때문에 중단점이 되기 때문입니다 (actor 외부에서 바라본 해당 속성의 Getter는 get() async 입니다). 하지만 이러한 변경 가능한 상태들도 actor 내부에서는 다음 예제 코드와 같이 await 없이 접근할 수 있습니다.
// Swift
extension TemperatureLogger {
func update(with measurement: Int) {
measurements.append(measurement)
if measurement > max {
max = measurement
}
}
}
만약 앞서 살펴보았던 여러가지 상태 속성들이 actor 내부에서 관리되는 것이 아닌 일반 인스턴스 내부 속성 값 들이었다면, 여러 작업에서 동시에 접근할 경우 특별한 동기화 처리를 하지 않으면 다음과 같은 이슈가 발생할 수 있습니다.
- 임의의 태스크에서 인스턴스의 `update(with:)` 메서드 호출
- 호출된 update(with:) 메서드는 measurements에 요청된 measurement를 추가까지만 완료 (아직 max 값 변경 전)
- 또 다른 태스크에서 인스턴스의 measurements와 max 값 확인
위와 같은 시나리오에서 마지막 과정(3번)에 인스턴스의 measurements
와 max
값을 확인 한 태스크는 변경의 중간 상태인 잘못된 데이터를 얻게 됩니다.
하지만 actor 인스턴스는 외부에서 변경 가능한 데이터 접근 시 반드시 await
을 사용해야 하고, 내부에서는 그렇지 않기 때문에 이러한 오류를 발생시키지 않습니다. 내부에서 상태 업데이트는 중단점이 없어 한 번에 수행되기 때문입니다.
Kotlin에서 actor는 코루틴으로 생성되며 해당 코루틴 내부로 격리된 상태 값들과 해당 상태 값들을 다른 코루틴들과 교환하기 위한 송/수신 채널로 이루어져 있습니다.
코루틴 공식 사이트의 actor 예제를 살펴봅시다.
// Kotlin
// Message types for counterActor
sealed class CounterMsg
// one-way message to increment counter
object IncCounter : CounterMsg()
// a request with reply
class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg()
앞서 코루틴 actor는 내부 상태의 변경 및 조회를 메시지의 송/수신을 통해 처리한다고 하였습니다. 따라서 actor를 사용하기에 앞서 위 예제처럼 actor가 처리할 메시지를 정의합니다.
Kotlin Sealed class는 컴파일 타임에 정의된 Sealed class의 상속 계층 구조를 제한하여 일반적인 상속 구현보다 더 많은 기능을 제공하는 클래스 타입입니다. 대표적으로 상속 계층 구조가 제한되어 있기 때문에 런타임에서 프로그램은 CounterMsg 클래스의 직속 상속 클래스는 `IncCounter`, `GetCounter` 두 가지 타입만이 존재함을 보장할 수 있습니다(만약 타입이 추가되었는데 when 에 구현이 없다면 컴파일 오류 발생).
이렇게 정의된 두 클래스는 예제에서 actor로 전달되는 메시지로 사용됩니다.
// Kotlin
fun CoroutineScope.counterActor() = actor<CounterMsg> {
var counter = 0 // actor state
for (msg in channel) { // iterate over incoming messages
when (msg) {
is IncCounter -> counter++
is GetCounter -> msg.response.complete(counter)
}
}
}
위 예제에서는 actor { }
코루틴 빌더를 이용하여 actor 코루틴을 생성합니다. actor 코루틴 빌더로 actor 생성 시 반환 값으로는 actor로 메시지를 전달할 수 있는 SendChannel
을 반환하고, actor 내부에서는 외부로부터 메시지를 수신 받을 수 있는 ReceiveChannel
을 이용할 수 있습니다. 위 코드에서 for-in loop에서 순회하고 있는 channel
이 actor의 ReceiveChannel입니다.
예제에서는 actor { } 코루틴 빌더 생성 시 타입을 CounterMsg
로 설정했기 때문에 앞서 정의 한 두 개의 메시지 IncCounter
와 GetCounter
만 송/수신 할 수 있습니다. 이렇게 채널(큐) 방식을 이용하기 때문에 actor 내부에서는 동기화 이슈를 걱정할 필요 없이 변경 가능한 상태들을 안전하게 변경할 수 있습니다 (한 번에 하나의 태스크만 상태 접근 가능).
이제 이 counterActor를 이용하는 코드를 살펴봅시다.
fun main() = runBlocking<Unit> {
val counter = counterActor() // create the actor
withContext(Dispatchers.Default) {
massiveRun {
counter.send(IncCounter)
}
}
// send a message to get a counter value from an actor
val response = CompletableDeferred<Int>()
counter.send(GetCounter(response))
println("Counter = ${response.await()}")
counter.close() // shutdown the actor
}
massiveRun() 함수는 테스트를 위해 작성된 함수로 대량의 코루틴을 생성 및 실행하여 전달 된 중단 함수를 호출하는 코드로, 이번 예제에서는 IncCounter
를 폭발적으로 호출하는 역할을 수행합니다. 앞선 예제의 counterActor() 함수 호출 후 반환받은 counter
는 생성된 actor로 메시지를 보낼 수 있는 SendChannel입니다. 따라서 counter.send(IncCounter)
와 같이 actor로 데이터를 송신할 수 있습니다.
actor로 전달할 메시지 정의 시 GetCounter
메시지도 정의하였는데, 이 클래스는 CompletableDeferred<Int>가 멤버 속성으로 정의되어 있습니다. 이것은 actor 내부에서 메시지를 송신 한 외부로 값을 전달할 때 사용하는 패턴입니다. actor 내부에서 메시지가 처리될 때 해당 호출해 줄 수 있도록 메시지를 전달하는 쪽에서 Deferred를 생성해서 전달하고, 전달 한 Deferred에 await()
함수 호출을 통해 actor로부터 결괏값을 전달받을 때 까지 대기합니다.
actor 내부적으로 채널을 이용하여 동작하므로 GetCounter 메시지가 언제 도착해서 actor로부터 응답을 받을지 알 수 없으므로 이 부분 역시 중단점이 되고, 그렇기 때문에 actor 메시지 객체의 멤버 속성으로 Deferred(CompletableDeferred) 타입이 사용됩니다.
Dispatchers
비동기 작업(Swift의 Task, Kotlin의 Coroutine)은 결국 적절한 스레드에서 스케줄링 되어 실행되어야 합니다. UI 애플리케이션 개발 시 가장 흔한 스레드 스위칭 패턴은
UI Thread -> BG(Backgounrd) Thread -> UI Thread
입니다. 예를 들어 사용자가 데이터 갱신을 요청하는 제스처를 수행하면 이는 데이터 갱신을 담당하는 객체로 전달되고, 해당 객체는 BG Thread로 전환하여 네트워크를 통한 데이터 수신 및 필요 시 로컬 DB 동기화를 수행하고 최종 결과를 반환하면, 다시 UI Thread로 전환하여 결과 데이터를 UI View에 렌더링 합니다.
이렇게 실제 데이터 동기화 부분을 BG Thread에서 수행하는 이유는 사용자의 터치나 제스처 등의 입력 컨트롤, UI 뷰 렌더링 등 사용자와 밀접한 다양한 동작을 수행 중인 UI Thread를 최대한 본래 목적에 맞는 기능들만 수행하도록 하여 보다 높은 응답성과 부드러운 뷰 렌더링이 가능하도록 하기 위함입니다.
Swift Async-Await에서는 이러한 UI -> BG -> UI 스레드 전환을 어떻게 구현할까요? Swift에서 ViewController나 View 등의 UI 요소들은 이미 @MainActor를 사용하도록 정의되어 있습니다.
@MainActor는 앞서 설명한 actor의 일종으로 Swift에서 Built-In으로 제공하는 global actor 중 하나이며, UI(Main) Thread에서 동작하는 actor입니다.
여기서는 임의의 ViewModel을 @MainActor로 만들어 비동기 데이터 동기화 처리를 하는 코드를 작성해 보겠습니다.
@MainActor
class MyViewModel {
let state: State
let apiClient: APIClient
init(state: State, apiClient: APIClient) {
self.state = state
self.apiClient = apiClient
}
func syncData() async {
let result = try await apiClient.getData()
state.data = result
}
}
actor APIClient {
fun getData() async -> Data {
// request data using REST API
}
}
위 예제에서 syncData()
는 사용자의 데이터 동기화 요청 액션이 발생하면 UI Controller에서 호출하는 비동기 함수입니다. MyViewModel 클래스는 @MainActor로 마킹되어 있기 때문에 내부 속성들이나 함수들을 모두 Main(UI) Thread에서 호출되도록 강제됩니다 (Isolated).
APIClient는 actor로 정의되었기 때문에 apiClient.getData()
비동기 함수는 백그라운드 스레드에서 스케줄링 되어 서버 데이터를 가져오는 비동기 함수입니다. 결과 Data를 수신하면 State.data에 반영함으로써 UI를 갱신합니다 (State.data = main thread에서만 접근 가능).
이렇게 하나의 비동기 작업을 Actor를 전환하며 수행하는 것을
actor hopping
이라고 합니다. 스레드 전환이 필요한 actor 간 너무 빈번한 actor hopping은 성능 이슈를 발생시킬 수 있습니다. 예를 들어 Main Actor <-> BG Actor 간 짧은 시간에 빈번하게 데이터를 전달하는 경우를 생각해 볼 수 있는데, 이 경우 일정량을 묶어 전달하는 Batch 처리가 해법이 될 수 있습니다.
위 예제 코드를 Kotlin 으로 유사하게 작성해보면 다음과 같습니다.
class MyViewModel(
private val apiClient: APIClient
) : ViewModel() {
private val _state: MutableStateFlow<Data?> = MutableStateFlow(Data.EMPTY)
val state = _state.asStateFlow()
fun syncData() = viewModelScope.launch {
val result = runCatching {
apiClient.getData()
}.getOrElse { Data.EMPTY }
_state.tryEmit(result)
}
}
class APIClient {
suspend fun getData(): Data = withContext(Dispatchers.IO) {
TODO("request data using REST API")
}
}
먼저 MyViewModel은 ViewModel이라는 클래스를 상속받고 있는데, ViewModel클래스는 viewModelScope라는 코루틴 스코프를 제공합니다. viewModelScope는 SupervisorJob() + Dispatchers.Main.immediate
조합의 코루틴 컨텍스트로 이루어진 코루틴 스코프입니다.
- SupervisorJob()
코루틴은 구조화된 동시성 지원을 위해 코루틴 간 계층 구조를 이루는데 이 계층구조에 실질적으로 참여하는 것은 각 코루틴의 Job입니다. 일반적인 Job은 자식 Job의 예외를 부모로 전달해 부모를 취소시키는 것과 달리 SupervisorJob은 자식 Job의 예외를 부모로 전달하지 않습니다. - Dispatchers.Main.immediate
Kotlin Coroutine에서 스레드는 Dispatcher로 조정합니다.
Built-in Dispatcher로는
1) Dispatchers.Main (UI Thread 작업)
2) Dispatchers.IO (DB나 Network 등의 I/O 작업)
3) Dispatchers.Default (CPU 사용량이 높은 작업)
4) Dispatchers.unconfined (특정 스레드에 국한되지 않는 작업)
있습니다.
결국 위 예제에서 viewModelScope.launch { } 코루틴 빌더를 통해 생성된 코루틴은 예외 발생을 부모로 전파하지 않으며, UI Thread에서 실행됩니다.
.immediate
는 최적화를 위한 옵션으로 호출 당시 이미 UI Thread라면 Dispatch 없이 바로 실행됩니다.
코루틴 내부에서는 APIClient.getData() 중단 함수를 호출하는데 중단 함수 구현을 보면 withContext(Dispatchers.IO) { }
로 컨텍스트 전환을 합니다. 이 경우 전달된 컨텍스트(Dispatchers.IO)를 이용한 코루틴을 생성하여 작업을 수행한 뒤 결과를 호출 코루틴에 돌려줍니다. 이 과정은 다음과 같습니다.
- Main Thread에서 코루틴을 생성하여 apiClient.getData() 호출
- IO Thread Pool에서 Idle 상태인 스레드를 선정하여 코루틴 전환
- IO Thread에서 코루틴은 API를 호출하고 그 결과 반환
- Main Thread에서 중단 상태로 결과를 대기중인 코루틴은 재개되어 안정적으로 state에 결괏값을 설정
- state를 수집(collect) 중인 UI View 갱신
From Callback to Async
Callback (Completion) 기반의 레거시 코드에서 비동기 함수 기반으로 전환하다 보면 Callback API를 Async-Await으로 변환해야 할 경우가 있습니다. 이 경우 다음과 같이 withCheckedContinuation 또는 withCheckedThrowingContinuation
을 사용할 수 있습니다. 이때 전달되는 continuation은 해당 비동기 함수가 중단된 시점에서 비동기 함수의 결과나 오류를 전달하여 비동기 함수를 재개 시킬 수 있는 핸들러입니다.
// Swift
func doWork(_ requestData: RequestData) async {
return await withCheckedThrowingContinuation({ continuation in
self.doCallbackBasedAnotherWork { result, error in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: result)
}
}
})
}
Kotlin 코루틴에서도 Callback 기반의 레거시 코드를 변환 해야 하는 동일한 상황이 발생할 수 있고(대표적으로 프레임워크 API), 이 경우 다음과 같이 suspendCancellableCoroutine { }
코루틴 빌더를 사용할 수 있습니다.
suspend fun doWork(requestData: RequestData)
= suspendCancellableCoroutine<Bundle> { continuation ->
doCallbackBasedAnotherWork { result ->
if (result.isSucceeded) {
continuation.resume(result.value)
} else {
continuation.resumeWithException(result.error)
}
}
}
Swift와 Kotlin에서 Callback 기반의 API를 브릿지 하기 위한 함수가 비록 이름은 다르지만 기반 기술이 동일하기 때문에 continuation이라는 동일한 개념을 가져가고 있음을 알 수 있습니다.
지금까지 Swift Async-Await과 Kotlin Coroutine를 간단히 비교해 보았습니다. 포스팅에서 언급한 내용 외에도 Swift의 Concurrency domain 간에 전달하는 데이터는 Sendable 타입이어야 하는 부분이나 @mainActor 와 같은 global scope actor에 대한 부분들이 있는데 이러한 kotlin과 비교하긴 어려운 부분들은 생략되었습니다.
Swift를 다루던 개발자가 Kotlin을 사용해야 할 경우 도움이 되길 바랍니다.
끝.