쾌락코딩

람다 내부의 return은 언제 불가능하며 이유는 무엇일까?

|

고차함수에 넘긴 람다에서 return은 사용 가능할 때가 있고, 불가능 할 때가 있다. 정확하게는 인라인 고차함수의 경우 return 사용이 가능하고, 일반 고차함수는 return 사용이 불가하다.

예를 들어 흔히 사용하는 forEach의 경우 inline 고차함수라서 람다 내부에서 return이 가능하다.


fun findAndPrintTen(numbers: List<Int>) {
    numbers.forEach {
        if (it == 10) {
            println("find 10!!")
            return
        }
    }
}

fun main() {
    findAndPrintTen(listOf(1,2,3,10))
}

// find 10!!
// inline 함수로 구현된 forEach 내부 
@kotlin.internal.HidesMembers
public inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {
    for (element in this) action(element)
}

그러나 inline이 아닌 일반 고차함수의 lambda에서는 return을 사용 할 수 없다. 원인을 알아보기 forEach를 직접 만들어 보며 테스트 해보자.

inline fun <Int> Collection<Int>.forEachA(block: (Int) -> Unit) {
    for (e in this) block(e)
}

fun <Int> Collection<Int>.forEachB(block: (Int) -> Unit) {
    for (e in this) block(e)
}

forEachAinline 고차함수고, forEachB는 일반 고차함수다. forEachA로 넘긴 람다에서는 return을 사용할 수 있고, forEachB로 넘긴 람다에서는 return을 사용하지 못할 것이다. 확인을 위해 findAndPrintTen() 함수로 돌아가서 forEachAforEachB를 사용하도록 한 번씩 변경해보자.

fun findAndPrintTen(numbers: List<Int>) {
    numbers.forEachA {
        if (it == 10) {
            println("find 10!!")
            return
        }
    }
}

fun findAndPrintTen(numbers: List<Int>) {
    numbers.forEachB {
        if (it == 10) {
            println("find 10!!")
            return  // compile error : 'return' is not allowed here
        }
    }
}

언급했던 대로 forEachB는 컴파일되지 못한다. 해당 위치에서는 ‘return’을 사용하지 못한다는 에러를 내뿜는다.

왜이럴까?

forEachA의 경우는 inline 함수이기 때문에 파라미터로 받는 람다가 호출하는 쪽에 inline된다. 예를 들어, 컴파일러는 아래 코드를

fun findAndPrintTen(numbers: List<Int>) {
    numbers.forEachA {
        if (it == 10) {
            println("find 10!!")
            return // findAndPrintTen를 return 시킨다
        } 
    }
}

아래처럼 변환시킨다.

fun findAndPrintTen(numbers: List<Int>) {
    for (number in numbers) {
        if (number == 10) {
            println("find 10!!")
            return // findAndPrintTen를 return 시킨다
        }
    }
}

forEachA로 넘긴 람다 block이 컴파일러에 의해 그대로 inline되기 때문에 return문을 만나면 일반 for문에서 return을 하는 것과 동일하게 findAndPrintTen를 리턴시킬 수 있다. 그래서 inline 고차함수로 넘긴 람다에서는 return이 허용된다.

반면 forEachB 파라미터로 받은 람다의 경우 호출하는 쪽에 inline되지 않는다. 람다로 받은 함수는 컴파일러에 의해 아무런 처리가 되지 않고, 컴파일 된 이후 Function  타입의 객체가 된다.

람다가 결국 Function 타입의 객체라면, 이 람다를 변수에 저장할 수도 있다는 뜻인데 이런 현상 때문에 return이 금지되는 것이다(inline 함수의 람다는 객체가 아니므로 변수에 저장 불가). 만약 람다를 외부에 저장해놓고, 자신을 호출한 함수의 context를 벗어난 곳에서 함수가 실행 된다면 예상치 못한 버그가 일어날 수 있기 때문이다. 위 코드의 경우에는 findAndPrintTen 함수가 이미 끝난 상태에서 어딘가에 저장된 람다가 다시 호출 되는 경우라고 볼 수 있다. 이미 늦은 시점이다.

실제로 아래의 코드를 보면, inline 고차함수로 넘긴 람다는 다른 변수에 저장이 불가능 하다(그래서 return이 허용). 반면 일반 고차함수로 넘긴 람다는 다른 변수로 저장이 가능하여 return문으로 인한 버그 방지를 위해 return을 금지한다.

var savedFunc: () -> Unit = {}

inline fun test1(block: () -> Unit) {
    savedFunc = block // compile error:  inline될 것이기 때문에 변수로 저장할 수 없다.
}

fun test2(block: () -> Unit) {
    savedFunc = block
}

Jetpack Compose가 필요한 이유(Mental Model)

|

요즘 kotlin weeklyandroid weekly를 보면, Jetpack Compose 이야기가 굉장히 핫합니다. 몇 년전, 매주 Weekly나 Medium 또는 Google 영상을 찾아보면 거의 절반 정도가 coroutine이야기였을 때가 있었는데요, 그때와 굉장히 비슷하다고 생각합니다. 많은 사람들이 coroutine에 관심을 많이 가졌고, 좋은 글들이 많이 올라왔습니다. 특히나 Rx와 Coroutine을 비교하는 글들이 많았던 것 같고, 2021년 현재 상당히 많은 회사에서 Rx를 걷어내고 Coroutine을 적용하고 있습니다.

시대를 좀 더 거슬러 올라가면 Java가 아닌 Kotlin을 써야 하는 이유, Kotlin의 장점 등에 대한 글이 굉장히 많이 나왔습니다. 결국 그 기술들이 서서히 자리 잡게 되었습니다.

최근 몇 달간의 아티클들을 보았을 때 어떤 때는 절반 넘게 Jetpack Compose 이야기일 때도 있습니다. 개인적으로 굉장히 기다렸던 기술이라서 직접 사용해 보기도 하고, 여러 아티클들을 많이 읽고 있는데요, 공부했던 부분들을 공유해보려고 합니다.

이 글은 Compose를 다루는 방법에 대한 이야기는 아닙니다. 그보다는 왜 Compose가 나왔고, 왜 Compose가 피할수 없는 미래인지에 대한 이야기입니다. 2021년 하반기에 정식 릴리즈가 되면, 그저 정식 릴리즈 되었으니 쓰는게 아니라, 왜 컴포즈가 나오게 되었는지를 알고 써야 컴포즈스럽게 쓸 수 있다고 생각합니다.

컴포즈가 필요한 이유는 크게 아래의 세가지 이유로 정리할 수 있습니다.

  1. xml을 벗어난 UI 개발
  2. 선언형 UI
  3. 상속이 아닌 확장

1. XML을 벗어난 UI 개발

image

몇 년 동안 코드와 UI를 연결하는 많은 방법들이 나왔습니다. 그림에서 보이듯이 계속해서 꾸준히 발전 해왔고, 지금 현재 MVVM과 databinding 조합으로 꾀나 잘 사용하고 있는데, 왜 컴포즈가 나왔을까요? 이들의 한계는 결국 여전히 xml을 사용한다는 점에 있습니다.

image

위 그림은 Xml으로 UI를 그릴 경우에 흔히 나타나는 패턴을 나타냅니다. view와 데이터 사이에 중간 계층이 하나 끼어들어야만 하는 형태입니다.

