[번역: Kotlin Coroutines: Deep Dive] (2) 코틀린 코루틴에서 일시 정지 작업을 하는 방법

이 포스팅은 Kotlin Coroutines: Deep Dive의 글을 번역한 것입니다. 오역, 많은 의역, 생략이 있으니 감안하여 읽어주십시오.

하나의 코루틴을 일시정지하는 것은 중간에 멈추는 것을 의미한다. 이건 세이브한 게임을 멈추고 당신이 다른 일에 집중할 수 있는 상태와 유사하다. 그리고 후에 언제든 세이브포인트에서 게임을 다시 시작할 수 있다. 코루틴은 일시정지하면 Continuation을 반환한다. 이를 사용하여 멈춘 지점에서 다시 시작할 수 있다.

이는 save가 아니라 block하는 스레드와는 매우 다르다는 사실에 주의하라. 코루틴은 매우 강력하다. 일시정지했을 때 어떤 리소스도 사용하지 않는다. 코루틴은 다른 스레드를 다시 시작할 수 있다. (적어도 이론 상으로는) 하나의 continuation은 직렬화, 비직렬화되어있을 수 있고 다시 시작될 수도 있다.

다시 시작

1
2
3
4
5
suspend fun main() {
println("Before")
suspendCoroutine<Unit> { }
println("After")
}

위 코드를 실행하면 “After” 글자를 볼 수 없을 것이다. 그리고 (main 함수는 절대 끝나지 않기 때문에) 코드가 실행을 멈춘 것도 아니다. 우리는 게임을 멈췄지만 다시 시작하지는 않았다. 그렇다면 어떻게 다시 시작할 것인가? 앞서 언급한 Continuation는 어디에 있는가?

suspendCoroutine 블록의 람다식을 다시 보자. argument를 받는 이 함수는 일시정지하기 전에 불리워진다. 그때의 매개변수가 continuation이다.

1
2
3
4
5
6
7
suspend fun main() {
println("Before")
suspendCoroutine<Unit> { continuation ->
println("Before too")
}
println("After")
}

같은 곳에서 다른 함수를 부르는 이러한 함수는 전혀 새로운 것이 아니다. 이는 let이나 apply, useLines와 유사하다. suspendCoroutine 함수는 이와 같은 방식으로 디자인되었다. 즉 일시정지하기 전에 continuation을 사용할 수 있도록 하는 것이다. suspendCoroutine이 호출된 후면 너무 늦을 것이다. 그래서 람다식을 통해 그 전에 부를 수 있도록 한다. 람다는 continuation을 어디서든 저장하거나 이를 다시시작할지 여부를 계획하기도 한다.

즉시 다시 시작하려면 이렿게하면 된다.

1
2
3
4
5
6
7
8
9
suspend fun main() {
println("Before")
suspendCoroutine<Unit> { continuation ->
continuation.resume(Unit)
}
println("After")
}
// Before
// After

위에서 “After”가 suspendCoroutine 안에서 resume을 호출했기 때문에 출력되었음에 주의하라.

Kotlin 1.3 이후로 Continuation 정의가 바뀌었다. resume과 resumeWithException 대신 하나의 결과값이 있는 resumeWith 함수가 있다. 우리가 사용하고 있는 resume과 resumeWithException 함수는 resumeWith를 사용한 표준 라이브러리에 있는 extension 함수이다.

1
2
3
4
inline fun <T> Continuation<T>.resume(value: T): Unit =
resumeWith(Result.success(value))
inline fun <T> Continuation<T>.resumeWithException(exception: Throwable): Unit =
resumeWith(Result.failure(exception))

설정된 시간동안 sleep할 다른 스레드를 시작할 수도 있다. 이는 언제든지 다시 시작할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
suspend fun main() {
print("Before ")
suspendCoroutine<Unit>{ continuation ->
thread {
print("Suspended ")
Thread.sleep(1000)
continuation.resume(Unit)
print("Resumed ")
}
}
print("After ")
}
// Before Suspended (1 second delay) After Resumed

중요한 부분이다. 정의된 시간이 끝난 후에 continuation을 다시 시작할 함수를 만들 수 있음에 주목하라. 여기서 continuation은 아래 보이는 것처럼 람다식에 의해 좌우된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun continueAfterSecond(continuation: Continuation<Unit>) {
thread {
Thread.sleep(1000)
continuation.resume(Unit)
}
}

suspend fun main() {
print("Before ")
suspendCoroutine<Unit> { continutaion ->
continueAfterSecond(continuation)
}
print("After ")
}
// Before (1 sec) After

