쾌락코딩

Dialog Fragment 모서리 둥글게 하기 (round corner)

|

DialogFragment를 상속받아 커스텀 다이얼 로그를 띄울 때 아래 그림처럼 모서리를 둥글게 하기 위해서는 커스텀 다이얼로그 클래스에 몇가지 코드가 필요하다. image

layout 파일

<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@drawable/alert_white_border"
        >
        ...
        <!-- 추가로 넣고 싶은 요소들 -->
        ...
</androidx.constraintlayout.widget.ConstraintLayout>

@drawable/alert_white_border

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@android:color/white" />
    <corners android:radius="20dp" />
</shape>

이제 CustomDialog클래스에 layout을 inflate 시키면 끝날것 같지만 그냥 inflate시키면 모서리가 여전히 직각이다. 따라서 아래의 두 코드를 삽입해주자.

dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
dialog?.window?.requestFeature(Window.FEATURE_NO_TITLE)

CustomDialog

class CustomDialog : DialogFragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val view = inflater.inflate(R.layout.dialog_detail_info, container,false)
        dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
        dialog?.window?.requestFeature(Window.FEATURE_NO_TITLE)

        ...

        return view
    }

}

dialog?.window?.requestFeature(Window.FEATURE_NO_TITLE) 이코드를 삽입 하지 않아도 모서리는 둥글게 나오지만, android version 4.4 이하에서는 blue line이 다이얼로그 상단에 나타난다고 하니 꼭 넣어주자.

코틀린 코루틴(coroutine) 개념 익히기

|

앞서 코루틴을 이해하기 위한 두 번의 발악이 있었지만, 이번에는 더 원론적인 코루틴에 대해서 알아보려 한다. 코루틴의 개념이 정확하게 서야 응용도 가능하기에 이번 포스팅으로 개념을 확실하게 알아갔으면 좋겠다.

인트로

image

코루틴은 코틀린만의 것이 아니다. 이름이 비슷해서 코틀린의 것이라고 생각할 수 있지만 파이썬, C#, Go, Javascript 등 여러 언어에서 지원하고 있는 개념이다. Javascript를 사용하고 있으면서 async await를 사용하고 있다면 이미 코루틴을 사용해본 경험이 있는것이다. 아무튼 코루틴은 새로운 개념, 새로운 기술이 아니라 프로그래밍이 세상에 나온 초창기 부터 존재하던 개념이다.

우리가 코틀린 코루틴을 공부해볼 가치가 있는 이유

image

앱이든 웹이든 비동기 처리가 핵심인 클라이언트 프로그래밍에서 지금까지 가장 핫한 키워드는 rx programming일 것이다. 그러나 구글이 안드로이드 공식 언어를 자바에서 코틀린으로 변경한 이후, 최근 들어 대표적인 샘플 예제들인 bluprintsunflower 앱의 비동기처리를 coroutine으로 바꾸었다(아직 rx로 짠 코드를 특정 브랜치로 남겨놓고 있긴하다). Rx 라이브러리를 걷어내고 코루틴으로 새로 작성한 것이다. 이와 더불어 상당히 많은 외국 자료들이 올라오고있다. 그 이유는 코루틴을 사용하면 비동기 처리가 너무나도 쉽게 이루어 질 수 있기 때문이라고 생각한다. 이런 이유 만으로 코루틴을 공부해 볼 가치는 충분하다.

코루틴이란!

이제 코루틴이 무엇인지 한번 알아보자. 코루틴을 3가지 키워드 정도로 알아보려고 한다.

  1. 협력형 멀티 태스킹
  2. 동시성 프로그래밍 지원
  3. 비동기 처리를 쉽게 도와줌

가장 중요한 개념은 1번, 협력형 멀티 태스킹이다. 사실 협력형 멀티태스킹에 대한 내용을 이해한다면 코루틴이란 것을 다 알게되는 것이다. 그러나 코루틴을 내 것으로 만들기 위해서는 동시성 프로그래밍과, 비동기 처리에 대한 관점에서 이해하는 것도 중요하다.

협력형 멀티태스킹

협력형 멀티태스킹을 프로그래밍 언어로 표현하자면 Co + Routine 이다. Co라는 접두어는 “협력”, “함께”라는 의미를 지니고 있다. Routine은 하나의 태스크, 함수 정도로 생각하면 된다. 즉 협력하는 함수다. 더 진도를 나가기에 앞서 Routine에 대해서 좀 더 알아보자.

Routine에는 우리가 흔히 알고있는 main routine과 sub routine이 존재한다. 이런 단어들이 생소할 수도 있지만, 우리가 늘 작성하고 있는 코드들이다. image