UI를 그리기 위해 XML을 사용하고 있음에도 불구하고, 코틀린 역시 UI에 어떤 id 값이 있고, 어떤 UI를 포함하려 하는지를 알고 있어야 하기 때문에 둘은 강하게 의존하게 됩니다. 시간이 많이 흐른 뒤 view를 수정하고 싶어서 xml을 수정하게 되면, 어쩔 수 없이 kotlin 코드도 수정해야 하며, 이때 하나라도 놓치면 런타임 에러가 나게 됩니다. 결국 코틀린 개발자와 코틀린 코드가 어떠한 안전장치도 없이 UI에 개념적으로 의존해야 한다는 의미이기도 합니다.

MVVM과 databinding 조합은 그나마 상황이 낫습니다. bindingAdapter를 사용하면 programming language에서 코드적으로나 개념적으로 UI의 구조에 덜 의존하게 될 수 있게 되고, 어느 정도는 동적인 UI 제어가 가능해지긴 했습니다. 그럼에도 불구하고 여전히 XML을 벗어나지 못했다는 한계가 있는데요, 특정 view의 attritue 정도를 동적으로 적용하기 쉬울 뿐, UI 그룹들을 동적으로 바꾸기에는 적합하지 않습니다. Fragment를 써서 Kotlin 코드로 Fragment를 교체하는 게 그나마 나은 방법이 되겠지만, Fragment 역시 쉽지 않습니다. code로 Fragment를 조작하는 일은 매우 귀찮은 작업일 뿐만 아니라 생명 주기도 까다로워서 예상치 못한 상황에 애를 먹기도 합니다.

Jetpack Compose를 사용하면 Fragment는 자연스럽게 없어집니다. 아래에서 다시 다루도록 하겠습니다.

image

위 그림은, 선언형 UI toolkit인 Jetpack Compose(only kotlin)로 구현할 경우 나타나는 패턴입니다. 즉, 데이터를 입력받으면 UI를 그려주는 Kotlin 함수(composable 함수)를 사용하여 UI를 그리게 되는 상황인데요, ViewModel에서 변경된 데이터를 Composable 함수의 파라미터로 바로 넘겨주기만 하면 되기 때문에, 서로 다른 분야를 이어주며 강한 결합도를 가지는 Extra Layer가 사라지게 됩니다. UI가 바뀌면, Composable 함수만 수정하면 될 뿐입니다.

Goodbye Fragment

Fragment의 기원은 Android 3.0에서 시작합니다. 태블릿 PC처럼 Activity 한 개에 보일 화면(View)들이 많을 경우 Activity Class 하나에 코드가 넘쳐나는 것을 방지하고자 세상에 나왔습니다. fragment 액티비티 한 개만 사용하는 상황에서, 두 화면을 각자 독립적인 클래스로 다룰 때도 결국은 각각 Activity의 Lifecycle이 필요합니다. 즉 Fragment는 Android 컴포넌트가 아니라 단지 Activity의 생명주기를 추종하는 View라고 볼 수 있습니다. 따라서 하나의 Activity에 성격이 다른 화면(View)이 두 개 필요할 때, Fragment를 사용하면 findViewById와 기타 로직이 Activity 하나에 너무 많아지는 걸 방지할 수 있게 되었습니다. 그러나 결국 Lifecycle을 추종하는 View 일 뿐이기 때문에 UI Toolkit이 Jetpack Compose로 바뀌면 View를 위한 Fragment의 사용성은 사라집니다. findViewById는 역사속으로 사라지고, 기타 로직은 ViewModel에서 수행하기 때문에 위의 그림 처럼 Fragment가 설 자리는 없어집니다.

실제로 React나 Flutter같은 선언형 UI 들을 보면 fragment같은 개념이 없습니다.

2. 선언형 UI

명령형 프로그래밍과 선언형 프로그래밍이란 단어를 한 번쯤 들어 보았을 것 같습니다. 명령형 프로그래밍은 우리가 흔히 코딩하듯이, 컴퓨터에게 하나하나 명령을 하는 형태로 코딩을 하는 것입니다. 반면에 선언형 프로그래밍은 컴퓨터에게 우리가 원하는 것을 선언 또는 표현하듯이 코딩 하는 것입니다.

명령형 프로그래밍

명령형 코드를 먼저 살펴보겠습니다.

val myList = listOf(1,2,3)
val targetList = mutableListOf<Int>()
for (i in myList) {
    if (i % 2 == 0) tempList.add(i)
}

로직이 간단하기 때문에, 코드를 순차대로 훑어 보시면 이 5줄의 코드가 어떤 의미를 가지는 코드인지 알 수 있습니다.

  1. 먼저 myList라는 숫자를 담는 list가 있습니다.
  2. targetList라는 임시 리스트를 만들었습니다.
  3. for문을 사용해서 myList의 모든 요소를 한 번씩 순회 합니다.
  4. 순회하면서 각 요소의 나머지가 0인 것들을 targetList에 담습니다.

컴퓨터에게 구체적으로 하나하나 명령한 이 코드를 따라가다 보니, 그제서야 list에서 짝수만 필터링하는 기능이라는 것을 알 수 있습니다. 우리는 방금 리스트에서 짝수만 필터링하는 그 과정, 즉 필터링을 하기 위해 어떻게(how) 하는지를 본 것입니다.

이같은 프로그래밍 방법이 명령형 프로그래밍입니다.

선언형 프로그래밍

반대로 선언형 프로그래밍은 다음과 같습니다.

val targetList = listOf(1,2,3).filter { it % 2 == 0 }

같은 필터링 기능인데, 세부 동작 하나하나를 명령한 느낌이 아닙니다. “나는 이 list에서 이것들을 필터링 할거야”라고 선언한 것에 불과합니다. 내부적으로 어떻게(how) 할지는 관심이 없습니다. 단지 “이렇게 표현할래”를 나타낸 것입니다.

우리는 컴퓨터가 아니라 사람입니다. 어렸을 때를 떠올려 보면, “엄마, 밥 먹고 싶어요”라고 표현했지, “엄마, 우선 밥솥에 쌀을 넣고, 물을 이만큼 넣은 후 밥솥에 전기를 연결해서 무슨 무슨 버튼을 눌러서..”이렇게 명령하지 않았습니다. 우리는 특정 개발자가 컴퓨터에게 명령한 로직들을 보고 그게 무슨 기능인지 이해하는 것보단, 개발자가 원했던 기능의 선언을 보고 이해하는 게 훨씬 익숙합니다. 그게 바로 선언형 UI가 대세가 된 이유라고 생각합니다.

선언형 프로그래밍은, 결국 잘 된 추상화라고도 볼 수도 있습니다. filter 함수도 내부 로직을 타고 들어가면 결국엔 컴퓨터에게 명령을 하는 코드로 되어있습니다. 다만 그걸 잘 추상화 하는 게 참 어려운 일이고, 그게 특히나 단순 로직의 추상화가 아니라 UI를 그리는 영역이라면 더더욱 어렵습니다. 누군가 그 어려운 일을 다 해준다면 우리는 컴퓨터에게 원하는 UI를 선언하기만 하면 됩니다(고맙게도 Jetpack Compose가 그 어려운 일을 다 해주고 있습니다👍🤩👍).

명령형 UI 프로그래밍

명령형 UI 프로그래밍은 아래와 같습니다.