이건 오류 없이 작동은 한다. 하지만 단 일 초 동안만 비활성화 상태가 되었다가 끝나는 스레드를 불필요하게 생성한다. 스레드는 비용이 싸지 않은데도 왜 낭비하고 있는가? 더 좋은 방법은 “알람”을 설정하는 것이다. JVM에서 그것을 구현하기 위해 ScheduledExecutorService를 사용했다. 여기서는 정의된 얼마간의 시간 후에 몇 개의 continuation.resume(Unit)을 호출하는 걸로 알람을 설정할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private val executor = 
Executor.newSingleThreadScheduledExecutor {
Thread(it, "scheduler").apply { isDaemon = true }
}

suspend fun main() {
print("Before ")
suspendCoroutine<Unit> { continuation ->
executorschedule({
continuation.resume(Unit)
}, 1000, TimeUnit.MILLISECONDS)
}
print("After ")
}
// Before (1 seconds delay) After

설정한 시간동안 일시정지하는 건 유용한 피처로 보인다. 그러니 이걸 delay라는 함수로 추출해보자.

1
2
3
4
5
6
suspend fun delay(timeMillis: Long): Unit = 
suspendCoroutine { continuation ->
executor.schedule({
continuation.resume(Unit)
}, timeMillis, TimeUnit.MILLISECONDS)
}

executor가 여전히 스레드를 사용하고 있지만 delay 함수를 사용하는 모든 코루틴에 한 개의 스레드만을 할당한다. 이 점은 하나의 스레드를 매번 블록하는 것보다는 낫다.

이는 코틀린 코루틴 라이브러리에서 지원하는 delay의 방식이다. 구현은 더 복잡하지만 필수적인 요소는 이렇다.

어떤 값을 가지고 다시 시작하기

resume 함수에서 Unit을 넘겨주는 거에 대해 걱정할 수도 있다. 그러면서 suspendCoroutine의 매개변수로 Unit을 사용하는 이유가 궁금해질 것이다. 이 두가지가 같다는 사실은 우연이 아니다. Unit은 함수로부터 반환되고, Continuation 타입 파라미터는 제네릭 타입이다.

1
2
3
4
val ret: Unit = 
suspendCoroutine<Unit> { cont: Continuation<Unit> ->
cont.resume(Unit)
}

suspendCoroutine이 호출될 때, 이 continuation에서 반환되는 타입이 어떤 것인지 명확해진다. 그 타입은 resume을 호출할 때 사용되곤 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
suspend fun main() {
val i: Int = suspendCoroutine<Int> { cont ->
cont.resume(42)
}
println(i) // 42

val str: String = suspendCoroutine<String> { cont ->
cont.resume("Some text")
}
println(str) // Some text

val b: Boolean = suspendCoroutine<Boolean> { cont ->
cont.resume(true)
}
println(b)
}

이는 (save-resume) 게임 비유에는 잘 들어맞지 않는다. 저장본을 다시 시작할 때 게임 내에서 어떤 것(값)을 가져올 수 있는 게임을 나는 알지 못한다. 그러나 이 점은 코루틴을 사용하면 완벽하게 이해된다. 종종 우리는 API로부터 네트워크 response를 받는 것처럼 어떤 데이터를 받아오기 위해 대기하곤 한다. 흔한 시나리오다. 스레드는 특정 데이터가 있는 지점에 도달할 때가지 비즈니스 로직을 실행시킨다. 그래서 네트워크 라이브러리에 이를 전달할 것을 요구한다. 코루틴 없이, 이 스레드는 앉아서 기다릴 수밖에 없다. 스레드가 비싼 자원이기 때문에 이는 엄청난 낭비다. 특히 안드로이드의 메인스레드와 같이 중요한 스레드라면 더욱 그렇다. 코루틴을 사용하면 그냥 일시정지 시켰다가, 라이브러리에게 “이 데이터를 받자마자 resume 함수에게 그냥 넘겨”라는 명령과 함께 continuation을 넘기기만 하면 된다. 그러면 스레드는 다른 일을 하러 갈 수 있다. 데이터가 오면 바로 그 스레드는 코루틴이 일시정지한 지점부터 다시 시작할 것이다.

실제로 이를 확인하기 위해 데이터를 수신할 때까지 일시정지하는 방법을 살펴보자. 아래 예제에서는 외부에서 가져온 requestUser 콜백함수를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
suspend fun main() {
println("Before")
val user = suspendCoroutine<User> { cont ->
requestUser { user ->
cont.resume(user)
}
}
println(user)
println("After")
}
// Before
// (1 second delay)
// User(name=Test)
// After