위의 자바 코드를 보자(이후부터는 코틀린으로 다룬다). main함수가 말 그대로 Main 함수다. 메인이 되는 함수인 것이다. 그리고 메인이 되는 함수는 다른 서브 함수인 plusOne을 호출한다. 우리가 짜는 프로그램은 흔이 이렇게 되어있다. 너무 익숙한 흐름이라 어려울 것이 전혀없다.

그런데 이 Sub Routine을 살펴보면 한가지 특징이 있다. 아래 그림을 보자. image

Sub Routine은 루틴에 진입하는 지점과 루틴을 빠져나오는 지점이 명확하다. 즉, 메인 루틴이 서브루틴을 호출하면, 서브루틴의 맨 처음 부분에 진입하여 return문을 만나거나 서브루틴의 닫는 괄호를 만나면 해당 서브루틴을 빠져나오게 된다.

다시 코드로 돌아가 보자(이번엔 코틀린 코드).

image

메인 쓰레드가 plusOne이라는 서브루틴에 진입한다. 당연히 코드는 처음부터 진입이 되어 맨 윗줄부터 실행이 될것이고, 그 아래 코드들을 쭉쭉 실행해서 return문을 만나면 서브루틴을 호출했던 부분으로 탈출한다. 그리고 진입점과 탈출점 사이에 쓰레드는 블락되어있다. 우리가 늘 짜는 코드라 이해가 쉽다.

그러나 코루틴(Coroutine)은 조금 다르다. image

이때까지 코루틴을 사용하지 않았다면 혼란스러운 그림일 수도 있지만 이해해보자!

코루틴도 routine이기 때문에 하나의 함수로 생각하자. 그런데 이 함수에 진입할 수 있는 진입점도 여러개고, 함수를 빠져나갈 수 있는 탈출점도 여러개다. 즉, 코루틴 함수는 꼭 return문이나 마지막 닫는 괄호를 만나지 않더라도 언제든지 중간에 나갈 수 있고, 언제든지 다시 나갔던 그 지점으로 들어올 수 있다.

이것도 역시 코드로 알아보자. image

drawPerson이라는 함수가 있다. 이 함수 안에는 startCoroutine이라는 코루틴 빌더가 있다(실제로 startCoroutine이라는 빌더는 존재하지 않는다. 실제 코루틴 라이브러리에는 다른방식으로 코루틴을 만들지만 여기서는 이해를 위해 쉽게 startCoroutine이라고 사용한다).

startCoroutine이라는 코루틴을 만나게 되면 해당 함수는 코루틴으로 작동할 수 있다. 따라서 언제든 함수 실행 중간에 나갈 수도 있고, 다시 들어올 수도 있는 자격이 부여되는 것이다. 언제 코루틴을 중간에 나갈수 있을까? suspend로 선언된 함수를 만나면 코루틴 밖으로 잠시 나갈 수 있다.

이제 순서를 따라 가보자.

  1. 쓰레드의 Main함수가 drawPerson()을 호출하면 startCoroutine블럭을 만나 코루틴이 된다(정확하게는 하나의 코루틴을 만들어 시작한다). 위에도 말했듯이 이제 drawPerson()은 진입점과 탈출점이 여러개가 되는 자격이 주어진 것이다.

  2. 코루틴이 실행이 되었지만, suspend를 만나기 전까지는 그다지 특별한 힘이 없다. suspend로 정의된 함수가 없다면 그냥 마지막 괄호를 만날 때 까지 계속 실행된다. 그러나 drawHead()suspend 키워드로 정의되어진 함수다. 따라서 drawHead() 부분에서 더 이상 아래 코드를 실행하지 않고 drawPerson()이라는 코루틴 함수를 (잠시)탈출한다.

  3. 메인 쓰레드가 해당 코루틴을 탈출했다. 그렇다고 쓰레드가 놀고 있을리는 없다. 우리가 짜 놓은 다른 코드들을 실행할 수도 있고, 안드로이드라면 UI 애니메이션을 처리 할 수도 있다. 그러나 Head는 어디선가 계속 그려지고 있다. drawHead()는 2초가 걸리는 suspend 함수였음을 기억해보자. drawHead()라는 suspend를 만나 코루틴을 탈출했지만, drawHead() 함수의 기능은 메인쓰레드에서 동시성 프로그래밍으로 작동하고 있을수도 있고, 다른 쓰레드에서 돌아가고 있을 수도 있다. 그것은 개발자가 자유롭게 선택할 수 있다(이해가 되지 않아도 좋다. 바로 뒤에서 다시 설명한다).

  4. 그렇게 메인쓰레드가 다른 코드들을 실행하다가도, drawHead()가 제 역할을 다 끝내면 다시 아까 탈출했던 코루틴 drawPerson()으로 돌아온다. 아까 멈추어놓았던 drawHead() 아래인 drawBody()부터 재개(resume)된다.