val linearLayout = LinearLayout()
linearLayout.orientation = VERTICAL

val textView1 = TextView().apply {
    text = "hello"
}
val textView2 = TextVew().apply {
    text = "world"
}
linearLayout.add(textView1)
linearLayout.add(textView2)

아까 for 문을 돈 것과 비슷하게, 컴퓨터에게 명령을 내리고 있습니다. 코드를 다 보고, 어디에 무엇이 add 되는지 다 읽고 나면, “아~ textView 두 개가 위아래로 배치되어 있도록 그리는 코드이구나”라는 걸 이해하게 됩니다.

선언형 UI 프로그래밍

이번엔 같은 기능을, 명령하지 않고 선언해보겠습니다.

<LinearLayout
    ...
    android:orientation="vertical">
    <TextView
				...
        android:text="hello"/>
    <TextView
        ...
        android:text="world"/>
</LinearLayout>

제가 원하는 UI를 그저 표현했습니다. 어떤 UI가 될지 아까보다 훨씬 유추하기 쉽습니다.

사실 XML도 선언형이기 때문에 Android 개발자라면 이미 선언형 UI에 익숙하신 겁니다. 다만 XML 파일에 선언하였기 때문에 더 다이나믹하게 UI 자체를 바꾸는 건 굉장히 귀찮고 어렵습니다.

위의 코드를 컴포즈로 바꿔보면, 아래와 같습니다.

@Composable
fun MyView() {
    Column {
        Text("hello")
        Text("world")
    }
}

만약 다른 뷰로 바꾸고 싶으면, 그저 다른 Composable 함수를 호출하면 그만입니다. 프레그먼트를 사용해서 바꿀 필요가 없습니다.

마지막으로는 상속 대신 확장이라는 측면에서 살펴보려고 합니다. 이는 Compose API를 사용하는 측면에서는 크게 중요한 내용은 아닐지라도 충분히 살펴볼 가치가 있어서 정리해보았습니다.

3. 상속을 버리고 합성을 선택하다.

기존에는 모든 컴포넌트가 View.java를 상속해야 합니다. 그러나 View.java는 3만 줄이 넘을 만큼 너무 거대합니다. BaseClass가 이렇게 많은 역할을 하게 되면, 하위 클래스가 많아지면 많아질수록 Base 클래스 자체를 바꾸는 게 굉장히 부담으로 다가옵니다. 실제로 Android Team의 Anna-Chriara는, 이제 와서 이렇게 비대해진 상속 관계를 도저히 바꾸기 힘들다며 후회하고 있다고 말했습니다.

Jetpack Compose는 상속 대신에 합성(Composition)을 사용하여 전체적인 UI 컴포넌트를 구성하도록 설계되었고, Composable이라 불리는 각 UI 컴포넌트들 또한 전혀 상속을 하지 않고 합성만을 사용해서 구현되어 있습니다. 생각해 보면 Composable은 단지 상태를 받아서 UI를 그려주는 함수에 불과하기 때문에 당연히 상속이란 개념이 없는 게 당연합니다.

Jetpack Compose의 탄생은 단지 선언형 UI 개발에 대한 열망 때문만이 아닙니다. 위에서 말했듯이 Android Team의 과도한 상속 기반 API를 바로잡을 때가 되었기 때문이기도 합니다. 개인적으로는 Android 팀이 UI 툴의 전체적인 아키텍쳐를 상속이 아니라 합성을 기반으로 변경한 것이 굉장히 큰 결심이자 발전이라고 생각하여 이 부분을 조금 더 상세하게 들어가 보려고 합니다.

왜 Android UI System은 망했을까?

처음 Android가 나왔을 때는 사용자의 요구 사항이라던지, 하드웨어의 성능 문제로 딱히 View의 다양성이 필요하지 않았습니다. 그러나 시대가 흐를수록, 사용자의 요구 사항과 하드웨어 성능이 높아져서 더 많은 View 들과 함께 각 UI 컴포넌트들에 다이나믹한 동작들을 기대하게 되었습니다. 그래서 점점 UI들이 추가되다 보니, UI 컴포넌트라면 공통적으로 사용되는 UI의 속성들을 재사용하기 위해 View라는 Base Class를 만들었고, 이를 여기저기서 상속하게 되었습니다. 아마도 시간이 지날수록 계속해서 추가 기능들이 추가되었을 것이고, 아래와 같은 트리 형태가 나타나게 되었습니다.

image

ButtonText를 랜더링 하는 자신만의 방법을 굳이 따로 가질 필요 없이, TextViewText를 랜더링 하는 방식을 재사용 할 수 있어서 효율적입니다. 그러나 시스템이 커지면 커질수록, Android의 인기가 높아지면 높아질수록, 상속의 한계는 점점 더 명확하게 나타납니다. 예를 들어 텍스트가 아니라 이미지가 랜더링되는 Button을 사용하고 싶어졌다고 해봅시다. 아마도 Button을 상속한 ImageButton이란게 있지 않을까?라는 생각으로 ImageButton를 찾아봅니다. 역시나 ImageButton 클래스가 존재했고, XML에서 기존의 ButtonImageButton으로 교체합니다. 그러나 ImageButtonButton이니까 당연히 아무런 문제 없이 돌아갈 거라고 생각했지만, 실행조차 되지 않고 컴파일 에러가 납니다😭. 에러는 기존에 코틀린으로 findViewById를 사용하여 Button을 찾아서 Button의 메서드를 호출한 부분에서 발생하였습니다. ImageButtonButton인데 왜 Button의 메서드를 호출하는 코드에서 에러가 난걸까요? 그 이유는, 사실 ButtonImageButton이 굉장히 밀접한 관계가 있을거라 생각했지만 사실은 전혀 관계가 없기 때문입니다😞.

image

여기서부터 과도한 상속으로 인한 딜레마에 빠지게 되고, 안드로이드 UI 시스템은 정확히 이 덫에 걸렸다고 생각합니다. 이 현상은 스스로 한 번씩 고민해 볼 만한 현상이라고 생각합니다. 과연 우리라면 ImageButtonImgaeView의 자식으로 채택해야 할까요, Button의 자식으로 채택해야 할까요? 아니면 각각 따로 상속받는 ImageButton을 디자인해서 구현해야 할까요? 정답이 없는 문제지만 한 가지 얻을 수 있는 교훈은 있습니다. 자신이 만든 컴포넌트를 다른 개발자들이 어떻게 사용할 것인지는 예측할 수 없고, 이 복잡함을 한방에 해결할 수 있는 상속 관계를 설계하기는 더더욱 어려운 점이라는 것입니다.

잠시 돌아가서, 왜 ButtonTextView의 하위 클래스로 설계하였을까요? 구글 Android 팀이 추후에 ButtonImageView가 들어가리라고는 생각하지 못하고 저렇게 설계한 걸까요? 이유는 알 수 없지만 그런 모든 가능성을 예측해서 상속 계층을 디자인하는 건 구글 개발자들도 힘들다는 게 결론이 아닐까 싶습니다.

상속보다는 합성을

결국 객체지향의 기본 내용인데, 상속은 코드 재사용을 목적으로 사용해선 안됩니다. 상속은 타입 계층을 만들 목적으로 사용해야 합니다. 만약 코드 재사용을 목적으로 상속을 사용한다면 기존 Android UI System 같은 문제가 생길 수 있습니다. 계층 관계가 전혀 없이 단지 코드 재사용이 목적이라면, 상속보다는 해당 기능을 수행할 다른 객체에 의존해서 그 객체를 사용하도록 하는 게 좋습니다.