suspendCoroutine을 직접 호출하는 것은 편리하지 않다. 대신 suspend 함수를 만드는 것을 선호한다. 그래서 이를 추출할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
suspend fun requestUser(): User {
return suspendCoroutine<User> { cont ->
requestUser { user ->
cont.resume(user)
}
}
}

suspend fun main() {
println("Before")
val user = requestUser()
println(user)
println("After")
}

현재 suspend 함수는 Retrofit이나 Room과 같은 많은 유명한 라이브러리에서 지원하고 있다. 그물게 suspend 함수 안에 콜백 함수를 쓸 때가 있다. 하지만 그런 경우라면 suspendCoroutine 때신suspendConcellableCoroutine을 사용하는 걸 추천한다. 이는 Cancellation 챕터에서 설명할 것이다.

1
2
3
4
5
6
7
suspend fun requestUser(): User {
return suspendCancellableCoroutine<User> { cont ->
requestUser { user ->
cont.resume(user)
}
}
}

여기서 API가 데이터가 아닌 다른 문제가 발생했을 때는 어떻게 될지 궁금할 수도 있다. 서비스가 죽거나 오류를 수신할까? 위 예제에서는 데이터를 반환하지 않는다. 대신 코루틴이 일시정지한 곳에서 예외가 발생해야 한다. 예외를 받고 다시 시작한다.

예외를 받은 resume

우리가 호출하는 모든 함수는 어떤 값을 반환하거나 예외를 던진다. suspendCoroutine도 같다. resume이 호출되면 매개변수로 데이터를 넘긴다. resumeWithException이 호출되면 매개변수로 전달된 예외가 일시정지한 지점에 개념적으로 던져진다.

1
2
3
4
5
6
7
8
9
10
11
12
class MyException : Throwable("Just an exception")

suspend fun main() {
try {
suspendCoroutine<Unit> { cont ->
cont.resumeWithException(MyException())
}
} catch (e: MyException) {
println("Caught!")
}
}
// Caught!

이 매커니즘은 다른 문제에서도 쓰인다. 한 예로, 네트워크 예외 신호를 보낼 때가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
suspend fun requestUser(): User {
return suspendCancellableCoroutine<User> { cont ->
requestUser { resp ->
if (resp.isSuccessful) {
cont.resume(resp.data)
} else {
val e = ApiException(
resp.code,
resp.message
)
cont.resumeWithException(e)
}
}
}
}

suspend fun requestNews(): News {
return suspendCancellableCoroutine<News> { cont ->
requestNews(
onSuccess = { news -> cont.resume(news) },
onError = { e -> cont.resumeWithException(e) }
)
}
}

함수가 아닌 코루틴을 일시정지하는 것

하나 강조할 것은 함수가 아닌 코루틴을 일시정지 시킨다는 것이다. 함수를 일시정지할 수 있는 것은 코루틴이 아니라 코루틴을 일시정지할 수 있는 함수이다. 어떤 변수에 함수를 저장하는 상상를 해보아라. 그리고 함수 호출 후 다시 시작시켜라.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 정말 이렇게 짜지 마세요
var continuation: Continuation<Unit>? = null

suspend fun suspendAndSetContinuation() {
suspendCancellableCotouine<Unit> { cont ->
continuation = cont
}
}

suspend fun main() {
println("Before")

suspendAndSetContinuation()
continuation?.resume(Unit)

println("After")
}
// Before

이건 좀 이해가 안 된다. 이건 게임을 멈추고 이전 지점에서 재시작하는 프로세스와 동일한데도 resume이 전혀 호출되지 않는다. 출력결과에는 오직 “Before”만 보인다. 그리고 다른 스레드나 코루틴에서 resume을 호출하지 않으면 이 프로그램은 영원히 끝나지 않는다. 이를 위해 1초 후 reusme하는 다른 코루틴을 배치할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 정말 이렇게 짜지 마세요. 잠재적인 메모리 누수의 원인이 됩니다.
var continuation: Continuation<Unit>? = null

suspend fun suspendAndSetContinuation() {
suspendCancellableCotouine<Unit> { cont ->
continuation = cont
}
}

suspend fun main() {
println("Before")

launch {
delay(1000)
continuation?.resume(Unit)
}
suspendAndSetContinuation()
println("After")
}
// Before
// (1 second delay)
// After

[번역: Kotlin Coroutines: Deep Dive] (1) Kotlin Coroutines를 사용하는 이유

이 포스팅은 Kotlin Coroutines: Deep Dive의 글을 번역한 것입니다. 오역, 많은 의역, 생략이 있으니 감안하여 읽어주십시오.