위의 과정에서 보았듯이 코루틴 함수는 언제든지 나왔다가 다시 들어올 수 있다. 이 대목에서 이미 눈치를 챈 분들도 있을것 같은데, 코루틴의 이런 성향은 동시성 프로그래밍과 밀접한 관계가 있다.

동시성 프로그래밍

함수를 중간에 빠져나왔다가, 다른 함수에 진입하고, 다시 원점으로 돌아와 멈추었던 부분부터 다시 시작하는 이 특성은 동시성 프로그래밍을 가능하게 한다.

동시성 프로그래밍의 개념을 잡고가자면, 병렬성 프로그래밍과 완전히 다른 개념이다. 예를 들어 양쪽에 놓여진 두 개의 도화지에 사람 그림을 각자 그린다고 가정해보자. image

동시성 프로그래밍이란 오른쪽 손에만 펜을 쥐고서 왼쪽 도화지에 사람 일부를 조금 그리고, 오른쪽 도화지에 가서 잠시 또 사람을 그리고, 다시 왼쪽 도화지에 사람을 찔끔 그리고… 이 행위를 아주 빨리 반복하는 것이다. 사실 내가 쥔 펜은 한 순간에 하나의 도화지에만 닿는다. 그러나 이 행위를 멀리서 본다면 마치 동시에 그림이 그려지고 있는 것 처럼 보일 것이다. 이것이 동시성 프로그래밍이다.

병렬성 프로그래밍은 이 것과 다르다. 병렬성은 실제로 양쪽 손에 펜을 하나씩 들고서 왼쪽과 오른쪽에 실제로 동시에 그리는 것이다. 같은 시간동안 두 개의 그림을 그리는 것이다.

코루틴은 개념자체로만 보면 병렬성이아니라 동시성을 지원하는 개념이다. 위의 설명을 코드로 다시 살펴보자. image

코루틴도 루틴이다. 즉 쓰레드가 아니라 일반 서브루틴과 비슷한 루틴이기 때문에 하나의 쓰레드에 여러개가 존재할 수 있다.

위의 코드에서는 메인 쓰레드에 코루틴이 두 개가 있다. 하나는 왼쪽 도화지에 그림을 그리는 코드고 다른 하나는 오른쪽 도화지에 그림을 그리는 코드다. 메인 쓰레드가 실행되면서 먼저 왼쪽 코루틴인 drawPersonToPaperA()라는 함수를 만났다고 가정해보자. 해당 함수는 가상 코루틴 빌더인 startCoroutine {} 블럭으로 인해 코루틴이 되고, 함수를 중간에 나갔다가 다시 들어올 수 있는 힘을 얻게된다. drawPersonToPaperA()가 호출되어 suspend함수인 drawHead()를 만나게 되면 이 코루틴을 잠시 빠져나간다.

왼쪽 코루틴을 빠져나갔지만 그렇다고 메인쓰레드가 가만히 놀고있진 않는다. 다른 suspend 함수들을 찾거나 resume되어지는 다른 코드들을 찾는다. 왼쪽 코루틴의 경우 2초 동안 drawHead()작업을 하게된다. 그러나 delay(2000)는 쓰레드를 블락시키지 않으므로 다른 일들을 할 수가 있다. 뿐만 아니라 drawHead()함수 안에서 다른 쓰레드를 실행시킨다면 병행적으로도 실행이 가능하다. 왼쪽 코루틴을 빠져나온 쓰레드가 오른쪽 코루틴을 만나게 되어 또 한번 suspend 함수를 만나게 되면 아까 도화지 그림에서 설명한 것과 같은 현상이 일어난다. 아까 오른손에 펜을 쥐고 왼쪽과 오른쪽 도화지를 아주빠르게 왔다 갔다 하면서 그림을 그리는 것 같은 셈이다. 이렇게 코루틴을 사용하여 쓰레드 하나에서 동시성 프로그래밍이 가능하다

코루틴을 생성해서 동시성 프로그래밍을 하는 것은, 쓰레드를 사용해서 동시성 프로그래밍을 하는 것과 차원이 다른 효율성을 제공한다. 위에서 말한 동작을 쓰레드 두 개를 만들어 실행한다고 가정해보자. image

왼쪽 쓰레드는 왼쪽 도화지에, 오른쪽 쓰레드는 오른쪽 도화지에 그림을 그리는 쓰레드이다. 그러나 CPU는 단 한 개뿐이다. 따라서 왼쪽에 조금, 오른쪽에 조금을 반복하기 위해선 CPU가 매번 쓰레드를 점유했다가 놓아주고, 새로운 쓰레드를 점유했다가 놓아주고를 반복해야 한다. 이를 컨텍스트 스위칭이라고 한다. 하나의 쓰레드에서 단순히 함수를 왔다 갔다 하는것과는 다르게 꽤 비용이 드는 작업이다.