더 이상 Button에서 text를 변경할 때, 상속받은 text 속성에 “hello”를 입력하지 않아야 합니다. 대신 ButtonText를 가지고 있게 해서, Text가 랜더링 하도록 위임하면 됩니다. 그리고 이게 바로 Compose가 설계된 방식이고, 여러분이 Jetpack Compose로 UI를 그릴 때 사용할 방식입니다.

Button(onClick = { /*TODO*/ }) {
    Text(text = "Hello")
}

Compose 관련 포스팅인데 글이 이상하게 조금 흘렀네요..😅 상속과 합성에 대해 더 관심있으신 분은 https://kt.academy/article/ek-composition 글 정독을 추천드립니다.

Android는 다른 플랫폼에 비해 선언형 UI ToolKit 도입이 많이 늦은 편입니다. 늦은 만큼 좋은 UI toolkit(React.js, Flutter 등등)을 많이 참고했다고 하니 충분히 기대해봐도 좋을 것 같고, github에 이미 꾀나 훌륭하게 만들어진 샘플들도 많으니 참고하셔서 학습하시면 많은 도움이 될 것 같습니다.

참고했던 글

  • https://louis993546.medium.com/mental-models-of-jetpack-compose-1-state-programming-models-cc0d47209720
  • http://intelligiblebabble.com/compose-from-first-principles/
  • https://medium.com/androiddevelopers/understanding-jetpack-compose-part-1-of-2-ca316fe39050
  • https://medium.com/mateedevs/bye-xml-it-was-nice-knowing-you-pt-1-50b195bab1a9
  • https://proandroiddev.com/why-do-we-need-jetpack-compose-d69a5fd20122
  • https://betterprogramming.pub/deep-dive-into-jetpack-compose-b09713760019
  • https://velog.io/@tura/android-jetpack-jetpack-compose-part-1-concepts-backgrounds
  • https://danielebaroncelli.medium.com/the-future-of-apps-declarative-uis-with-kotlin-multiplatform-d-kmp-part-1-3-c0e1530a5343

The one and only object(번역)

|

원본 - The one and only object

자바에서 static 키워드는 인스턴스화된 객체가 아닌, object와 관련된 메서드와 프로퍼티를 나타낼 때 쓰입니다. 그리고 굉장히 널리 쓰이는 디자인 패턴인 Singleton을 생성할 때도 쓰입니다. 싱글톤은 다른 여러 객체들에서 접근할 수 있는 오직 하나의 객체를 생성하는 것입니다.

코틀린은 싱글톤 패턴을 구현하기 위해 더 우아한 방법을 제공합니다. 바로 object 키워드입니다. Java와 Kotlin 이 Singleton을 구현하는 방법에 어떤 차이가 있는지, Kotlin에서 static 키워드를 사용하지 않고 어떻게 Singleton을 만드는지(먼저 말씀드리면 object 키워드를 사용합니다), 또 내부적으로 어떻게 동작하는지 궁금하시다면 끝까지 읽어주세요.

우선, 잠시 뒤로 물러나서 오직 단 하나의 객체인 Singleton이 왜 필요한지를 알아봅시다.

Singleton이란?

Singleton은 클래스가 단일 객체를 가지고 있고, 그 객체에 대해 global 접근이 가능한 패턴을 말합니다. 그래서 앱의 여러 부분에서 공유해서 사용할 객체이거나 객체를 생성할 때 많은 resource가 드는 경우에 유용하게 사용됩니다.

Singleton In Java

클래스가 단일 객체를 가지고 있음을 보장하려면 object 생성에 직접 관여를 해야합니다. 단일 객체를 가진 클래스를 만드려면, 클래스 생성자를 private으로 하고 단일 object에 대해 static하게 접근 할 수 있도록 만들어야 합니다. 한편 보통 싱글톤은 객체 생성 비용이 많이 들기 때문에 앱이 시작할 때 Singleton을 만들고 싶지는 않을 겁니다. 이런 상황을 위해 이미 생성된 객체가 있는지를 확인하는 static 메서드를 제공해줍니다. 이 메서드는 이미 생성된 instance가 있으면 그것을 반환하고, 없다면 새로 생성하여 반환합니다.

<!-- Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 -->

public class Singleton{
    private static Singleton INSTANCE;
    private Singleton(){}
    public static Singleton getInstance(){
        if (INSTANCE == null){
            INSTANCE = new Singleton();
        }
        return INSTANCE;
    }
    private int count = 0;
    public int count(){ return count++; }
}

위 코드는 겉보기엔 괜찮아 보일지 몰라도 thread-safe하지 못합니다. 하나의 쓰레드가 if문을 통과하였지만, 다른 쓰레드가 싱글톤을 생성하고 있었다면 작업을 잠시 멈추게 될 수 있습니다. 그러다가 다시 if문 안에서 resume되면, 그때는 또 다른 instance를 만들게 되겠죠.

<!-- Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 -->