우리가 Kotlin Coroutines을 배우는 이유가 무엇인가? RxJava, Reactor과 같이 이미 잘 만들어진 JVM 라이브러리가 있는데도 말이다. 더욱이 자바는 멀티스레딩을 지원한다. 즉 이미 비동기를 구현하기 위한 다양한 방법이 있다.

Kotlin Coroutines는 그것보다도 더 많은 것을 제공한다. 그 중 하나는 멀티플랫폼을 제공한다는 것이다. 즉슨 코틀린 플랫폼 사이에서 코루틴을 사용할 수 있음을 의미한다.

연습해보자. 코루틴과 다른 방법들 사이에 얼마나 차이가 있는지 보라. 아래에서는 안드로이드와 백엔드 비즈니스 로직의 두 가지 전형적인 예시를 보여줄 것이다.

안드로이드(와 다른 프론트엔드 플랫폼)에서 사용하는 코루틴

앱 로직을 수행할 때 가장 자주 진행되는 과정은 다음과 같다.

  • API, 데이터베이스 등과 같은 소스의 데이터 가져오기
  • 데이터 가공하기
  • 그 데이터로 뷰에 보여주는 등의 작업 수행하기

더 잘 이해하기 위해 앱을 개발하는 중이라고 가정해보자. API를 통해 뉴스 “정보를 받아와서” 이를 “정렬”하고, “화면에 보여주는” 서비스를 만들 것이다. 원하는 기능을 그대로 넣은 게 다음과 같다.

1
2
3
4
5
fun onCreate() {
val news = getNewsFromApi()
val sortedNew = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNew)
}

슬프지만 이대로 끝나선 안 된다. 안드로이드에서 각 애플리케이션은 뷰를 업데이트하는 데에 단 하나의 스레드를 가지고 사용한다. 이 스레드(Main Thread)는 매우 중요하여 절대 block(차단)되어서는 안 된다. 그런데 위 코드는 이 점을 만족시키지 못한다. 만약 메인 스레드에서 실행되었다면 getNewsFromApi 는 이 스레드를 block할 것이고, 앱은 죽을 것이다. 만약 다른 스레드였다면 앱은 showNews가 호출될 때 죽을 것이다. 이 작업은 메인 스레드에서 수행되어야 하는 작업이기 때문이다.

스레드 변경(switching)

이 문제는 스레드를 변경함으로써 해결할 수 있다. 우선 block할 수 있는 스레드에서 메인 스레드로 변경하자.

1
2
3
4
5
6
7
8
9
fun onCreate() {
thread {
val news = getNewsFromApi()
val sortedNew = news.sortedByDescending { it.publishedAt }
runOnUiThread {
view.showNews(sortedNew)
}
}
}

이러한 스레드 변경은 어떤 앱에서는 여전히 사용하는 방식이긴하다. 그러나 이 방식은 몇가지 문제를 안고 있다.

  • 이 스레드들을 cancel할 수 있는 방도가 없어서 종종 메모리 누수(memory leak) 상태에 직면한다.
  • 스레드 비용이 많이 든다.
  • 빈번한 스레드 변경은 혼란스럽고 관리를 어렵게 만든다.
  • 이 코드는 불필요하게 크고 복잡하다.

당신이 뷰를 연 후 빠르게 닫는 걸 상상해보라. 열 때 몇 개의 스레드들이 시작하며 데이터를 처리한다. 뷰를 닫았음에도 존재하지 않는 뷰를 업데이트하기 위해 이 작업을 cancel하지 않고 계속 진행하게 된다. 불필요하다는 것이다.

이러한 문제에 대해 생각해보며 더 좋은 해결책을 보도록 하자.

콜백(Callbacks)

콜백은 이 문제를 해결하는 방법 중 하나이다. 이 방식은 기능이 non-blocking하게 만든다. 그러나 콜백 함수가 끝나야만이 시작할 수 있는 작업을 수행할 때만 적용할 수 있다. 코드에 적용하면 이렇다.

1
2
3
4
5
6
fun onCreate() {
getNewsFromApi { news ->
val sortedNew = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNew)
}
}

여기서 작업을 cancel 할 수 없음에 주목하라. 취소할 수 있는 콜백 함수를 만들 수도 있을 것이다. 그러나 쉽지 않다. cancel을 위해 각 콜백 함수를 커스텀해야 할 뿐만 아니라 cancel을 할 가능성이 있는 모든 객체를 별도로 수집해야 한다.

1
2
3
4
5
6
fun onCreate() {
startedCallbacks += getNewsFromApi { news ->
val sortedNew = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNew)
}
}