아래는 “함수형 코루틴”이라는 책에나오는 구문이다. image

비동기 처리가 이렇게 편해지다니

위에서 설명한 코틀린의 능력으로 비동기 처리가 굉장히 쉬워진다. 심지어는 이게 비동기 처리인가? 싶을 정도로 읽기 쉽고 짜기도 쉬운 코드를 볼 수 있다.

한 가지 예시를 들어보겠다. 필자의 아침 기상으로부터 회사에 도착하기 까지의 과정이다.

  1. 8시 기상
  2. 샤워하기
  3. 옷입기
  4. 출발하기
  5. 회사도착하기
  6. 일하기

위의 시나리오는 꼭 순서대로 이뤄져야하는 작업이다. 기상하지도 않았는데 옷을 입을수 없고, 옷을 입지 않았는데 출근을 할 수도 없다. 또한 각 과정은 시간이 오래 걸리는 작업이라고 가정하자. 코드로 치면 네트워크 처리쯤?

callback

가장 먼저 callback을 사용해 구현해보자. image

콜백으로 비동기 처리를 구현했을 때 흔히 겪을 수 있는 콜백 헬이다. 심지어 에러 처리도 하지 않는 코드인데 벌써 보기가 어렵다. 비동기 처리를 위한 다양한 방법론이 나오기 전에는 대부분 이런방식으로 처리했다.

안드로이드 프로그래밍에서 공기와도 같은 존재가 된 RxJava, RxKotlin으로 짜여진 동일한 코드를 보자.

RxKotlin

image

콜백 코드보다 훨씬 눈에 잘 들어오는 것 같다. 왜 잘들어 올까? wakeUp(), takeShower(), putOnShirt() 등등의 함수들이 보통 사람이 생각하는 것 처럼 순차적으로 보이기 때문이 아닐까? 즉 각 과정의 함수들이 동일한 depth를 유지하며 동기적인 코드처럼 보이기 때문에 훨씬 보기가 편해졌다.

그런데 Rx를 모르는 사람이 이 코드를 본다면 어떨까? Observable이 무엇인지, just가 무엇인지, flatMap은 무엇인지… 코드를 보면 어지러울수도 있다. 뿐만 아니라 사실 Rx가 제공하는 마법같은 operation들이 상당히 많기 때문에 학습 곡선이 꽤 높기로 유명하다.

하지만 Rx를 아는사람들 끼리는 너무나도 좋은 도구인것은 확실하며 높은 학습 곡선 만큼 효율성도 크다. 아무튼 Rx로 짜면 이런 식이다.

Kotlin + coroutine

이번엔 마지막으로 Kotlin + coroutine 조합을 보자. image

“이게 비동기 처리를 하는 코드라고?”라는 생각이 들 수도 있다. 그러나 분명히 비동기 코드다! 안에서 호출되는 각 함수들은 분명히 네트워크를 타고 실행되야하는 오래걸리는 작업이고, 언제 끝날지 모르는 비동기 작업들이지만 각자 함수들의 순서는 정확히 지켜진다. takeShower()함수는 wakeUp()함수가 끝나야만 실행되고, putOnShirt()함수는 takeShower()함수가 끝나야만 실행된다.

이게 가능한 이유는, goCompany라는 함수가 코루틴이기에 wakeUp을 만나면 wakeUp함수를 실행함과 동시에(여기서는 백그라운드 스레드에서 동시에 실행될 것이다.) 잠시 goCompay를 빠져나간다. 그러다가 wakeUp이 자신의 일을 끝마치면 다시 goCompany로 돌아올 수 있기 때문이다. 이게 코루틴으로 비동기 처리를 할 때 생기는 장점이다.

결론

비동기 처리에 있어서 Rx는 매우 훌륭한 도구이고 나 역시 코루틴을 공부하기 전까지는 더 할나위 없는 기술이라 생각하며 사용해 왔다. 그러나 점점 생각이 바뀌었다. Rx를 사용하는 가장 주된 이유 중 하나가 비동기 처리인데, 결국은 Rx도 비동기 처리를 쉽게 하기 위한 하나의 발악이라고 볼 수 있기 때문이다(물론 데이터를 흐름으로 바라보며 처리한다는 점은 훌륭하다). 결국 발악은 발악이고 궁극적으로 비동기 처리를 위하여 여러가지 헬퍼 코드들이 덕지 덕지 붙게 된다. 반면 코루틴은 비동기 처리를 하기 위해 그런 발악, 또는 시도를 거부하는 것 처럼 느껴진다. 마치 “왜 비동기 처리 때문에 고생하니? 그냥 동기처럼 짜면 그만인데”라고 이야기하는 것 같다.