public class Singleton{
    private static Singleton INSTANCE;
    private Singleton(){}
    public static Singleton getInstance(){
        if (INSTANCE == null) {                // Single Checked
            synchronized (Singleton.class) {
                if (INSTANCE == null) {        // Double checked
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
    private int count = 0;
    public int count(){ return count++; }
}

이문제를 해결하기 위해 double checked locking을 사용할 수 있습니다. 이를 사용하면 instance가 null일 때, synchronized 키워드가 lock을 걸고, 두 번째 if문에서 instance가 정말로 null인지를 다시 체크합니다. 만약 instance가 null이라면 Singleton을 만듭니다. 그러나 이것 만으로도 충분하진 않습니다. instance를 volatile로 선언해줘야합니다. Volitile 키워드는 변수가 동시에 동작하는 쓰레드들로 인해 비동기적으로 수정될 수 있음을 컴파일러에게 알려줍니다.

이 모든것은 여러분이 싱글톤이 필요할 때마다 작성해야 하는 보일러 플레이트 코드입니다. 사실 간단한 작업임에도 불구하고 코드는 꽤나 복잡하네요. 그래서 자바에서는 대부분 enum을 사용하여 싱글톤을 만들기도 합니다.

Singleton in Kotlin

코틀린은 어떨까요? 코틀린은 static method와 static fields가 없는데, 그렇다면 어떻게 싱글톤을 만들까요?

안드로이드 스튜디오나 IntelliJ를 사용하면 이해하기가 좀 더 수월해집니다. Java로 만들어진 Singleton을 Kotlin으로 변환해보면, 모든 static property들과 메서드들은 companion object로 옮겨집니다.

<!-- Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 -->

class Singleton private constructor() {
    private var count = 0
    fun count(): Int {
        return count++
    }

    companion object {
        private var INSTANCE: Singleton? = null// Double checked

        // Single Checked
        val instance: Singleton?
            get() {
                if (INSTANCE == null) { // Single Checked
                    synchronized(Singleton::class.java) {
                        if (INSTANCE == null) { // Double checked
                            INSTANCE =
                                Singleton()
                        }
                    }
                }
                return INSTANCE
            }
    }
}

우리의 예상대로 코드가 변환되었지만 이걸 좀 더 간단하게 변경할 수 있습니다. 간단하게 하기 위해서 생성자와 companion 키워드를 지우고 object 키워드를 클래스명 앞에 붙여주세요. objectcompanion objects의 차이는 글 아래에서 다루도록 할게요.

<!-- Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 -->

object Singleton {
    private var count: Int = 0

    fun count() {
        count++
    }
}

count() 메서드를 사용하고 싶다면, Singleton object를 통해서 접근할 수 있습니다. 코틀린에서 object는 단일 instance를 가지는 특별한 클래스입니다. class 대신 object키워드를 사용하여 class를 만들면, 코틀린 컴파일러는 생성자를 private하게 만들어주고, object에 대한 static reference를 만들어주고, static block 안에서 reference를 초기화 해줍니다.

Static block은 static field에 처음 접근할 때 딱 한번만 호출됩니다. JVM은 static block을 처리할 때, 비록 synchronized키워드가 없다고 하더라도 synchronized block과 매우 비슷하게 다룹니다. Singleton 클래스가 초기화 될 때, JVM synchronized block위에서 lock을 획득하여 다른 쓰레드가 동시에 접근할 수 없도록 해줍니다. lock이 풀릴때면 Singleton 객체는 이미 생성이 되었고 static block은 두번 다시는 수행되지 않습니다. 이렇게 Singleton 객체가 단 하나만 있음을 보장하게 됩니다. 게다가 이 object는 thread-safe함과 동시에 object에 처음 접근하는 순간 생성됩니다(lazliy-created).

그럼 디컴파일된 Kotlin byte code를 살펴보고 실제로 어떤 일이 일어났는지 확인해봅시다.

Kotlin class의 바이트 코드를 확인하려면 Tools > Kotlin > Show Kotlin Bytecode 를 선택하세요. Kotlin byte code가 나오면, Decompile을 눌러 decompile된 java code를 확인할 수 있습니다.

<!-- Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 -->

public final class Singleton {
   private static int count;
   public static final Singleton INSTANCE;
   public final int getCount() {return count;}
   public final void setCount(int var1) {count = var1;}
   public final int count() {
      int var1 = count++;
      return var1;
   }
   private Singleton() {}
   static {
      Singleton var0 = new Singleton();
      INSTANCE = var0;
   }
}

그러나 object도 한계가 있습니다. object 선언은 생성자를 가질 수 없기에 파라미터를 받을 수가 없습니다. 만약 파라미터를 받을 수 있다고 하더라도, 생성자에 전달된 non-static 파라미터는 static block에서 접근할 수 없기 때문에 의미가 없을 겁니다.

static 초기화 블록은 다른 static method처럼 오직 클래스의 static property에만 접근할 수 있습니다. Static block은 객체 생성 전에 호출되기 때문에 객체의 property나 생성자로 받아온 파라미터를 사용할 수 없습니다.

Companion object

companion object는 object랑 비슷합니다. companion object는 언제나 클래스 안에 선언되며 클래스의 프로퍼티에 접근할 수 있습니다. 또한 companion object는 굳이 이름이 필요없습니다. 만약 companion object에 이름을 부여하면, 호출하는 쪽에서 companion object의 이름을 사용하여 member에 접근할 수 있습니다.

<!-- Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 -->

class SomeClass {
    //…
    companion object {
        private var count: Int = 0
        fun count() {
            count++
        }
    }
}
class AnotherClass {
    //…
    companion object Counter {
        private var count: Int = 0
        fun count() {
            count++
        }
    }
}
// usage without name
SomeClass.count()
// usage with name
AnotherClass.Counter.count()

예를 들면 위에 코드에는 이름이 있는 companion object와 이름이 없는 companion object가 있습니다. count()를 호출하는 쪽에서는 마치 SomeClass의 static 멤버인 것 처럼 count()를 사용할 수 있습니다. 또한 AnotherClass의 경우 static 멤버를 사용하듯이 Counter를 사용하여 count() 메서드에 접근할 수 있습니다.

companion object는 private 생성자가 포함된 inner class로 디컴파일 됩니다. host 클래스는 오직 자신만 접근할 수 있는 synthetic 생성자를 통해 inner class를 초기화 합니다. 그리고 다른 클래스에서 companion object에 접근할 수 있도록 public refernece를 유지합니다.

<!-- Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 -->

public final class AnotherClass {
    private static int count;
    public static final AnotherClass.Counter Counter = new AnotherClass.Counter((DefaultConstructorMarker)null);

    public static final class Counter {
        public final void count() {
            AnotherClass.count = AnotherClass.count + 1;
        }
        private Counter() { }
        // $FF: synthetic method
        public Counter(DefaultConstructorMarker $constructor_marker) {
            this();
        }
    }

    public static final class Companion {
        public final void count() {
            AnotherClass.count = AnotherClass.count + 1;
        }
        private Companion() {}
    }
}

Object 표현식

지금까지 우리는 object keyword를 object 선언식으로만 살펴보았습니다. object keyword는 표현식으로도 사용될 수 있습니다. 표현식으로 사용하면, object keyword는 익명 객체와 익명 inner class를 생성할 수 있습니다.

잠시동안 어떤 값을 홀딩하고 있을 객체가 필요하다고 해봅시다. 객체를 선언하고, 원하는 값들과 함께 초기화 하여 추후에 접근할 수 있습니다.

<!-- Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 -->

val tempValues = object : {
    var value = 2
    var anotherValue = 3
    var someOtherValue = 4
}

tempValues.value += tempValues.anotherValue

generated code를 보면, 위의 코드는 으로 표기된 익명 Java class로 변환되어 getter와 setter와 함께 익명 객체가 저장됩니다.

<!-- Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 -->

<undefinedtype> tempValues = new Object() {
    private int value = 2;
    private int anotherValue = 3;
    private int someOtherValue = 4;

    // getters and setters for x, y, z
    //...
};

또한 object keyword는 boilerplate code 없이도 익명 클래스를 생성할 수 있도록 도와줍니다.

<!-- Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 -->

val t1 = Thread(object : Runnable {
    override fun run() {
         //do something
    }
})
t1.start()

//Decompiled Java
Thread t1 = new Thread((Runnable)(new Runnable() {
     public void run() {

     }
}));
t1.start();

object keyword는 thread-safe한 singleton을 생성하도록 도와줄 뿐만 아니라, 익명 객체와 익명 클래스를 별도의 추가 코드 없이 생성하도록 도와줍니다. object와 companion object를 사용하면 코틀린은 static keyword를 사용한 것과 동일한 기능을 하도록 코드를 생성해줍니다. 게다가 object를 표현식으로 사용하면 boilerpalte code 없이도 익명 객체와 익명 클래스를 만들 수 있습니다.

Explore Kotlin Annotations(번역)

|

원본 - Explore Kotlin Annotations

(자바와 함께 사용할 때 도움이 되는 Kotlin annotation들)

이번 포스팅에는 Android app을 개발할 때 쓰이는 Kotlin annotation들을 살펴보겠습니다. 그 중에서도 @JvmStatic, @JvmOverloads, @JvmField에 대해 알아볼게요.

구글이 Android 개발 언어를 Kotlin으로 공식 지정한 이후로, 많은 개발자 분들이 Java를 Kotlin으로 변경하고 있습니다. 그렇지만 한순간에 모든 Java 코드를 Kotlin으로 전환할 수는 없죠.

그래서 코드의 일부(Class, enum 등등)들은 Kotlin으로 전환하되, 일부는 java와 혼합해서 사용될 수 있습니다. 만약 여러분이 이런 상황에 놓여있다면, 아래에서 다룰 annotation들을 알아두셔야 해요.

@JvmStatic

코틀린에서는 pakage-level function이 static method로 표현됩니다. 또한 @JvmStatic annotation을 사용하여 일부 companion object나 이름있는 object에 정의된 함수에 대한 정적 메서드를 만들 수도 있습니다.

@Target([AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER]) annotation class JvmStatic

이 어노테이션이 함수에 적용된다면, 함수에 대해 추가적인 static method가 생성됩니다. 만약 프로퍼티에 적용된다면, 추가적으로 static getter/setter 메서드가 생성됩니다.

아래의 코드를 살펴볼게요.

class ClassName {
    companion object {
        @JvmStatic
        fun iAmStatic() {
            //body of Static function
        }
        fun iAmNonStatic() {
            //body of Non static function
        }
    }
}

iAmStatic()은 static function이고, iAmNonStatic()은 non-static function입니다. Java class에서 위의 메서드를 호출한다고 생각해보세요. 그 결과는 아래와 같습니다.

ClassName.iAmStatic(); // works fine
ClassName.iAmNonStatic(); // compile error
ClassName.Companion.iAmStaticMethod(); // works fine
ClassName.Companion.iAmNonStaticMethod(); // other way to work

이름있는 object의 경우에도 똑같이 적용됩니다.

object ObjectName {
    @JvmStatic fun iAmStatic() {
        //body of Static function
    }
    fun iAmNonStatic() {
        //body of Non static function
    }
}

Java에서 호출하면 결과는 아래와 같습니다.

ObjectName.iAmStatic(); // works fine
ObjectName.iAmNonStatic(); // compile error
ObjectName.INSTANCE.iAmStatic(); // works
ObjectName.INSTANCE.iAmNonStatic(); // works

@JvmOverloads

@Target([AnnotationTarget.FUNCTION, AnnotationTarget.CONSTRUCTOR]) annotation class JvmOverloads

이 어노테이션이 적용되면, 해당 함수의 default parameter 값을 대체하도록 컴파일러가 overloads를 생섭합니다. 만약 메서드가 N개의 파라미터를 가지고 있고, 그 중 M개가 default value를 가지고 있다면, M개의 overloads가 생성되는 것이죠. N-1개의 파라미터를 가진 것(default value 파라미터중 마지막 하나를 제외함) 한 개, N-2개의 파라미터를 가진 것 한 개, … 이렇게 결국 모든 overloads가 생성됩니다.

여기 두 개의 필드를 가진 클래스가 있습니다. 필드 하나는 초기화 되었고, 다른 하나는 그렇지 않습니다.

data class Customer( val name: String, val dob: Date = Date())

Customer 객체를 생성할 때, 두 번째 argument로 어떠한 값도 넘기지 않으면 현재 시간이 적용될 것입니다. 따라서 이를 코틀린에서 호출할 때는 별다른 컴파일 에러 없이 아래 코드로 수행할 수 있습니다.

val custOne = Customer("Don")
val custTwo = Customer("Don", Date())

Java에서 동일하게 호출하면 어떻게 될까요? Java에서는 모든 파라미터를 꼭 입력해주어야 합니다. 그렇지 않으면 컴파일 에러가 발생해요.

Customer custOne = new Customer("Don"); //Here we are passing only one argument, so we will get compile error
Customer custTwo = new Customer("Don", new Date());//No error

Java에서 호출할 때 역시 default value를 사용하려면, kotlin 코드에 @JvmOverloads를 적용시켜야 합니다. 어노테이션을 적용한 kotlin 코드는 아래와 같습니다.

Customer @JvmOverloads constructor( val name: String, val dob: Date = Date())

이제 Java에서 호출할 때도 모든 파라미터를 넘길 필요가 없어집니다.

Customer custOne = new Customer("Don"); //No Error
Customer custTwo = new Customer("Don", new Date());//No error

@JvmField

@Target([AnnotationTarget.FIELD]) annotation class JvmField

이 어노테이션이 적용되면, 컴파일러는 해당 property에 대한 getters/setters를 생성하지 않고 field자체로 노출시킵니다.

아래에는 일반적인 Java코드가 있습니다.

public class Customer {
    public String name;
    public Date dob;

    public getName() {
        return this.name;
    }
    public getDob() {
        return this.dob;
    }
}

이에 대응되는 Kotlin 코드는 아래와 같습니다.

data class Customer( val name: String, val dob: Date = Date())

만약 위 클래스의 프로퍼티에 접근하려면, 코틀린에서는 아래와 같이 접근하죠.

val customer = Customer("Don", Date())
val name = customer.name
val dob = customer.dob

그러나 자바에서는 아래처럼 getter 메서드를 사용해야 합니다.

Customer customer = new Customer("Don", new Date());
String name = customer.getName();
Date dob = customer.getDob();

만약 여러분이 특정 field에 대한 getter, setter를 원하지 않고 그저 일반적인 field로 사용하고 싶다면 @JvmField를 사용하여 컴파일러가 getter, setter를 생성하지 않도록 할 수 있습니다. @JvmField 어노테이션을 적용한 kotlin 코드는 아래와 같습니다.

data class Customer(@JvmField val name: String, val dob: Date = Date())

이제 Java에서도 아래처럼 field에 접근할 수 있습니다.

Customer customer = new Customer("Don", new Date());
String name = customer.name;

감사합니다.

The suspend modifier — under the hood(번역)

|

원본 - The suspend modifier — under the hood

컴파일러는 어떻게 코루틴 실행을 일시중지하고 재개하도록 코드를 변경할까요?

Kotline coroutine의 suspend modifier는 안드로이드 개발자에게 자연스럽게 스며들었습니다. 그 내부가 어떻게 동작하는지 궁금하지 않으신가요? 컴파일러는 도대체 어떻게 코루틴을 일시 중지하고 다시 재개하도록 코드를 변경해주는걸까요?

이 원리를 알면 suspend 함수가 왜 자신의 일을 끝마치기 전까지는 return하지 않는지, 그리고 어떻게 쓰레드를 block하지 않고 일시 중지할 수 있는지를 이해하는데 도움이 됩니다.

요약; 코틀린 컴파일러는 모든 suspend 함수에 대해서 코루틴 실행 관리를 위한 state machine을 생성해 줍니다.

Android에서 코루틴을 사용하는게 익숙치 않으신가요? 아래의 coroutine 코드랩을 살펴보세요.

비디오로 시청하는 것을 더 좋아하시면 아래 영상을 참고하세요.

https://www.youtube.com/watch?time_continue=472&v=IQf-vtIC-Uc&feature=emb_title

Coroutines 101

코루틴은 비동기 연산을 간단하게 해줍니다. 문서에 설명된것 처럼, 비동기 처리가 아니었다면 메인 쓰레드를 block하고 결국 앱을 멈춰버리는 작업을 위해 코루틴을 사용합니다.

코루틴은 가독성이 좋지 않은 콜백 기반 API들을 직관적으로 보이게끔 해줍니다. 예를들어, 아래의 콜백 기반 코드를 살펴봅시다.

// Simplified code that only considers the happy path
fun loginUser(userId: String, password: String, userResult: Callback<User>) {
  // Async callbacks
  userRemoteDataSource.logUserIn { user ->
    // Successful network request
    userLocalDataSource.logUserIn(user) { userDb ->
      // Result saved in DB
      userResult.success(userDb)
    }
  }
}

이 콜백들은 코루틴을 사용하여 순차적인 코드로 변경될 수 있습니다.

suspend fun loginUser(userId: String, password: String): User {
  val user = userRemoteDataSource.logUserIn(userId, password)
  val userDb = userLocalDataSource.logUserIn(user)
  return userDb
}

함수 앞에 suspend modifer를 추가하였습니다. 이는 해당 함수가 코루틴 내부에서 실행되야 함을 컴파일러에게 알려줍니다. 일반적으로 suspend 함수를 특정 지점에서 멈출 수 있고, 다시 재개할 수 있는 보통의 함수라고 생각해도 좋습니다.

콜백과는 다르게 코루틴은 쓰레드 변경을 쉽게 할 수 있으며 예외 처리도 쉽습니다.

그런데 함수를 suspend로 사용하면 컴파일러는 정확히 어떤 일을 해주는걸까요?

Suspend under the hood

다시 loginUser susupend 함수로 돌아가서, suspend 함수를 호출하는 함수 역시 suspend 함수여야 합니다.

suspend fun loginUser(userId: String, password: String): User {
  val user = userRemoteDataSource.logUserIn(userId, password)
  val userDb = userLocalDataSource.logUserIn(user)
  return userDb
}

// UserRemoteDataSource.kt
suspend fun logUserIn(userId: String, password: String): User

// UserLocalDataSource.kt
suspend fun logUserIn(userId: String): UserDb

코틀린 컴파일러는 내부적으로 유한 상태 머신(finite state machine)을 사용하여 suspend 함수를 최적화된 콜백 형태로 바꿉니다(밑에서 자세히 다룹니다).

결론적으로, 컴파일러는 여러분 대신하여 최적화된 콜백들을 작성해줍니다!

Continuation interface

suspend 함수들은 Continuation객체를 통해 다른 suspend 함수들과 소통합니다. Continuation 는 몇가지 추가 정보를 가진 제네릭 callback interface일 뿐입니다. 나중에 보시겠지만, 이는 suspend 함수의 generated state machine을 나타냅니다.

Continuation의 정의는 아래와 같습니다.

interface Continuation<in T> {
  public val context: CoroutineContext
  public fun resumeWith(value: Result<T>)
}
  • context 는 continuation 내부에서 사용될 CoroutineContext입니다.
  • resumeWithResult와 함께 코루틴을 재개합니다. 이는 일시 중지를 유발하는 연산의 결과를 포함할 수도 있고 예외를 포함할 수 도 있습니다.

주의 : Kotlin 1.3부터는 resume(value: T)resumeWithException(exception: Throwable) 확장 함수를 사용할 수 있습니다. 이들은 resumeWith 호출의 특별판이라고 보시면 됩니다.

컴파일러는 suspend modifier를 함수의 추가 파라미터인 completeion(Continuation 타입)으로 바꿉니다. 이 completion은 suspend 함수의 결과를 가지고 자신을 호출한 코루틴과 소통할 때 사용됩니다.

fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
  val user = userRemoteDataSource.logUserIn(userId, password)
  val userDb = userLocalDataSource.logUserIn(user)
  completion.resume(userDb)
}

간단하게 하기 위해서, 우리 예제는 User대신 Unit을 리턴하고 있습니다. User 객체는 추가된 Continuation 파라미터 안으로 return 됩니다.

실제로 suspend 함수들의 바이트코드는 Any?를 리턴하는데요, 이는 Any?가 T | COROUTINE_SUSPENDED의 union type이기 때문입니다. 따라서 함수가 리턴이 가능할 때가 되면 동기적으로 리턴하게끔 됩니다.

주의 : 만약 내부적으로 어떤 suspend 함수도 호출하지 않는 함수를 suspend 함수로 지정하면, 컴파일러는 여전히 Continuation 파라미터를 추가하긴 하겠지만 그걸로 어떤 일도 하지 않습니다. 그 함수의 바이트 코드는 일반적인 다른 함수와 다를게 없습니다.

다른 곳에서도 Continuation 인터페이스를 볼 수 있습니다.

  • suspendCoroutine 혹은 suspendCancellableCoroutine(대부분의 경우 이게 더 권장됩니다)를 사용하여 callback기반 API를 코루틴으로 바꿀 때, 파라미터로 전달된 코드 블럭을 실행 한 후 중단된 코루틴을 재개하기 위해 Continuation 객체와 직접 상호작용합니다.
  • suspend 함수에서 startCoroutine 확장 함수를 사용하여 coroutine을 시작할 수 있습니다. 이 함수는 Continuation객체를 파라미터로 받는데요, 이는 새 코루틴이 결과를 반환하든, 예외륵 반환하든 완료될 때 호출됩니다.

Using different Dispatchers

복잡한 계산을 서로 다른 쓰레드에서 실행하도록 서로 다른 Dispatcher를 바꿔가며 작업할 수 있습니다. Kotlin은 어떻게 어디서 일시 중지된 작업을 재개 할 지 알 수 있을까요?

Continuation의 하위 타입인 DispatchedContinuation라는게 있습니다. 이 클래스의 resume 함수는 Dispatcher의 dispatch 호출을 CoroutineContext 내에서 사용가능하도록 합니다. 모든 Dispatcher들은 isDispatchNeeded 함수(dispatch전에 호출됨)가 항상 false를 반환하는 Dispatchers.Unconfined를 제외하고 전부 dispatch를 호출합니다.

The generated State machine

아래에서 보시는 코드는 컴파일러에 의해 생성되는 바이트코드와 완전히 일치하지는 않습니다. 단지 내부적으로 어떻게 동작하는지를 설명하기 위한 코틀린 코드입니다. Coroutine version 1.3.3 기준으로 작성되었고, version이 바뀜에 따라 차이가 생길 수 있습니다.

Kotlin 컴파일러는 함수가 일시중지될 수 있는 시기를 내부적으로 식별합니다. 모든 일시 중지(suspension) 지점은 유한 상태 기계로 표현됩니다. 이 상태들은 컴파일러에 의해 label들로 표현됩니다.

fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {

  // Label 0 -> first execution
  val user = userRemoteDataSource.logUserIn(userId, password)

  // Label 1 -> resumes from userRemoteDataSource
  val userDb = userLocalDataSource.logUserIn(user)

  // Label 2 -> resumes from userLocalDataSource
  completion.resume(userDb)
}

상태 기계를 더 잘 표현하기 위해서, 컴파일러는 when문을 사용합니다.

fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
  when(label) {
    0 -> { // Label 0 -> first execution
        userRemoteDataSource.logUserIn(userId, password)
    }
    1 -> { // Label 1 -> resumes from userRemoteDataSource
        userLocalDataSource.logUserIn(user)
    }
    2 -> { // Label 2 -> resumes from userLocalDataSource
        completion.resume(userDb)
    }
    else -> throw IllegalStateException(/* ... */)
  }
}

이 코드는 서로 다른 state들이 서로 정보를 공유할 수 있는 방법이 없기 때문에 완전하지 못합니다. 컴파일러는 이를 해결하기 위해 동일한 Continuation을 사용합니다. Continuation의 제네릭 타입이 원래 함수의 리턴 타입(User)이 아닌 Any?인 이유가 바로 이것이죠.

게다가 컴파일러는 필요한 data를 hold하면서도 loginUser 함수를 재귀적으로 호출하여 실행을 재개할 수 있도록 private class를 생성합니다. 아래에서 generated class와 거의 흡사한 클래스를 확인하실 수 있습니다.

주석은 컴파일러에 의해 생성된게 아닙니다. 코드를 이해하기 쉽도록 제가 임의로 추가했습니다.

fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {

  class LoginUserStateMachine(
    // completion parameter is the callback to the function
    // that called loginUser
    completion: Continuation<Any?>
  ): CoroutineImpl(completion) {

    // Local variables of the suspend function
    var user: User? = null
    var userDb: UserDb? = null

    // Common objects for all CoroutineImpls
    var result: Any? = null
    var label: Int = 0

    // this function calls the loginUser again to trigger the
    // state machine (label will be already in the next state) and
    // result will be the result of the previous state's computation
    override fun invokeSuspend(result: Any?) {
      this.result = result
      loginUser(null, null, this)
    }
  }
  /* ... */
}

invokeSuspend에서 loginUserContinuation객체를 넘기며 계속 다시 호출하기 때문에, loginUser 함수의 나머지 파라미터들은 nullable이 됩니다. 이 지점에서, 컴파일러는 어떻게 state를 이동할 것인지에 대한 정보만 추가하면됩니다.

가장 먼저 해야할 일은 1)지금이 함수가 처음 호출된 상태인지를 알아야 하고, 2)함수가 이전 state에서 재개(resume)된 상태인지를 알아야 합니다. 이는 전달된 continuation이 LoginUserStateMachine 타입인지 아닌지를 통해 알 수 있습니다.