이러한 콜백 구조는 단점이 있다. 이걸 이해하기 위해 더 복잡한 예시를 보자. 세 개의 엔드포인트로부터 데이터를 가져오는 경우이다.

1
2
3
4
5
6
7
8
9
fun showNews() {
getConfigFromApi { config ->
getNewsFromApi(config) { news ->
getUserFromApi { user ->
view.showNews(user, news)
}
}
}
}

이 코드는 아래와 같은 이유로 완벽과는 거리가 멀다.

  • news와 user 데이터는 사실 동시에 받아와도 된다. 그런데 지금 콜백 구조로는 그럴 수 없다.
  • 전에 말한대로, 다른 더 많은 데이터를 불러오려고 할 때 cancel할 필요가 있다.
  • 요구하는 게 점점 많아질수록 이 코드는 읽기 어려워질 것이다. 그러한 상황을 콜백 지옥(callback hell)이라고 부른다.
  • 콜백을 사용할 때 작업의 순서를 제어하기 힘들다. 인디케이터를 다음과 같이 사용해도 진행도가 제대로 나타나지 않는다.
    1
    2
    3
    4
    5
    6
    fun onCreate() {
    showProgressBar()
    showNews {
    hideProgressBar()
    }
    }

이게 콜백 구조가 어떤 면에서는 완벽하지 않은 이유이다. 다른 접근방식으로 RxJava를 사용한 예를 보자.

RxJava와 그밖의 reactive streams

Java에서 유명한 이 대체방식은 reactive streams(또는 Reactive Extensions)를 사용한다. 즉, RxJava 또는 successor Reactor를 말한다. 이 방식으로 말할 것 같으면, 모든 동작은 시작하여(started) 진행된(processed) 관찰되어지는(observed) 데이터 스트림(일련의 작업) 내에서 일어난다. 그래서 종종 앱에서는 동시에 진행되곤 한다.

다음 코드가 RxJava를 사용한 문제 해결 방식이다.

1
2
3
4
5
6
7
8
9
10
11
fun onCreate() {
disposables += getNewsFromApi()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.map { news ->
news.sortedByDescending { it. publishedAt }
}
.subscribe { sortedNews ->
view.showNews(sortedNews)
}
}

위 예제에서 disposables은 사용자가 화면을 벗어났을 때 이 스트림을 cancel하기 위한 요소이다.

이는 분명 콜백보다 더 괜찮은 해결책이다. 메모리 누수가 없고, 작업 취소(cancel)를 할 수 있으며 스레드를 사용하는 데도 적절하다. 유일한 문제는 복잡하다는 것이다. 만약 맨처음 코드인 “이상적인” 코드와 비교한다면 공통점이 거의 없음을 알 수 있을 것이다.

subscribeOn, observeOn, map 그리고 subscribe라는 함수들을 모두 알아야지만 코드를 이해할 수 있다. cancelling(cancel할 수 있는 기능)은 분명 필요하다. 함수는 Observable이나 Single 클래스 안에 있는 객체를 반환해야 한다. 실제로 RxJava를 소개할 때 흔히 우리 코드를 아래와 같이 바꾼다.

1
fun getNewsFromApi(): Single<List<News>>

두 번째 문제를 생각해보자. 데이터를 화면에 보여주기 전에 세 개의 엔드포인트를 호출해야 한다. 이건 RxJava에서는 사실 풀 수 있는 문제다. 그러나 코드가 지금보다 훨씬 더 복잡해진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fun showNews() {
disposables += Observable.zip(
getNewsFromApi()
.flatMap { getNewFromApi(it) }
.subscribeOn(Schedulers.io()),
getUserFromApi()
.subscribeOn(Schedulers.io())
) { news: News, user: User ->
Pair(news, user)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (news, user) ->
view.showNews((news, user))
}
}

이 코드는 정말 메모리 누수가 없고, concurrent하다. 그러나 zip, flatMap과 같은 RxJava 함수를 넣어야 하고, Pair로 값을 묶어야 하며, 이 구조를 해제해야 한다. 이 옳은 구현은 정말 복잡하다. 그래서 이번에야말로 코루틴으로 짠 코드를 보도록 하자.

코틀린 코루틴 사용하기