코루틴을 공부하고도 Rx를 더 선호하는 사람이 있을 수 있고, 코루틴에 푹 빠지게 되는 사람도 있을 것이다. 무엇이 더 좋고 더 나쁜가에 대한 정답은 없지만, 개인 적인 생각으로는 비동기 처리의 궁극적인 모습은 마치 비동기 코드가 아닌것 처럼 짜는 모습이 아닐까 생각한다.

Bonus

코루틴이 무엇인지, 사용하면 어떤 이점이 있는지에 대해서 글을 작성해보았다. 개인적으로 코루틴을 공부할때 한 가지 궁금했던점이 있었다. “대충 개념은 알겠는데… 어떻게 함수를 중간에 왔다 갔다 할수 있는거지?”에 대한 궁금증이었다.

이걸 가능하게 하는 방법론이라고 해야할까? 키워드라 해야할까. 아무튼 이 글에서 다루기에는 또 너무 방대한 이야기가 되므로 툭 던져놓자면 CPS(Continuatino Passing Style)이다.

Fragment 메모리 누수에 유의하자. 특히 liveData!

|

프래그먼트의 복잡한 lifecycle

프래그먼트는 복잡한 lifecycle을 가지고 있다. 따라서 여러 프래그먼트를 사용하는 앱에서는 메모리 누수에 조심해야 한다.

우선 프래그먼트의 라이프사이클 그림을 보자.

image

각 프래그먼트의 역할은 공식문서에 잘 나와있기 때문에 따로 설명하지는 않지만 특별히 눈여겨 봐야할 메서드가 있다. 바로 onDestroyView()onDestroy()다.

onDestroyView()onDestroy()

onDestroyView()가 호출되면 프래그먼트 객체 자체는 사라지지 않고 메모리에 남아있다. 반면 onDestroy()가 호출되면 프래그먼트 객체가 파괴된다. 프래그먼트들을 계속 바꿔가며 사용할 경우를 생각해보자.

예를 들어 아래와 같이 하단 탭바에 여러개의 메뉴를 두고 각각의 프래그먼트를 생성하여 앱이 동작하는 경우 특별히 신경써주지 않으면 메모리가 누수되고 있다는 사실을 모르고 있을 확률이 높다.

image

앱을 켠 직후 1번 탭에 들어갔다고 가정하자. 일반적인 fragment 라이프사이클에 따라 프래그먼트 객체가 생성되고 view가 보여진다. 이 경우 onAttach()부터 onResume()까지가 호출되어 있다. 1번 프래그먼트 객체 주소값은 A1A1A1이다

그리고 이제 2번 탭을 눌러 2번 프래그먼트로 이동하자. 2번 객체가 생성되기 전에 1번 프래그먼트에서 onDestroyView()가 호출된다. 그러나 onDestroy()는 호출되지 않는다. 즉, 1번 프래그먼트 객체가 제거되지는 않았다는 뜻이며 오로지 1번 프래그먼트의 view만 파괴되었다는 뜻이다. 1번 뷰가 사라지고 2번 프래그먼트의 객체와 view가 라이프사이클에 따라 생성된다. 2번 프래그먼트 객체 주소값은 B2B2B2이다.

현재 우리는 2번 프래그먼트를 보고있는 상태다. 그러나 여전히 1번 프래그먼트의 객체(A1A1A1)는 살아있다.

문제점은 바로 이 지점이다. 현재 2번 프래그먼트에서 사용자와 상호작용 중인데, 1번 객체는 여전히 살아있다. 만약 1번 객체에서 LiveData를 구독하고 있다고 생각해보자. 프래그먼트의 라이프사이클 인지를 위해 lifecyclerOwner에다가 아무 생각 없이 this(프래그먼트 객체)를 바인딩 하고 있는가?

만약 그렇다면 1번 프래그먼트의 liveData는 사용자가 2번, 3번 프래그먼트에 머무르고 있을때도 지워지지 않고 존재하고 있는 셈이다.

메모리 누수인 셈이다.

LiveData는 lifecycleOwner가 DESTROYED 되어야 사라지기 때문이다.

onDestroy가 호출되는 시점

2번 메뉴에 머물다가 다시 1번 프래그먼트로 돌아갈 때 라이프사이클을 보자.

1번으로 돌아가는 순간 아래와 같은 라이프사이클이 호출된다.

  1. onCreate() 호출 : A2A2A2
  2. onCreateView() 호출 : A2A2A2
  3. onStart() 호출 : A2A2A2
  4. onResume() 호출 : A2A2A2
  5. onDestroy() 호출 : A1A1A1

마지막 부분에, 이전에 까지 유지되고 있던 1번 프래그먼트의 객체 A1A1A1가 파괴된다.

결론

프래그먼트에서 메모리 누수를 막기위해서 lifecycleOwner로 viewLifecycleOwner를 고려하자.