만약 처음 호출된 상황이라면, LoginUserStateMachine 객체를 생성하고 파라미터로 받은 completion 객체를 저장하여 이 인스턴스를 호출한 함수를 어떻게 다시 시작하는지 그 방법을 기억합니다. 만약 처음 호출된게 아니라면, 상태 기계(suspend function)를 계속 실행합니다.

이제 state 이동 및 state간 정보 공유를 위해 컴파일러가 생성한 코드를 살펴봅시다.

fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
    /* ... */

    val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)

    when(continuation.label) {
        0 -> {
            // Checks for failures
            throwOnFailure(continuation.result)
            // Next time this continuation is called, it should go to state 1
            continuation.label = 1
            // The continuation object is passed to logUserIn to resume
            // this state machine's execution when it finishes
            userRemoteDataSource.logUserIn(userId!!, password!!, continuation)
        }
        1 -> {
            // Checks for failures
            throwOnFailure(continuation.result)
            // Gets the result of the previous state
            continuation.user = continuation.result as User
            // Next time this continuation is called, it should go to state 2
            continuation.label = 2
            // The continuation object is passed to logUserIn to resume
            // this state machine's execution when it finishes
            userLocalDataSource.logUserIn(continuation.user, continuation)
        }
          /* ... leaving out the last state on purpose */
    }
}