코루틴이 소개하는 핵심 기능은 어떤 지점에서 코루틴을 suspend(일시 정지)하고 후에 다시 resume(재시작) 할 수 있다는 것이다. 이 덕분에 메인 스레드에서 코드를 run(실행)하다가 API 데이터를 요청했을 때 suspend 할 수 있다. 코루틴이 suspend 되었을 때 스레드는 block(차단)되지 않고 뷰를 바꾸거나 다른 코루틴을 진행하는 등에 계속 사용할 수 있다. 데이터가 준비되면 코루틴은 메인 스레드를 기다린다. 드문 상황이지만 코루틴 대기열이 있을 수도 있다. 기다리던 스레드를 사용할 수 있게 되면 중지된 지점부터 계속 진행한다.

1
2
3
4
5
6
7
8
9
10
11
12
suspend fun updateNews() {
showProgressBar()
val news = getNewsFromApi()
val sortedNews = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
hideProgressBar()
}

suspend fun updateProfile() {
val user = getUserData()
view.showUser(user)
}
1
2
3
4
5
6
val scope = CoroutineScope(Dispatchers.Main)

fun onCreate() {
scope.launch { updateNews() }
scope.launch { updateProfile() }
}

Kotlin Coroutine Example

그림에서 메인 스레드에서 분리된 코루틴으로 실행되는 updateNews와 updateProfile 함수를 보자. 두 함수(또는 코루틴)는 순서가 바뀌어도 된다. 스레드를 block하지 않고 코루틴을 suspend하기 때문이다. updateNews 함수가 네트워크 응답을 기다리고 있을 때 메인 스레드는 updateProfile이 사용한다. 여기서는 사용자 데이터는 이미 캐싱되었기 때문에 getUserData에서 suspend하지 않았다고 가정한다. 그러므로 작업을 완료할 수 있다. 네트워크 응답 시간이 충분하지 않아서 데이터를 받아오는 게 늦어지면 메인 스레드는 그 시간동안 사용되지 않는다(다른 함수가 사용할 수 있다). 데이터를 받으면 메인 스레드를 가져와 getNewsFromApi() 바로 다음 지점부터 시작하여 updateNews를 resume(재개)한다.

정의에 따르면 코루틴은 suspend와 resume이 가능한 컴포넌트다. JavaScript나 Rust, Python과 같은 언어에서 볼 수 있는 async/await나 generators와 같은 개념도 코루틴을 사용하지만 그 기능은 매우 제한적이다.

그래서 첫 번째 문제점은 다음 방식으로 해결한다.

1
2
3
4
5
6
7
fun onCreate() {
viewModelScope.launch {
val news = getNewsFromApi()
val sortedNews = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
}
}

위 코드에서 현재 안드로이드에서 가장 흔한 viewModelScope를 사용했다. 이걸 대신해서 커스텀 scope를 사용할 수도 있다.

이 코드는 우리가 원하는 것에 거의 가깝다! 이 해결책에서 코드는 메인 스레드에서 run하지만 절대 block 하진 않는다. suspend 기법 덕에 데이터를 기다릴 필요가 있을 때 해당 코루틴을 block 대신 suspend 한다. 코루틴이 일시 정지했을 때 메인 스레드는 진행도를 예쁘게 보여주는 등의 다른 일을 할 수 있다. 그리고 데이터가 준비되면 코루틴은 메인 스레드를 다시 받아 멈췄던 부분부터 다시 시작한다.

그렇다면 어떻게 세 개의 API를 호출할까? 이 또한 유사한 방식으로 만들 수 있다.

1
2
3
4
5
6
7
8
fun showNews() {
viewModelScope.launch {
val config = getCondfigFromApi()
val news = getNewsFromApi(config)
val user = getUserFromApi()
view.showNews(user, news)
}
}

이 해법은 제법 괜찮아보인다. 그러나 최선은 아니다. API 호출들을 하나가 끝나면 다음 호출을 부르듯, 순서대로 진행되고 있다. 그래서 각 작업이 1초 걸린다고 하면, 전체 함수는 2초가 아닌 3초가 걸린다. 여기서 코틀린 코루틴 라이브러리는 async와 같은 기능을 지원한다. 즉 일부 요청으로 다른 코루틴을 즉시 시작하고 (await 함수로)그 결과가 나중에 도착할 때까지 기다리는 데에 사용할 수 있다.

1
2
3
4
5
6
7
8
fun showNews() {
viewModelScope.launch {
val config = async { getCondfigFromApi() }
val news = async { getNewsFromApi(config.await()) }
val user = async { getUserFromApi() }
view.showNews(user.await(), news.await())
}
}

코드는 여전히 단순하고 가독성 있다. 이는 JavaScript나 C#에서 잘 사용되는 async/await 패턴을 이용한다. 효과적이고 메모리 누수도 없다. 단순한데다가 잘 구성되어있기까지 하다.

