[번역: 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] var / val / const val / companion object 특성

이 포스팅은 아래 Kotlin 공식문서의 일부를 번역(의역) + 의견을 추가한 것입니다.

var / val

변수의 정의가 가변(mutable)하는 경우 var 키워드(variable)를 사용하고, read-only일 경우 val 키워드(value)를 사용한다. 변수의 값이 가변하는 경우가 아니다. 예를 들어, area의 값은 width와 height에 따라 변하지만 정의는 변하지 않는다.

1
val area: Int = this.width * this.height

코틀린에서 backing field는 단지 메모리에 값을 유지하기 위해서만 사용되는 것에 불과하다. field는 직접 선언될 수 없으며 변수의 setter나 getter 내에서만 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
var counter = 0 // initializer가 field에 직접 값을 할당한다.
set(value) {
if (value >= 0) {
field = value
/* 'counter'라는 실제 변수명을 사용하는 건
setter를 다시 부르는 일이기 때문에 StackOverflow Error가 난다.
*/
// counter = value
}
}

이런 식의 암시적인 field 체계 대신 아래와 같이 backing 변수를 만들 수도 있다.

1
2
3
4
5
6
7
8
private var _table: Map<String, Int>? = null
public val table: Map<String, Int>
get() {
if (_table == null) {
_table = HashMap() // 여기서 파라미터의 타입이 추론된다.
}
return _table ?: throw AssertionError("Set to null by another thread")
}

const val

컴파일 타임 상수이다. 다음 조건을 모두 충족시키는 경우에 해당한다.

  • 최상위 레벨 속성이거나 object나 companion object의 멤버여야 한다.
  • String이나 원시(primitive) 타입으로 초기화되어야 한다.
  • 커스텀 getter를 사용할 수 없다.

컴파일 시 inline 형태로 이 변수를 변수 대신 실제 값으로 대치한다. 그러나 필드가 사라지는 건 아니라서 relection으로 상호작용은 여전히 가능하다.

1
const val SUBSYSTEM_DEPRECATED: String = "this subsystem is deprecated"

companion object(동반 객체)

class 내부에 object 선언은 “companion” 키워드를 써서 할 수 있다.

1
2
3
4
5
class MyClass {
companion object Factory {
fun create(): MyClass = MyClass()
}
}

companion object의 멤버는 단순하게 클래스명을 통해 호출할 수 있다.

1
val instance = MyClass.create()

companion object의 이름은 빼도 되고, 이때는 Companion으로 접근한다.

1
2
3
4
class MyClass {
companion object { }
}
val x = MyClass.Companion

클래스 멤버는 companion object 내의 private 멤버에 접근할 수 있다.

-

[번역: 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)
}
}
}

[후기] 안드로이드 뜻밖의 역사

이 책은 Androids: The Team that Built the Android Operating System 전문을 번역한 책이며, 영문으로는 해당 링크에서 무료로 볼 수 있다.

  • Read started from 2022.08.26
  • 출간일: 2022.08.19
  • 저자: 쳇 하스
  • 출판사: 인사이트

-

나는 이 책을 기술적인 세부 내용을 아는 소프트웨어/하드웨어 엔지니어 말고도 누구나 읽기를 바란다._1부 中

책을 읽기 전 팔락팔락 넘기다가 SCSI 단어가 나오길래 비관련 종사자는 읽기 힘들거라고 생각했는데, 책을 처음부터 읽는다면 미리 설명한 사람들이 나와서 알만한 이야기를 한다. 이 책에서 나는,

  1. 안드로이드 1.0을 릴리즈하기 위한 열정
  2. 안드로이드 내부 기능 각각이 탑재된 배경
  3. 안드로이드 OS가 시장에 나오기까지의 과정

세 가지를 중점적으로 봤기 때문에 이런 부분을 기대한다면 읽기 불편할 것 같진 않다고 생각했다.

첫 번째, 열정.

다 읽고 나면 안드로이드라는 새로운 운영 체제를 위해 함께 토론하고 결정하며 달려온 기분을 맛볼 수 있다.

역사를 공유하고 있다는 확고한 느낌이 있었죠._1장 中

이 책을 읽은 시기가 마침 회사에서 안드로이드 개발자로 일하면서 두 가지를 고민하던 때였다.

내가 ‘왜’ 여기 있는 거지?

물론 안드로이드 개발을 하고 있었으나 나는 내가 좋아해서 고른 이 직무에 관해 의구심이 생겼다.

‘나는 왜 안드로이드를 좋아했던 거지?’

지금도 좋아하지 않는 게 아니었다. 그런데 처음 이끌렸던 이유가 기억나지 않았다. 분명히 있을 거라고 생각했고, 나는 지금까지 인생을 회고하면서 그걸 찾아보고자 했다. 그런데 이 책은 표지처럼 안드로이드의 골대를 세우고, 내부 톱니바퀴를 채우고, 외피를 덮는 공정을 밟는다. 그게 뜻밖에도 나에게 도움을 주었다.

자세한 건 여기에 적지 않겠으나, 첫 직장에서 C#을 했었고, 거기서 마음에 들었던 부분이 안드로이드에 있었다는 걸 이 책에서 안드로이드의 내부를 하나하나 짚어주는 걸 따라가면서 발견해냈던 것이다.