이전 코드 스니펫과 다른점들이 보이시나요? 컴파일러가 생성한 것들을 살펴봅시다.

  • when 문의 argument가 LoginUserStateMachine객체의 label이 되었습니다.
  • 새로운 state가 진행될 때마다, 함수가 일시 중지되었을 때 오류가 발생했는지 확인합니다.
  • 다음 suspend 함수(예-logUserIn)를 호출하기 전에, LoginUserStateMachinelabel이 다음 state를 위해 업데이트됩니다.
  • state machine 내부에서 다른 suspend function을 호출할 때, contination 객체(LoginUserStateMachine 타입)를 파라미터로 넘깁니다. 호출될 suspend function 역시 컴파일러에 의해 변경되어 있으며 continuation 객체를 파라미터로 받는 또다른 상태 머신입니다! 그 suspend function의 상태 머신이 종료되면, 이 상태 머신을 다시 실행합니다.

마지막 state는 본인을 호출한 함수의 실행을 재개해야하기 때문에 상황이 조금 다릅니다. 아래 코드와 같이 LoginUserStateMachine에 저장된 cont 변수의 resume을 호출해야 합니다.

fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
    /* ... */

    val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)

    when(continuation.label) {
        /* ... */
        2 -> {
            // Checks for failures
            throwOnFailure(continuation.result)
            // Gets the result of the previous state
            continuation.userDb = continuation.result as UserDb
            // Resumes the execution of the function that called this one
            continuation.cont.resume(continuation.userDb)
        }
        else -> throw IllegalStateException(/* ... */)
    }
}