Transformations로 LiveData 변형하기

|

프로젝트 개발시 ViewModel을 사용함과 동시에 궁합이 잘 맞는 LiveData를 많이 사용한다. 그러나 Observavle을 위해 LiveData를 그 자체로만 사용한다면 LiveData의 편리함을 모두 누릴 수 없다. Transformations 클래스를 알아보면서 평소 생각없이 사용했던 LiveData를 더 가치있게 만들어보자.

Transformations

Transformations는 LiveData를 위한 클래스이기 때문에 당연히 AAC로 제공된다. 실제 패키지는 아래와 같다.

android.arch.lifecycle.Transformations

Transformations 클래스의 도움을 받으면 기본에 선언해 놓았던 LiveData를 변형시켜 개발자 입맛에 맞춰 사용할 수 있다. 공식문서에 소개되어있는 기능으로는 map과 switchMap이 있는데, 함수형을 알고있는 개발자라면 컬렉션에서 제공하는 map과 switchMap과 거의 동일하다고 보면 될것같다.

Transformations.map

Transformations.map을 알아보기에 앞서 일반 배열의 map을 보자.

val list1 = listOf(1,2,3)
val list2 = list1.map { it.times(2) }
println(list2) // [ 2, 4, 6 ]

코틀린 컬렉션에서 제공하는 map은 위에서 보듯이 요소 각각에 2를 곱해 새로운 List를 반환한다. 포인트는 새로운 List를 반환한다는 것이다.

Transformations.map역시 마찬가지다.

val userLiveData: MutableLiveData<User> = repository.getUser(id)
val userNameLiveData: LiveData<String> = Transformations.map(userLiveData) { user ->
    user.firstName + " " + user.lastName
}

공식문서의 코드 샘플을 약간 수정했다.

  • map의 첫 번째 인자로 LiveData source를 넘겨준다. 넘겨준 LiveData source 가 변경될 때마다 map이 반환하는 새로운 LiveData의 value역시 새롭게 갱신된다.

  • 두 번째 인자로는 함수를 넘겨준다. 함수의 파라미터 타입은 source로 넘겨준 LiveData의 value Type(<User>)이며 함수의 return값은 무엇이든 상관없다.

  • Transformations.map의 return 값(람다의 결과물 말고)은 LiveData이다. 기존 컬렉션의 map이 그러하듯 Transformations.map 역시 내용물 요소의 값만 변환 시킬 뿐 LiveData를 리턴한다.

직관적으로 보면 userNameLiveData는 그저 userLiveData의 User 이름만 추출하여 새롭게 만든 LiveData이다. 따라서 userLiveDatauserNameLiveData는 서로 독립적인 객체인 것처럼 느껴진다. 그러나 userNameLiveData의 value는 userLiveData의 value가 바뀔 때 마다 함께 갱신된다.

어떻게 그렇게 될까? Transformations.map의 내부적으로 MediatorLiveData를 사용하고 있기 때문이다.

MediatorLiveData를 간단히 설명하자면 Rx의 merge 함수와 비슷하다. 서로다른 data source(여기서는 LiveData)가 독립적으로 존재하는 상황에서, 각각의 데이터 소스들이 변경되는 것을 따로 따로 관찰하는 것이 아니라 어떤 소스에서 변경이 일어나든 한번에 관찰하려고 하는 것이다. 사실 다 필요 없고 공식문서 한 번 보면 다 이해된다. 공식문서를 보자ㅠㅠ.

참고로 Transformations.map의 내부 구현은 아래와 같다.

@MainThread
@NonNull
public static <X, Y> LiveData<Y> map(
        @NonNull LiveData<X> source,
        @NonNull final Function<X, Y> mapFunction) {
    final MediatorLiveData<Y> result = new MediatorLiveData<>();
    result.addSource(source, new Observer<X>() {
        @Override
        public void onChanged(@Nullable X x) {
            result.setValue(mapFunction.apply(x));
        }
    });
    return result;
}

Transformations.switchMap

Transformations.map을 이해했다면 switchMap는 90% 먹고들어간 셈이다.

 val userIdLiveData: MutableLiveData<Int> = MutableLiveData<Int>().apply { value = 1 };
 val userLiveData: LiveData<User> = Transformations.switchMap(userIdLiveData) { id ->
     repository.getUserById(id)
 }

 fun setUserId(userId: Int) {
      userIdLiveData.setValue(userId);
 }

image

  • switchMap의 첫 번째 인자로 LiveData source를 넘겨준다. 넘겨준 LiveData source 가 변경될 때마다 switchMap이 반환하는 새로운 LiveData의 value역시 새롭게 갱신된다.

  • 두 번째 인자로는 함수를 넘겨준다. 함수의 파라미터 타입은 source로 넘겨준 LiveData의 value Type(<Int>)이며 함수의 return값은 LiveData이어야만 한다.

