본 게시물은 Ivan Fytsyk님의 게시글 Asynchronous execution in Kotlin and Swift을 바탕으로 작성되었습니다.
Swift와 Kotlin, 각 언어에서 비동기 실행하기
본 게시글에서는 모바일 앱 개발의 양대 언어인 Swift와 Kotlin에서 비동기 실행을 하는 방법에 대해 정리합니다.
이번 시간에 우리는 Kotlin과 Swift에서 동시성 모델(concurrency model)을 비교하며 설명해 볼 것입니다. 이 지식은 Android 및 iOS 프레임워크를 모두 배워보고자 원하는 분들에게도 유익할 것이고, 이미 양대 플랫폼에서 개발을 해 본 분들에게도 유용할 것입니다. 더 많은 정보는 필자의 저서를 통해 확인할 수 있습니다.
기본 용어들
우리가 다루어보고자 하는 기본 용어들에는 쓰레드(Thread), 오퍼레이션(Operation) 및 큐(Queue)가 있습니다.
쓰레드(Thread)
쓰레드(Thread)는 동시에 어떤 연산을 실행할 목적으로 멀티코어 CPU를 사용하는 저수준의 추상화(abstraction)입니다. 싱글코어 CPU에서는 각 연산들 사이를 빠르게 전환함으로써 병렬 실행을 에뮬레이트(emulate)할 수 있습니다. 오늘날 Kotlin이나 Swift에서는 쓰레드가 직접적으로 사용되는 경우가 흔치 않기 때문에 이 글에서 우리는 쓰레드에 대해서는 다루지 않겠습니다. 새로운 쓰레드를 생성하고 이들을 관리하는 것은 많은 시스템 자원을 요구하기 때문에, 현대적인 개발 환경에서 우리는 일반적으로 미리 정의된 몇몇의 쓰레드 풀(Thread Pool)만을 사용하고 (이를 위해) 오퍼레이션(Operation)과 큐(Queue)를 가지고 작업하게 될 것입니다.
오퍼레이션(Operation)
오퍼레이션(Operation)은 단순히 한 블록의 코드입니다. 일반적으로 클로저(closure)라든가 다른 객체지향(OOP)적 추상화로써 표현됩니다(예를 들어 Swift에서는 DispatchItem
으로 불리고 Kotlin에서는 Task
로 불립니다.
큐(Queue)
큐(Queue)는 오퍼레이션(Operation)들의 선입선출형 콜렉션(collection)입니다. (이 콜렉션에 담긴) 각각의 오퍼레이션들은 순서대로 선택되어 나온 뒤 각기 다른 쓰레드에서 실행될 수 있습니다.
큐에 새로운 오퍼레이션을 추가하는 것은 크게 두 가지 방법으로써 가능합니다. 동기식(sync
)과 비동기식(async
)이 그것입니다. 동기식은 새로 시작된 오퍼레이션이 끝날 때까지 현재의 오퍼레이션(큐에 오퍼레이션을 넣고자 하는 지금의 작업 흐름)이 일시중지될 것임을 의미합니다. 비동기식은 새로운 오퍼레이션이 시작되더라도 현재의 오퍼레이션은 일시중지되지 않고 나머지 작업을 계속 실행될 것임을 의미합니다.
예제 1
설명은 여기까지만 하고, 지금부터는 코드들을 많게 보면서 Kotlin과 Swift에서 위의 개념들이 어떻게 구현되어 있는지를 확인해 보겠습니다. 예제 코드를 실행해보기 위해 Android Studio Kotlin REPL과 Xcode Playground를 사용할 것입니다.
먼저 Kotlin의 코드입니다.
import kotlinx.coroutines.experimental.*
runBlocking {
GlobalScope.async { // Kotlin sample
delay(1000L) // 1 second suspending
println("async call")
}
delay(2000L)
}
다음은 Swift의 코드입니다.
DispatchQueue.global().async { // Swift sample
sleep(1) // 1 second suspending
print("async call")
}
여기서 어떤 일들이 벌어지는지 설명해보겠습니다. 먼저 Kotlin 측 코드를 보면 runBlocking { ... }
이라는 부분이 보입니다. 이것은 블로킹 함수(blocking function)으로서 REPL의 메인 쓰레드(main threa)가 종료되기 전에 비동기 호출의 결과를 우리가 볼 수 있도록 해 줍니다. 이것은 단지 우리가 코드의 실행 결과를 보는 데 도움을 주기 위해 도입된 구조일 뿐입니다. Xcode Playground는 이와 유사한 구조가 없어도 작동이 됩니다.
예제 2
그 다음 우리가 관심을 두어야 할 것은 GlobalScope
와 DispatchQueue.global()
입니다. 이들은 "미리 정의되어 있고 현재 작동 중인 쓰레드" 및 필자가 앞서 언급했던 오퍼레이션 큐(operation queue)를 캡슐화하고 있습니다. 대부분의 경우 이것으로 우리가 소스 코드들을 비동기식으로 실행하는 데 충분합니다. 보다 특수한 목적을 위해 우리는 직접 사용자 정의 큐를 만들 수도 있습니다.
백그라운드에서 실행이 오래 걸리는 작업들이 완료되고 난 뒤, 보통 우리는 UI 쓰레드에게 이 실행 결과를 전달하기도 합니다. Kotlin에서는 이것이 어떻게 이루어지는지 보겠습니다.
import kotlinx.coroutines.experimental.*
import kotlinx.coroutines.experimental.android.Main
val job = GlobalScope.async {
delay(1000L) // 1 second suspending
println("async call")
GlobalScope.async(context = Dispatchers.Unconfined) {
// 실제 Android 어플리케이션에서는 Dispatchers.Main으로 대체합니다.
println("called in UI thread")
}
}
runBlocking {
job.join()
}
Swift에서는 백그라운드 작업 실행 완료를 UI 쓰레드에게 어떻게 통지하는지 보겠습니다.
DispatchQueue.global().async {
sleep(1) // 1 second suspending
print("async call")
DispatchQueue.main.async {
print("call in UI thread")
}
}
여기서 우리는 비동기 블록 실행이 완료되는 것을 기다리기 위하여 job.join()
을 사용하여 UI 쓰레드를 대기시켰습니다. 앞서 적었던 예제에서 delay(2000L)
을 호출했던 것을 대체한 것에 지나지 않습니다.
비동기 코드 내에서도 다시 일부 코드 블록을 UI 쓰레드에서 실행하기 위하여 우리는,
Kotlin에서는 GlobalScope.async(context = Dispatchers.Unconfined) { ... }
이라는 미리 정의된 메인 큐(main queue)가 동반된 생성자를 사용하였고,
Swift에서는 DispatchQueue.main.async { ... }
이라는 호출을 사용하였습니다.
여기까지가 모바일 개발에서 사용되는 동시성 구현의 가장 쉬운 예였습니다. 이런 구조라면 백그라운드에서는 무거운 연산이나 데이터 처리를 수행하고 그 결과만 UI 쓰레드에 전달하면 되겠지요. 다음 장에서 우리는 오퍼레이션과 큐를 가지고 할 수 있는 작업들을 해 보고, 우리가 직접 큐를 만들어 보기도 할 것이며 교착상태(deadlock)나 경쟁(race)과 같은 동시성 문제를 어떻게 해결할 것인지에 대해 살펴보겠습니다.