보셨다시피 컴파일러는 상당히 많은 일을 대신 해줍니다! 아래와 같은 suspend function을

suspend fun loginUser(userId: String, password: String): User {
  val user = userRemoteDataSource.logUserIn(userId, password)
  val userDb = userLocalDataSource.logUserIn(user)
  return userDb
}

이렇게 만들어주죠.

fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {

    class LoginUserStateMachine(
        // completion parameter is the callback to the function that called loginUser
        completion: Continuation<Any?>
    ): CoroutineImpl(completion) {
        // objects to store across the suspend function
        var user: User? = null
        var userDb: UserDb? = null

        // Common objects for all CoroutineImpl
        var result: Any? = null
        var label: Int = 0

        // this function calls the loginUser again to trigger the
        // state machine (label will be already in the next state) and
        // result will be the result of the previous state's computation
        override fun invokeSuspend(result: Any?) {
            this.result = result
            loginUser(null, null, this)
        }
    }

    val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)

    when(continuation.label) {
        0 -> {
            // Checks for failures
            throwOnFailure(continuation.result)
            // Next time this continuation is called, it should go to state 1
            continuation.label = 1
            // The continuation object is passed to logUserIn to resume
            // this state machine's execution when it finishes
            userRemoteDataSource.logUserIn(userId!!, password!!, continuation)
        }
        1 -> {
            // Checks for failures
            throwOnFailure(continuation.result)
            // Gets the result of the previous state
            continuation.user = continuation.result as User
            // Next time this continuation is called, it should go to state 2
            continuation.label = 2
            // The continuation object is passed to logUserIn to resume
            // this state machine's execution when it finishes
            userLocalDataSource.logUserIn(continuation.user, continuation)
        }
        2 -> {
            // Checks for failures
            throwOnFailure(continuation.result)
            // Gets the result of the previous state
            continuation.userDb = continuation.result as UserDb
            // Resumes the execution of the function that called this one
            continuation.cont.resume(continuation.userDb)
        }
        else -> throw IllegalStateException(/* ... */)
    }
}

코틀린 컴파일러는 모든 suspend 함수를 상태 머신으로 변경하여 함수를 일시 중지해야 할 때마다 콜백을 사용해 최적화합니다.

컴파일러가 내부적으로 어떻게 동작하는지 알게 되었으니, suspend 함수가 왜 일을 모두 끝마치기 전까지 return하지 않는지 이해하셨으리라 생각합니다. 또한 어떻게 thread를 block하지 않고 일시중지 하는지도요(함수가 다시 시작될 때 필요한 정보들이 Continuation 객체에 저장됩니다!)