다른 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 모든 페이지를 동시에 로드하기
fun showAllNews() {
viewModelScope.launch {
val allNews = (0 until getNumberOfPages())
.map { page -> async { getNewsFromApi(page) }
.flatMap { it.await() }
view.showAllNews(allNews)
}
}

// 다름 페이지를 차례로 로드하기
fun showPagesFromFirst() {
viewModelScope.launch {
for (page in 0 until getNumberOfPages()) {
val news = getNewsFromApi(page)
view.showNextPage(news)
}
}
}

[번역: Async Basics with Rust] 동시성 VS 병렬성(Concurrent vs Parallel)

이 포스팅은 Async Basics with Rust의 글을 번역한 것입니다. 오역, 의역, 생략이 있으니 감안하여 읽어주십시오.

동시성과 병렬성의 차이는 무엇인가?

이 주제에 대해 곧바로 동시성이 무엇인지 정의함으로써 파헤쳐보자. 병렬 상의 동시성과 쉽게 헷갈릴 수 있어서 시작부터 두 가지를 명확히 구분해둘 것이다.

동시 실행(Concurrency)이란 동시에 많은 것을 **처리하는** 걸 말한다.

병행(Parallelism)이란 동시에 많은 일을 **수행하는** 걸 말한다.

우리는 멀티태스킹을 동시에 여러 개의 작업을 진행한다는 개념으로 쓴다. 이러한 다중 작업에는 두 가지 방법이 있다.

하나는 작업을 동시에(;함께; 겸임) 진행하지만, 실제 같은 시간에 하지는 않는 것이며,

그림1 - Concurrent

또 다른 방법은 병렬적으로 실제 같은 시간에 여러 작업을 진행하는 것이다.

그림2 - Parallel

몇 가지를 정의해보자.

  • 리소스(Resource)

    작업을 진행하는 데에 필요한 것. 리소스는 제한되어있다.

    한 예로 CPU의 시간이나 메모리를 들 수 있다.

  • 작업(Task)

    진행하면서 어떤 종류의 리소스를 필요로하는 기능 집합(A set of operations)이다.

    하나의 작업은 몇 개의 sub-operations로 구성된다.

  • 병렬성(Parallel)

    정확히 같은 시간에 독립적으로 일어나는 일.

  • 동시성(Concurrent)

    동시에 진행중(in progress)인 작업들을 말하지만, 반드시 같은 시간에 진행되는 것은 아니다.

    이는 중요한 차이점이다. 만약 두 작업이 동시에 실행됐지만 병렬적이지는 않을 때, 그 작업들은 stop(멈춤)하거나 resume(재시작)할 수 있어야 한다.

주석 > 왜냐하면 병렬적이지 않은 것은 위의 [그림2 - Parallel]과 같이 한 작업이 계속해서 CPU를 붙잡고 항상 진행중 상태에 있지 않기 때문이다. 멈추고 재시작하기를 반복한다.

따라서 동시 실행 속성을 가지고 있다면 interruptable(끼어들 수 있는)하다고 말한다.

내가 사용하는 심상모형(mental model)

나는 우리가 병렬성이나 동시성을 가진 프로그램을 만들 떄 어려워하는 이유가 일상에서 일어나는 사건들을 모델링하는 방법에서 기인한다고 생각한다. 우리는 대게 잘못된 직관으로 대략적인 정의를 내리는 경향이 있기 때문이다.

concurrent의 사전적 정의는 parallel과의 차이를 인지하는 데에 도움을 주지 않는다.

나로 말할 것 같으면, 병렬성과 동시성의 차이점이 필요한지를 떠올린 게 시작이었다.

이들이 필요한 이유는 리소스의 활용도와 효율성과 관련된 모든 것에 있다.

효율성이란 어떤 일을 하면서, 또는 바라는 결과를 내는 데에 쓰이는 자원, 에너지, 노력, 돈, 그리고 시간을 낭비하지 않는 (대게 측정할 수 있는) 능력을 말한다.

  • 병행(Parallelism)

    작업을 수행하면서 리소스를 계속해서 늘린다. 이는 효율성을 고려하지 않는다.

  • 동시 실행(Concurrency)

    효율성과 리소스 활용도 모두를 고려한다. 동시 진행은 절대 단 하나의 작업을 더 빠르게 만들 수는 없다. 대신 리소스를 더 효율적으로 운용하고, 그럼으로써 작업들의 집합(a set of tasks)이 더 빠르게 끝나도록 한다.

경제학에서 몇 가지 유사점을 찾아보자.

상품 제조 사업에서는 린(LEAN)이 대표적이다.

린 이라는 기술을 사용함에 있어 가장 이점은 기다리는 시간과 가치없는 작업을 제거하는 것이다.

프로그래밍에서 말할 것 같으면, blocking과 polling을 피한다고 말할 수 있다.

동시성 및 I/O와의 관계

지금까지 말한 걸 보면, 비동기 코드를 작성하는 데 리소스를 최적으로 사용할 때야 비로소 의미가 있다.

프로그램을 짤 때 동시성이 도움이 되지 않는 경우도 있다. 병렬로 작업할 파트들로 나눌 수 있다면 더 많은 리소스를 할당하는 식으로 문제를 해결할 수 있다.

동시성에 관한 두 가지 주요 이용 사례가 있다:

  1. 입출력이 수행되는 중에 일부 외부 이벤트가 발생할 때까지 기다리는 상황.
  2. 여러 가지에 집중해야할 때 한 가지에만 너무 오래 기다리는 걸 방지해야하는 상황.

첫 번째는 기본적인 입출력 예제이다: 당신이 한 가지 작업을 진행하기 전에 네트워크 호출이나 DB 쿼리 등이 발생할 때까지 기다려야하는 상황이다. 그러나 지금 다른 할 일도 많기 때문에 다른 작업을 계속하다가 작업(네트워크 호출 등)이 준비가 됐는지 정기적으로 확인하거나 준비가 됐을 떄 알림을 받아야 한다.

두 번째는 UI 단에서 자주 일어나는 일이다. 당신이 한 개의 코어만 가지고 있다고 하자. 그럼 CPU에서 집중적으로 작업을 수행하고 있는데 대체 어떤 UI가 무반응을 피할 수 있을까?

음, 당신이 지금 하고 있는 작업이 뭐든간에 멈추고, “UI 갱신”을 하고, 그 후에 하려고 했던 일을 재시작할 수도 있다. 이렇게하면 작업을 1초에 60번 중지/재시작 해야한다. 그러면 당신은 결국 대략 60Hz의 새로고침 빈도를 반응하는 UI를 가지게 될 것이다.

OS에서 제공하는 스레드에 관하여

I/O 처리 전략에 관해 이야기하면서 스레드에 대해 좀 더 다룰 거지만 여기서도 언급하겠다. OS 스레드를 사용할 때 한 가지 문제는 코어에 매핑되는 것처럼 보인다는 것이다. 대부분의 운영 체제가 스레드 수가 코어 수와 같을 때까지 하나의 스레드를 하나의 코어에 매핑하려고 시도하더라도 이게 반드시 올바른 심상 모델은 아니다.

코어보다 많은 스레드를 생성하면, OS는 스레드간에 스위치를 수행하고 각 스레드에 실행시간을 제공하는 스케줄러를 사용하여 각 스레드를 동시에 진행하도록 할 것이다. 그리고 시스템에서 프로그램은 겨우 하나만 실행되지 않는다는 걸 명심해야한다. 다른 프로그램도 여러 개의 스레드를 생성할 수 있고, 이는 CPU에 있는 코어보다 더 많은 스레드가 있음을 의미한다.

그러므로, 스레드는 병렬적으로 작업을 수행하도록 하는 수단이 된다. 이는 동시성을 달성하는 수단이기도 하다.

이건 동시성에 관한 마지막 파트로 이어진다. 이제 일종의 참조 프레임을 정의해야 한다.

참조 프레임 바꾸기

당신의 관점에서 봤을 때 완벽하게 동기적인 코드를 짰다고 하자. 잠시 멈춰서 운영 체제 관점에서 이게 어떻게 보일지 생각해봐라.

운영 체제는 당신의 코드를 처음부터 끝까지 실행하지 않을 수 있다. 매순간 프로세스를 멈추고 다시 시작하길 반복할 것이다. CPU는 당신이 보기에 이 작업에만 집중하고 있다고 생각하는 동안에도 멈추고 일부 입력을 처리하고 있을지도 모른다.

그러니 동기적 실행은 그저 환상이다. 하지만 프로그래머로서 당신의 관점에서, 그렇지만도 않다. 이게 요점이다:

다른 맥락 없이 동시성을 말할 때, 당신은 프로그래머이며 당신의 코드는 참조 프레임이 된다. 만약 이를 염두에 두지 않고 동시성에 대해 이해하려한다면 혼란스러울 수 있다.

즉, 참조 프레임을 염두에 둬야 한다.

아직 복잡하게 들릴 수 있다. 이후 비동기 코드와 함께 작업하면서 이를 계속 상기해낸다면 복잡함은 점점 덜게 될 것이라 약속한다.