쾌락코딩

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

Comments