그런 점에서 나는 이 책을 읽기 잘했다는 마음이 들었고, 끝까지 재미있게 읽을 수 있었다.

두 번째, 배경.

안드로이드의 각 내부기능, 예를 들면 알람, 지도, 웹뷰는 물론이고 선택된 언어, 저장장치 구성, 이미지 렌더링에 관하여 코드로써 다뤄본 적이 있다면 더욱 빠져들 수 있을 것이다.

참고로, 현재 Android Developer의 플랫폼 아키텍처에서는 소프트웨어 스택(흔히 말하길 ‘안드로이드 운영체제 구조’)을 아래와 같이 그림으로 나타내고 있다.

Android 소프트웨어 스택

그러나 이건 런타임이 ART(Android 5.0(API 레벨 21) 이상이 실행되는 기기의 기본 런타임)로 바뀐 2022년 버전 구조이고, 이 책에서는 1.0이하를 중점적으로 다루고 있으므로 아래와 같이 런타임에 Dalvik을 사용하는 구조에 가깝다고 볼 수 있겠다.

현업 종사자라면 두 사진을 비교해보는 것도 소소한 즐거움이 되지 않을까.

Android 운영체제 구조

그림 출처: Anatomy Physiology of an Android (Google IO 2008)

“누군가 그걸 좋아했기 때문이 아니라 플랫폼이 성공하려면 타당했기 때문이고 팀이 거기에 적응한 거예요.”_8장 中

의사결정 과정에 대해 다이앤이 한 말이다. 기능의 세부사항들은 모두 당시에 타당한 기준으로 선별되었고, 구성하였음을 알 수 있다.

책에서 재미있게 읽고 기억에 남았던 게 몇 가지 있다.

  • GPU없이 렌더링되던 시절의 이야기
  • 초기 운영체제 모델로 오른 액티비티와 main() 함수
  • ART 런타임으로 바뀌기 이전의 초기 런타임 형태
  • View의 스레드 측면 작동 방식

포트 5228에 대한 내용도 잠깐 있었고 내용이 정말 알찼다. 안드로이드의 기능이나 구조를 한 번씩 언급해준다. 개인적으로 웹뷰에 대한 이야기가 길게 이어져서 좋았고, 업데이트 중 메모리 확보 및 보안을 신경쓴 부분도 자세하게 나와서 읽는 재미가 있었다.

1.0 릴리즈까지 타이트한 일정이었으나 결국 제품을 완성하여 최고의 타이밍에 출시한 안드로이드. <세상을 빛낸 Geek>에서는 맥의 화려함을 강조한 모양이나, 나는 <안드로이드 뜻밖의 역사>가 좋다.

안드로이드가 오픈 소스인 점, 앱스토어에서 사용자를 가리지 않은 점. 모두에게 열려있다는 인상, 그리고 개발자 친화적이라는 생각도 들었다.

나는 Android가 좋다:)

다음은 1부에 나오는 에번 밀러의 말을 옮긴 것이다. 이를 마지막으로 후기를 마무리하고자 한다.

“처음부터 필연 같은 건 없었어요.
안드로이드가 성공하지 못할 이유는 많았죠.
똑같은 일을 다시 이뤄 내고 싶다고 해도 할 수 없을 거예요.
뭔가 마법이 벌어진 거죠.”

-

Android

[Microsoft] Java 11 이상으로 이동하는 이유

  • 2022.05.20 작성됨

Java 8에 대한 업데이트가 유료로 전환되어 Microsoft에서는 빠른 시일 내에 11 버전으로 옮기길 권장하고 있다.

Java 11로 업그레이드할 경우 장점으로는

  • 모듈: 메모리를 더 적게 쓰고, 클래스 로딩이 빨라진다.
  • GC: Java 8에서는 여러 스레드를 사용하는 병렬 GC를 사용해 속도를 높인 반면, Java 11에서는 G1 가비지 수집기를 사용해 처리량을 높였다.

Android Studio Arctic Fox(2020.3.1)

An exception occurred applying plugin request [id: ‘com.android.library’]
Failed to apply plugin ‘com.android.internal.library’.
Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8.

Arctic Fox는 JDK 11 기반으로 되어있어 Gradle도 7.0.x 버전으로 올라갔다. (기존 Android Studio 4.x는 JDK 1.8 기반) 이에 따라 에러 메세지에서는 ‘com.android.library’ 플러그인을 사용하는 데 문제가 있다고 명시해주고 있다.

2022~2023년 Kotlin 팀의 계획: 주요 프로젝트 및 생산성 기능

2022~2023년 핵심 프로젝트는 다음과 같습니다.

  • Kotlin 릴리스의 품질과 안정성 개선
  • K2 컴파일러의 베타 버전 출시
  • Kotlin Multiplatform Mobile의 안정화 버전 출시
  • K2 지원 플러그인을 포함한 Kotlin IntelliJ IDEA 플러그인의 알파 버전 출시
  • Kotlin/JS IR 백엔드의 안정화 버전 출시

2022 구글 I/O

안드로이드 로드맵 2022

안드로이드 버전 점유율(statistics)

Android Studio > Create New Project

Airbnb의 Server-Driven UI 시스템

Ghost Platform(GP)로 안드로이드의 업데이트가 느리다는 단점을 상쇄하기 위해 UI를 SDUI로 만드는 데에 도움을 주는 플랫폼을 AirBnb에서 만들어 사용하고 있다.