map과 다른점은 람다 함수의 return값이 LiveData여야 한다는 것이다. map의 경우 람다함수의 return값이 각 요소의 값들을 변경시키는 것에 불과하며 자동으로 LiveData가 되어서 결과물이 반환되었지만, switchMap의 경우 실제로 LiveData 하나를 반환해야 한다. 그래서 switchMap은 Model단이나 Room데이터베이스와 같이 애초에 LiveData를 반환하는 기능들과 자주 함께 쓰인다.

userLiveData의 value역시 userIdLiveData의 value가 바뀌면 자동으로 갱신되는데 map과 마찬가지로 내부적으로 MediatorLiveData가 사용되기 때문이다.

내부 구현은 아래와 같다.

@MainThread
@NonNull
public static <X, Y> LiveData<Y> switchMap(
        @NonNull LiveData<X> source,
        @NonNull final Function<X, LiveData<Y>> switchMapFunction) {
    final MediatorLiveData<Y> result = new MediatorLiveData<>();
    result.addSource(source, new Observer<X>() {
        LiveData<Y> mSource;

        @Override
        public void onChanged(@Nullable X x) {
            LiveData<Y> newLiveData = switchMapFunction.apply(x);
            if (mSource == newLiveData) {
                return;
            }
            if (mSource != null) {
                result.removeSource(mSource);
            }
            mSource = newLiveData;
            if (mSource != null) {
                result.addSource(mSource, new Observer<Y>() {
                    @Override
                    public void onChanged(@Nullable Y y) {
                        result.setValue(y);
                    }
                });
            }
        }
    });
    return result;
}

더 좋은 자료

코루틴을 이해하기 위한 발악 2편

|

틈틈히 코루틴 자료들을 찾아 보면서 내가 왜 코루틴을 이해하는데 이렇게 오래걸리는지에 대해 깨달은 점이 있다. 머릿속에서 나도 모르게 자꾸만 쓰레드가 떠오르기 때문이었다.

코루틴을 공부할 때는 쓰레드에 엮이면 안된다. 쓰레드에 대한 개념은 쿨하게 버리고 코루틴을 새로운 개념으로 공부해야 했다.

하지만 코루틴이라는 개념을 처음 접하는 입장에서 동시성(concurrency)을 이해하려면, 당연히 쓰레드부터 떠올라 쉬운 일이 아니다. 심지어는 쓰레드를 떠올리는게 잘못 된것이라는 생각조차 들지 않았다.

이제부터는, 코루틴 자료들을 보면 여전히 쓰레드 관련 이야기가 많이 나오겠지만, 코루틴 자체를 이해하기 위해 코루틴을 쓰레드로 대체해서 생각하거나 엮어서 생각하지 말자.

코루틴은 코루틴이다.

동시성(concurrency)과 병렬성(parallelism)

동시성(concurrency)은 병렬성(parallelism)이 아니다.

코루틴을 공부하기에 앞서 짚고 넘어갈 간단한 OS지식이다.

동시성이란 프로그램이 마치 여러가지 일을 동시에 하는 것 처럼 느껴지게 하기 위하여 여러 쓰레드를 아주 잘게 쪼개어 시분할을 하는 것이다.

쓰레드

thread-concurrency

운영체제의 쓰레드 스케쥴러(Thread Scheduler)는 한 번에 한가지 쓰레드만 처리할 수 있다. 만약 코드에서 쓰레드를 5개 생성했다고 가정하자. 각 쓰레드가 가지는 작업들 마다 걸리는 시간이 모두 다르다. 이 5개의 작업들을 마치 동시에 실행하는 것처럼 하기 위해 짧은 시간동안 쓰레드1을 실행, 그 다음 쓰레드를 바꿔 아주 짧은 시간동안 쓰레드 2를 실행, 같은 방법으로 쓰레드 3을 실행… 을 반복하고 다시 쓰레드 1을 반복한다. 사실 한 번에 한 개의 작업만을 하는 것은 다름 없으나 마치 동시에 실행되고 있는 것 같다.

위의 설명이 쓰레드를 사용한 동시성 프로그래밍이다. 언제 어떤 쓰레드를 몇 초 동안 실행시킬지, 그리고 이전 쓰레드를 멈추고나서 다음은 어떤 작업 쓰레드를 실행시킬지 등등을 모두 OS가 관리한다. 참고로 쓰레드를 이렇게 자주 변경하는 것은 많은 리소스가 필요한 무거운 작업이다.

한편 최근에는 하나의 CPU가 여러개의 core를 가지고 있다. B쓰레드가 CPU core2 에서 돌아가는 동안, A쓰레드는 CPU core1에서 돌아갈 수 있다. 정말 실제로 두 작업이 동시에 실행되는 것이다. 이게 병렬성이다.

동시성과 병렬성은 엄연히 다른 개념이다.

코루틴

코루틴의 동시성 프로그래밍을 보자. 코루틴-동시성

코루틴은 개념적으로 쓰레드와 비슷하다. 경량 쓰레드라고도 불린다.

나는 이말에 집착한 나머지 자꾸 쓰레드 거의 동일한 개념으로 생각하려 했기에 이해가 많이 늦었다. 코루틴을 경량 쓰레드라고 표현한 것은, 실제 쓰레드는 아니지만 결과적으로 목적이 쓰레드와 같은 반면 더 성능이 좋고 가볍기 때문에 붙여진 별명이라고 생각한다. 조금 더 심도 있게 들어가고싶다면 Difference between thread and coroutine in Kotlin 를 참고 하자.

경량쓰레드라니

코루틴 관련 자료들 대부분이 코루틴을 여러개 만들면서 동시성 프로그래밍을 선보이고 있다. 내가 알고 있던 동시성 프로그래밍 방법은 글 윗부분에서 설명한 쓰레드를 사용한 방법 뿐이었다. 무조건 그 방법밖에 없는줄 알았기 때문에 코루틴으로 동시성이 가능한 것을 보고는 코루틴도 결국 쓰레드들이라고 생각했었다. 그러나 코루틴은 쓰레드 위에서 돌아가긴 하더라도 특정 쓰레드에 종속적이지 않을 수 있다.

코루틴은 그저 하나의 쓰레드(혹은 스케줄러)위에서 실행이 시작될 수가 있다. 하나의 쓰레드에 코루틴이 여러개 존재할 수가 있는데, 실행중이던 하나의 코루틴이 suspend(멈춤)되면, 현재 쓰레드에서 resume(재개)할 다른 코루틴을 찾는다. 다른 쓰레드에서 찾는게 아니라 같은 쓰레드에서 찾는것이다(물론 추후에 다루겠지만 다른 쓰레드에서 resume할 수도 있다). 따라서 쓰레드를 switch하는데 드는 overhead가 없다.

또 하나, 위에서 말한 suspend, resume 등등을 모두 개발자가 직접 컨트롤 할 수 있다. 여러 작업을 가지고 동시성 프로그래밍을 할 때 모두 OS가 컨트롤 했던 쓰레드 방식과는 다르다. Thread 방식의 경우 Thread 캐치를 CPU idea 상태에 따라서 알아서 처리하는대신 이걸 개발자가 직접 처리할 수 있는게 코루틴이다. 이점이 꽤나 큰 장점이라고 한다.

헷갈릴수 있는것이, 코루틴의 경우 절차적 프로그래밍 처럼

method1()
method2()

순서로 코드를 작성했지만 실제 실행될 때 method2(), method1()순서로 실행될 수가 있다. 쓰레드 2개로 분리되어 순서가 변경된게 아니라, 정말 하나의 쓰레드에서 돌아가는데 실행 순서만 바뀌는 것이다(물론 개발자 의도 하에). 컴파일 될때 컴파일러가 그런 순서로 동작 될수 있는 구조가 되도록 코드가 추가된다고 보면 될 것 같다.

하나의 코루틴 멈춤(suspend)에 대한 간단한 예시

코틀린 웹 서버 프레임워크인 Ktor에는 http 요청 함수인 HttpClient.post가 있다. 이 함수는 결과 값을 반환하기 전에 결과를 기다릴 시간이 필요한 함수이다. 즉 잠시 멈추었다가 결과값이 네트워크를 타고 돌아오면 그떄서야 실행해야할 함수, suspend(일시적으로 멈출) 함수인 것이다.

그래서 간단히 suspend 키워드로 표시된다.

suspend fun getReturnValueFromServer(): SomeType {
 ...
}

즉 코드가 suspend function을 호출하는 순간 해당 코루틴을 잠시 중단 시켜놓을 수 있으며 결과값이 왔을 때 이 함수를 다시 resume할 수 있다. 또한 함수가 suspend되었을 시점에 즉시 다른 resume가능한 코루틴을 찾아 그 코루틴을 실행하다가, getReturnValueFromServer()가 결과값을 들고 나타나면 다시 resume할 수 있다.

마무리

코루틴은 동시성 뿐만 아니라 병렬 실행 역시 가능하게 한다. 또한 메인 쓰레드에서만 쓰레드를 돌리는게 아니라 네트워크 요청같은 경우 다른 쓰레드를 사용하기도 하는데, 이러한 내용들은 코루틴을 이해한 다음에 +alpha가 되야하는 부분이라서 다루지 않았다. 어느정도 코루틴에 대한 이해와 감이 잡혔으니 다음 발악때는 조금더 깊고 넓은 사용법을 알아봐야겠다.